Skip to content

Commit c192ca8

Browse files
committed
Improve styling of Mastodon Social Share modal
1 parent 5cd8ba0 commit c192ca8

6 files changed

Lines changed: 220 additions & 94 deletions

File tree

src/components/Social/Mastodon/client/__tests__/index.spec.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,31 +112,91 @@ describe('MastodonModalElement', () => {
112112
'Share to Mastodon'
113113
)
114114

115+
const mastodonHeaderIcon = element.querySelector(
116+
'.modal-content .flex.items-center.gap-3 svg'
117+
) as SVGElement | null
118+
expect(mastodonHeaderIcon).toBeTruthy()
119+
expect(mastodonHeaderIcon?.getAttribute('aria-hidden')).toBe('true')
120+
expect(mastodonHeaderIcon?.getAttribute('focusable')).toBe('false')
121+
122+
const modalContent = element.querySelector('.modal-content') as HTMLDivElement | null
123+
const form = element.querySelector('#mastodon-share-form') as HTMLFormElement | null
124+
expect(modalContent?.className).toContain('bg-page-base')
125+
expect(form?.className).toContain('bg-page-base')
126+
127+
const title = element.querySelector(`#${element.modalId}-title`) as HTMLHeadingElement | null
128+
expect(title?.className).toContain('text-content-inverse')
129+
115130
const closeIcon = element.querySelector(
116131
'button[aria-label="Close modal"] svg'
117132
) as SVGElement | null
118133
expect(closeIcon?.getAttribute('aria-hidden')).toBe('true')
119134
expect(closeIcon?.getAttribute('focusable')).toBe('false')
120135

136+
const closeButton = element.querySelector(
137+
'button[aria-label="Close modal"]'
138+
) as HTMLButtonElement | null
139+
expect(closeButton?.className).toContain('text-primary-inverse')
140+
expect(closeButton?.className).toContain('focus-visible:outline-none')
141+
expect(closeButton?.className).toContain('focus-visible:after:border-spotlight')
142+
expect(closeButton?.className).toContain('after:rounded-none')
143+
144+
const shareText = element.querySelector('#share-text') as HTMLTextAreaElement | null
145+
expect(shareText?.className).toContain('outline-none')
146+
expect(shareText?.className).toContain('focus-visible:outline-none')
147+
expect(shareText?.className).toContain('focus-visible:ring-0')
148+
expect(shareText?.className).toContain('focus-visible:shadow-none')
149+
150+
const shareTextWrapper = shareText?.parentElement as HTMLDivElement | null
151+
expect(shareTextWrapper?.className).toContain('after:rounded-none')
152+
expect(shareTextWrapper?.className).toContain('after:border-transparent')
153+
121154
const instanceInput = element.querySelector('#mastodon-instance') as HTMLInputElement | null
122155
expect(instanceInput?.getAttribute('aria-describedby')).toBe(
123156
`${element.modalId}-instance-hint`
124157
)
125158
const hint = element.querySelector(`#${element.modalId}-instance-hint`) as HTMLElement | null
126159
expect(hint?.textContent).toContain('Enter only the domain')
160+
161+
const instanceWrapper = instanceInput?.closest('div') as HTMLDivElement | null
162+
expect(instanceInput?.className).toContain('focus-visible:outline-none')
163+
expect(instanceInput?.className).toContain('focus-visible:ring-0')
164+
expect(instanceInput?.className).toContain('focus-visible:shadow-none')
165+
expect(instanceWrapper?.className).toContain('after:rounded-none')
166+
expect(instanceWrapper?.className).toContain('after:border-transparent')
167+
expect(instanceWrapper?.className).not.toContain('after:border-spotlight')
168+
169+
const rememberCheckbox = element.querySelector('#remember-instance') as HTMLInputElement | null
170+
expect(rememberCheckbox?.className).toContain('focus-visible:outline-none')
171+
expect(rememberCheckbox?.className).toContain('focus-visible:after:border-spotlight')
172+
expect(rememberCheckbox?.className).toContain('after:rounded-none')
173+
174+
const cancelButton = element.querySelector('button.modal-cancel') as HTMLButtonElement | null
175+
const shareButton = element.querySelector('button[type="submit"]') as HTMLButtonElement | null
176+
expect(cancelButton?.className).toContain('focus-visible:outline-none')
177+
expect(cancelButton?.className).toContain('focus-visible:after:border-spotlight')
178+
expect(cancelButton?.className).toContain('after:rounded-none')
179+
expect(shareButton?.className).toContain('focus-visible:outline-none')
180+
expect(shareButton?.className).toContain('focus-visible:after:border-spotlight')
181+
expect(shareButton?.className).toContain('after:rounded-none')
182+
expect(shareButton?.className).toContain('text-content-inverse')
127183
})
128184
})
129185

130186
test('openModal shows modal and populates text', async () => {
131-
await renderModal(async ({ element }) => {
187+
await renderModal(async ({ element, window }) => {
132188
element.openModal('Highlight text to share')
133189
await flushMicrotasks()
134190

135191
const dialog = element.querySelector('[role="dialog"]') as HTMLElement | null
136192
const textarea = element.querySelector('#share-text') as HTMLTextAreaElement | null
193+
const instanceInput = element.querySelector('#mastodon-instance') as HTMLInputElement | null
194+
const instanceWrapper = instanceInput?.closest('div') as HTMLDivElement | null
137195

138196
expect(dialog?.hasAttribute('hidden')).toBe(false)
139197
expect(textarea?.value.trim()).toBe('Highlight text to share')
198+
expect(window.document.activeElement).toBe(instanceInput)
199+
expect(instanceWrapper?.className).not.toContain('after:border-spotlight')
140200
})
141201
})
142202

src/components/Social/Mastodon/client/index.ts

Lines changed: 135 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ export class MastodonModalElement extends LitElement {
5959
open: { type: Boolean, reflect: true },
6060
shareText: { type: String, attribute: false },
6161
instanceValue: { type: String, attribute: false },
62+
shareTextFocusVisible: { type: Boolean, attribute: false },
63+
suppressInitialInstanceFocusVisible: { type: Boolean, attribute: false },
64+
instanceInputFocusVisible: { type: Boolean, attribute: false },
6265
rememberInstance: { type: Boolean, attribute: false },
6366
savedInstances: { attribute: false },
6467
statusMessage: { type: String, attribute: false },
@@ -70,6 +73,9 @@ export class MastodonModalElement extends LitElement {
7073
declare open: boolean
7174
declare shareText: string
7275
declare instanceValue: string
76+
declare shareTextFocusVisible: boolean
77+
declare suppressInitialInstanceFocusVisible: boolean
78+
declare instanceInputFocusVisible: boolean
7379
declare rememberInstance: boolean
7480
declare statusMessage: string
7581
declare statusType: 'error' | 'success' | ''
@@ -86,6 +92,9 @@ export class MastodonModalElement extends LitElement {
8692
this.open = false
8793
this.shareText = ''
8894
this.instanceValue = ''
95+
this.shareTextFocusVisible = false
96+
this.suppressInitialInstanceFocusVisible = false
97+
this.instanceInputFocusVisible = false
8998
this.rememberInstance = false
9099
this.savedInstances = []
91100
this.statusMessage = ''
@@ -183,6 +192,9 @@ export class MastodonModalElement extends LitElement {
183192
this.statusMessage = ''
184193
this.statusType = ''
185194
this.isSubmitting = false
195+
this.shareTextFocusVisible = false
196+
this.suppressInitialInstanceFocusVisible = true
197+
this.instanceInputFocusVisible = false
186198
this.rememberInstance = false
187199

188200
const savedInstance = getCurrentMastodonInstance() ?? this.savedInstances.at(0) ?? ''
@@ -229,6 +241,9 @@ export class MastodonModalElement extends LitElement {
229241
this.statusMessage = ''
230242
this.statusType = ''
231243
this.isSubmitting = false
244+
this.shareTextFocusVisible = false
245+
this.suppressInitialInstanceFocusVisible = false
246+
this.instanceInputFocusVisible = false
232247
this.rememberInstance = false
233248

234249
if (typeof window !== 'undefined') {
@@ -313,6 +328,56 @@ export class MastodonModalElement extends LitElement {
313328
}
314329
}
315330

331+
private updateFocusVisibleState(
332+
element: HTMLInputElement | HTMLTextAreaElement,
333+
applyState: (_isFocusVisible: boolean) => void
334+
): void {
335+
const scheduleUpdate =
336+
typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function'
337+
? window.requestAnimationFrame.bind(window)
338+
: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0)
339+
340+
scheduleUpdate(() => {
341+
if (typeof document === 'undefined' || document.activeElement !== element) {
342+
return
343+
}
344+
345+
applyState(element.matches(':focus-visible'))
346+
})
347+
}
348+
349+
private handleShareTextFocus(event: FocusEvent): void {
350+
const target = event.target
351+
if (target instanceof HTMLTextAreaElement) {
352+
this.updateFocusVisibleState(target, isFocusVisible => {
353+
this.shareTextFocusVisible = isFocusVisible
354+
})
355+
}
356+
}
357+
358+
private handleShareTextBlur(): void {
359+
this.shareTextFocusVisible = false
360+
}
361+
362+
private handleInstanceFocus(event: FocusEvent): void {
363+
const target = event.target
364+
if (target instanceof HTMLInputElement) {
365+
if (this.suppressInitialInstanceFocusVisible) {
366+
this.suppressInitialInstanceFocusVisible = false
367+
this.instanceInputFocusVisible = false
368+
return
369+
}
370+
371+
this.updateFocusVisibleState(target, isFocusVisible => {
372+
this.instanceInputFocusVisible = isFocusVisible
373+
})
374+
}
375+
}
376+
377+
private handleInstanceBlur(): void {
378+
this.instanceInputFocusVisible = false
379+
}
380+
316381
protected override render() {
317382
const modalTitleId = `${this.modalId}-title`
318383
const instanceHintId = `${this.modalId}-instance-hint`
@@ -324,6 +389,14 @@ export class MastodonModalElement extends LitElement {
324389
root: document,
325390
})
326391
: null
392+
const mastodonIconMarkup =
393+
typeof document !== 'undefined'
394+
? queryMastodonIconMarkup({
395+
iconBankId: ICON_BANK_ID,
396+
iconName: 'mastodon',
397+
root: document,
398+
})
399+
: null
327400

328401
return html`
329402
<div
@@ -338,14 +411,17 @@ export class MastodonModalElement extends LitElement {
338411
class="modal-backdrop absolute inset-0 bg-black/50 backdrop-blur-sm"
339412
@click=${(event: Event) => this.handleBackdropClick(event)}
340413
></div>
341-
<div
342-
class="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-auto bg-content-inverse rounded-lg shadow-2xl"
343-
>
344-
<div class="flex items-center justify-between p-6 border-b border-trim">
345-
<h2 id=${modalTitleId} class="m-0 text-xl font-semibold">Share to Mastodon</h2>
414+
<div class="modal-content relative w-full max-w-2xl max-h-[90vh] overflow-auto rounded-xl bg-page-base shadow-2xl">
415+
<div class="bg-page-inverse px-6 py-5 flex items-center justify-between">
416+
<div class="flex items-center gap-3 text-content-inverse">
417+
${mastodonIconMarkup ? unsafeHTML(mastodonIconMarkup) : null}
418+
<h2 id=${modalTitleId} class="m-0 text-lg font-bold text-content-inverse">
419+
Share to Mastodon
420+
</h2>
421+
</div>
346422
<button
347423
type="button"
348-
class="modal-close flex items-center justify-center w-8 h-8 p-0 border-0 bg-transparent rounded cursor-pointer transition-all duration-150 hover:bg-content-inverse-muted hover:text-content"
424+
class="modal-close relative flex items-center justify-center w-8 h-8 rounded-full bg-white/20 border-0 cursor-pointer text-primary-inverse transition-colors hover:bg-white/30 focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1 focus-visible:after:border-spotlight"
349425
aria-label="Close modal"
350426
@click=${() => this.closeModal()}
351427
>
@@ -355,32 +431,42 @@ export class MastodonModalElement extends LitElement {
355431
356432
<form
357433
id="mastodon-share-form"
358-
class="flex flex-col gap-6 p-6"
434+
class="flex flex-col gap-5 p-6 bg-page-base"
359435
@submit=${(event: Event) => this.handleSubmit(event)}
360436
>
361437
<div class="flex flex-col gap-2">
362-
<label for="share-text" class="font-medium">Text to share</label>
363-
<textarea
364-
id="share-text"
365-
name="text"
366-
rows="4"
367-
readonly
368-
class="w-full p-3 border border-trim rounded-md bg-content-inverse-muted font-[inherit] text-sm leading-6 resize-y focus:outline-2 focus:outline-primary focus:outline-offset-2"
369-
.value=${this.shareText}
370-
></textarea>
438+
<label for="share-text" class="text-sm font-medium text-content-offset uppercase tracking-wider">
439+
Text to share
440+
</label>
441+
<div
442+
class=${`relative after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent ${this.shareTextFocusVisible ? 'after:-inset-1 after:border-spotlight' : ''}`}
443+
>
444+
<textarea
445+
id="share-text"
446+
name="text"
447+
rows="4"
448+
readonly
449+
class="w-full p-3 border border-trim-offset rounded-lg bg-page-offset font-[inherit] text-sm leading-6 resize-y outline-none focus:border-trim-offset focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus-visible:border-trim-offset focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
450+
.value=${this.shareText}
451+
@focus=${(event: FocusEvent) => this.handleShareTextFocus(event)}
452+
@blur=${() => this.handleShareTextBlur()}
453+
></textarea>
454+
</div>
371455
</div>
372456
373457
<div class="flex flex-col gap-4">
374458
<label for="mastodon-instance" class="flex flex-col gap-2 font-medium">
375-
<span>Mastodon Instance</span>
459+
<span class="text-sm font-medium text-content-offset uppercase tracking-wider">
460+
Instance
461+
</span>
376462
<span id=${instanceHintId} class="sr-only"
377463
>Enter only the domain, without https://</span
378464
>
379465
<div
380-
class="flex items-stretch border border-trim rounded-md overflow-hidden bg-content-inverse-input focus-within:outline-2 focus-within:outline-primary focus-within:outline-offset-2"
466+
class=${`relative flex items-stretch border border-trim-offset rounded-lg overflow-hidden bg-page-base after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent ${this.instanceInputFocusVisible ? 'after:-inset-1 after:border-spotlight' : ''}`}
381467
>
382468
<span
383-
class="flex items-center px-3 py-2 bg-content-inverse-muted text-sm select-none"
469+
class="flex items-center px-3 py-2 bg-page-offset text-sm text-content-offset select-none"
384470
aria-hidden="true"
385471
>
386472
https://
@@ -392,8 +478,10 @@ export class MastodonModalElement extends LitElement {
392478
placeholder="mastodon.social"
393479
required
394480
aria-describedby=${instanceHintId}
395-
class="flex-1 min-w-0 px-3 py-2 border-0 bg-transparent text-base text-content focus:outline-none"
481+
class="flex-1 min-w-0 px-3 py-2 border-0 bg-transparent text-base text-content outline-none focus:border-0 focus:outline-none focus:ring-0 focus:ring-offset-0 focus:shadow-none focus-visible:border-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:shadow-none"
396482
.value=${this.instanceValue}
483+
@focus=${(event: FocusEvent) => this.handleInstanceFocus(event)}
484+
@blur=${() => this.handleInstanceBlur()}
397485
@input=${(event: Event) => this.handleInstanceInput(event)}
398486
/>
399487
</div>
@@ -417,30 +505,30 @@ export class MastodonModalElement extends LitElement {
417505
</div>`
418506
: null}
419507
420-
<label for="remember-instance" class="flex items-center gap-2 text-sm cursor-pointer">
508+
<label for="remember-instance" class="flex items-center gap-2 text-sm cursor-pointer text-content-offset">
421509
<input
422510
type="checkbox"
423511
id="remember-instance"
424512
name="remember"
425-
class="w-4 h-4 cursor-pointer"
513+
class="relative w-4 h-4 cursor-pointer accent-accent focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent focus-visible:after:-inset-1 focus-visible:after:border-spotlight"
426514
.checked=${this.rememberInstance}
427515
@change=${(event: Event) => this.handleRememberChange(event)}
428516
/>
429517
<span>Remember this instance</span>
430518
</label>
431519
</div>
432520
433-
<div class="flex gap-3 justify-end">
521+
<div class="flex gap-3 justify-end pt-2">
434522
<button
435523
type="button"
436-
class="btn-secondary modal-cancel px-4 py-2 border border-trim rounded-md font-medium cursor-pointer transition-all duration-150 bg-transparent text-content hover:bg-content-inverse-muted"
524+
class="btn-secondary modal-cancel relative px-5 py-2.5 border border-trim-offset rounded-lg font-medium cursor-pointer bg-transparent text-content hover:bg-page-offset transition-colors focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1 focus-visible:after:border-spotlight"
437525
@click=${() => this.closeModal()}
438526
>
439527
Cancel
440528
</button>
441529
<button
442530
type="submit"
443-
class="btn-primary px-4 py-2 border-0 rounded-md font-medium cursor-pointer transition-all duration-150 bg-spotlight text-white hover:bg-primary-offset disabled:opacity-50 disabled:cursor-not-allowed"
531+
class="btn-primary relative px-5 py-2.5 border-0 rounded-lg font-medium cursor-pointer bg-page-inverse text-content-inverse hover:bg-content-active transition-colors focus-visible:outline-none after:pointer-events-none after:absolute after:content-[''] after:inset-0 after:rounded-none after:border-2 after:border-transparent after:opacity-0 after:transition-opacity after:duration-150 after:ease-out focus-visible:after:opacity-100 focus-visible:after:-inset-1 focus-visible:after:border-spotlight disabled:opacity-50 disabled:cursor-not-allowed"
444532
?disabled=${this.isSubmitting}
445533
>
446534
Share
@@ -467,14 +555,36 @@ export class MastodonModalElement extends LitElement {
467555
cursor: pointer;
468556
font-size: 0.875rem;
469557
padding: 0.25rem 0.5rem;
558+
position: relative;
470559
transition: all 0.15s ease;
471560
}
472561
562+
.saved-list :global(.saved-instance)::after {
563+
border: 2px solid transparent;
564+
border-radius: 0;
565+
content: '';
566+
inset: 0;
567+
opacity: 0;
568+
pointer-events: none;
569+
position: absolute;
570+
transition: opacity 0.15s ease;
571+
}
572+
473573
.saved-list :global(.saved-instance:hover) {
474574
background: var(--color-primary);
475575
color: var(--color-page-base);
476576
}
477577
578+
.saved-list :global(.saved-instance:focus-visible) {
579+
outline: none;
580+
}
581+
582+
.saved-list :global(.saved-instance:focus-visible)::after {
583+
border-color: var(--color-spotlight);
584+
inset: -0.25rem;
585+
opacity: 1;
586+
}
587+
478588
.modal-status.error {
479589
color: var(--color-danger);
480590
}

0 commit comments

Comments
 (0)