Skip to content

Commit 9a3b85f

Browse files
committed
Fix Social Highlighter functionality
1 parent 70555e8 commit 9a3b85f

9 files changed

Lines changed: 461 additions & 107 deletions

File tree

package-lock.json

Lines changed: 287 additions & 83 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
"dependencies": {
7676
"@adobe/remark-gridtables": "^3.0.19",
7777
"@astrojs/check": "0.9.8",
78+
"@astrojs/compiler-rs": "0.1.6",
7879
"@astrojs/db": "^0.20.1",
7980
"@astrojs/mdx": "5.0.3",
8081
"@astrojs/preact": "5.1.1",
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { isType1Element } from '@components/scripts/assertions/elements'
2+
3+
export const SELECTORS = {
4+
analyticsState: 'vercel-analytics-state:last-of-type',
5+
} as const
6+
7+
export const queryAnalyticsStateElement = (root: ParentNode = document): HTMLElement | null => {
8+
const element = root.querySelector(SELECTORS.analyticsState)
9+
return isType1Element(element) && element instanceof HTMLElement ? element : null
10+
}

src/components/Analytics/index.astro

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,10 @@ const pathname = Astro.url.pathname
1313
></vercel-analytics-state>
1414

1515
<script>
16+
import { queryAnalyticsStateElement } from './client/selectors'
1617
import { computeRoute, inject, pageview } from '@vercel/analytics'
1718

18-
const analyticsState = document.querySelector(
19-
'vercel-analytics-state:last-of-type'
20-
) as HTMLElement | null
19+
const analyticsState = queryAnalyticsStateElement()
2120
const pathname = analyticsState?.dataset['pathname'] ?? window.location.pathname
2221
const params = JSON.parse(analyticsState?.dataset['params'] ?? '{}')
2322
const mode = analyticsState?.dataset['mode'] === 'development' ? 'development' : 'production'

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ const mockPlatforms = vi.hoisted<MockPlatform[]>(() => [
3939
getShareUrl: ({ text, url }) =>
4040
`https://social.example/bluesky?text=${encodeURIComponent(`${text ?? ''} ${url ?? ''}`.trim())}`,
4141
},
42+
{
43+
id: 'reddit',
44+
ariaLabel: 'Share on Reddit',
45+
icon: 'reddit',
46+
getShareUrl: ({ url }) =>
47+
`https://social.example/reddit?url=${encodeURIComponent(url ?? '')}`,
48+
},
4249
{
4350
id: 'mastodon',
4451
ariaLabel: 'Share on Mastodon',
@@ -169,6 +176,8 @@ describe('HighlighterElement', () => {
169176
const dialog = element.querySelector('.share-dialog') as HTMLElement | null
170177
expect(dialog?.getAttribute('role')).toBe('toolbar')
171178
expect(dialog?.querySelector('.share-dialog__text')?.textContent).toBe('Share Selection')
179+
expect(element.querySelectorAll('site-tooltip')).toHaveLength(mockPlatforms.length)
180+
expect(Array.from(element.querySelectorAll('.share-button')).every(button => !button.hasAttribute('title'))).toBe(true)
172181

173182
const describedBy = trigger?.getAttribute('aria-describedby')
174183
expect(describedBy).toBeTruthy()
@@ -247,6 +256,40 @@ describe('HighlighterElement', () => {
247256
})
248257
})
249258

259+
test('does not use native share on desktop browsers', async () => {
260+
await renderHighlighter(async ({ element, window }) => {
261+
const shareSpy = vi.fn().mockResolvedValue(undefined)
262+
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
263+
;(window.navigator as Navigator & { share?: typeof shareSpy }).share = shareSpy
264+
window.matchMedia = vi.fn().mockReturnValue({ matches: false }) as typeof window.matchMedia
265+
266+
getShareButton(element, 'reddit').click()
267+
await flushMicrotasks()
268+
269+
expect(shareSpy).not.toHaveBeenCalled()
270+
expect(openSpy).toHaveBeenCalledWith(
271+
expect.stringContaining('https://social.example/reddit'),
272+
'_blank',
273+
'noopener,noreferrer'
274+
)
275+
openSpy.mockRestore()
276+
})
277+
})
278+
279+
test('passes unquoted share text to Mastodon modal', async () => {
280+
await renderHighlighter(async ({ element }) => {
281+
getShareButton(element, 'mastodon').click()
282+
await flushMicrotasks()
283+
284+
expect(mockMastodonModal.openModal).toHaveBeenCalledWith(
285+
expect.stringContaining(`${defaultProps.content} http://localhost/`)
286+
)
287+
expect(mockMastodonModal.openModal).not.toHaveBeenCalledWith(
288+
expect.stringContaining(`"${defaultProps.content}"`)
289+
)
290+
})
291+
})
292+
250293
test('opens Mastodon modal and emits share event', async () => {
251294
await renderHighlighter(async ({ element }) => {
252295
const shareListener = vi.fn()

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

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
* Uses LoadableScript pattern for optimized loading
55
*/
66

7-
import type { ShareData } from '@components/Social/common'
7+
import type { ShareData, SharePlatform } from '@components/Social/common'
88
import { platforms, nativeShare } from '@components/Social/common'
99
import { MastodonModal } from '@components/Social/Mastodon/client'
10+
import { initializeTooltipHost } from '@components/Tooltip/client'
1011
import { addScriptBreadcrumb } from '@components/scripts/errors'
1112
import { handleScriptError } from '@components/scripts/errors/handler'
1213
import {
@@ -25,11 +26,16 @@ import {
2526
queryShareButtons,
2627
queryShareDialog,
2728
queryShareIcon,
29+
queryShareTooltipHosts,
2830
} from './selectors'
2931

3032
const SCRIPT_NAME = 'Highlighter'
3133
const COMPONENT_TAG_NAME = 'highlighter-element'
3234
const ICON_BANK_ID = 'highlighter-icon-bank'
35+
const TOOLTIP_TRIGGER_CLASSES =
36+
'inline-flex items-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-spotlight'
37+
const TOOLTIP_POPUP_CLASSES =
38+
'pointer-events-none absolute left-1/2 top-full z-(--z-content-floating) mt-2 hidden -translate-x-1/2 max-w-64 rounded-md border border-trim bg-page-inverse px-2 py-1 text-sm leading-tight text-page-base shadow-elevated whitespace-nowrap'
3339

3440
let highlighterInstanceCounter = 0
3541
const VISIBLE_SHARE_LABEL = 'Share Selection'
@@ -100,13 +106,15 @@ export class HighlighterElement extends LitElement {
100106
this.bindTriggerButton()
101107
this.bindWrapperListeners()
102108
this.bindShareButtons()
109+
this.initializeShareTooltips()
103110
this.applyThemeStyles()
104111
}
105112

106113
protected override updated(): void {
107114
this.bindTriggerButton()
108115
this.bindWrapperListeners()
109116
this.bindShareButtons()
117+
this.initializeShareTooltips()
110118
this.applyThemeStyles()
111119
}
112120

@@ -144,19 +152,7 @@ export class HighlighterElement extends LitElement {
144152
>
145153
<span class="share-dialog__text">${VISIBLE_SHARE_LABEL}</span>
146154
<span class="share-dialog__buttons">
147-
${platforms.map(
148-
platform => html`
149-
<button
150-
type="button"
151-
class="share-button"
152-
data-platform="${platform.id}"
153-
aria-label="${platform.ariaLabel}"
154-
title="${platform.ariaLabel}"
155-
>
156-
${this.renderPlatformIcon(platform.id)}
157-
</button>
158-
`
159-
)}
155+
${platforms.map(platform => this.renderShareButton(platform))}
160156
</span>
161157
<span class="share-dialog__arrow"></span>
162158
</span>
@@ -268,6 +264,31 @@ export class HighlighterElement extends LitElement {
268264
return iconMarkup ? unsafeHTML(iconMarkup) : null
269265
}
270266

267+
private renderShareButton(platform: SharePlatform) {
268+
return html`
269+
<site-tooltip class="relative inline-flex">
270+
<span data-tooltip-trigger class="${TOOLTIP_TRIGGER_CLASSES}">
271+
<button
272+
type="button"
273+
class="share-button"
274+
data-platform="${platform.id}"
275+
aria-label="${platform.ariaLabel}"
276+
>
277+
${this.renderPlatformIcon(platform.id)}
278+
</button>
279+
</span>
280+
<span
281+
data-tooltip-popup
282+
role="tooltip"
283+
aria-hidden="true"
284+
class="${TOOLTIP_POPUP_CLASSES}"
285+
>
286+
${platform.ariaLabel}
287+
</span>
288+
</site-tooltip>
289+
`
290+
}
291+
271292
private getIconMarkup(iconName: string): string | null {
272293
if (!iconName) {
273294
return null
@@ -306,6 +327,11 @@ export class HighlighterElement extends LitElement {
306327
}
307328
}
308329

330+
private initializeShareTooltips(): void {
331+
const tooltipHosts = queryShareTooltipHosts(this)
332+
tooltipHosts.forEach(host => initializeTooltipHost(host))
333+
}
334+
309335
private applyFocusVisibleStyles(): void {
310336
if (!this.triggerButton) {
311337
return
@@ -403,12 +429,28 @@ export class HighlighterElement extends LitElement {
403429
private getShareData(): ShareData {
404430
const text = this.getHighlightedText()
405431
return {
406-
text: `"${text}"`,
432+
text,
407433
url: window.location.href,
408434
title: document.title,
409435
}
410436
}
411437

438+
private shouldUseNativeShare(): boolean {
439+
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
440+
return false
441+
}
442+
443+
if (typeof navigator.share !== 'function') {
444+
return false
445+
}
446+
447+
const hasTouchPoints = navigator.maxTouchPoints > 0
448+
const prefersCoarsePointer =
449+
typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches
450+
451+
return hasTouchPoints || prefersCoarsePointer
452+
}
453+
412454
/**
413455
* Handle share action for a specific platform
414456
*/
@@ -428,7 +470,7 @@ export class HighlighterElement extends LitElement {
428470
}
429471

430472
// Try native share first on mobile
431-
if (typeof navigator.share === 'function') {
473+
if (this.shouldUseNativeShare()) {
432474
const shared = await nativeShare(data)
433475
if (shared) {
434476
this.emitShareEvent(platformId, data)

src/components/Social/Highlighter/client/selectors.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
isSpanElement,
44
isType1Element,
55
} from '@components/scripts/assertions/elements'
6+
import type { TooltipElement } from '@components/Tooltip/client'
67
import { ClientScriptError } from '@components/scripts/errors'
78

89
export const SELECTORS = {
@@ -11,6 +12,7 @@ export const SELECTORS = {
1112
dialog: '.share-dialog',
1213
dialogArrow: '.share-dialog__arrow',
1314
shareButton: '.share-button',
15+
tooltipHost: 'site-tooltip',
1416
shareIcon: 'svg.share-icon',
1517
status: '[data-highlighter-status]',
1618
} as const
@@ -36,6 +38,12 @@ export const queryShareButtons = (context: Element): HTMLButtonElement[] => {
3638
)
3739
}
3840

41+
export const queryShareTooltipHosts = (context: Element): TooltipElement[] => {
42+
return Array.from(context.querySelectorAll(SELECTORS.tooltipHost)).filter(
43+
(node): node is TooltipElement => isType1Element(node) && node.tagName === 'SITE-TOOLTIP'
44+
)
45+
}
46+
3947
export const queryShareDialogArrow = (context: Element): HTMLSpanElement | null => {
4048
const arrow = context.querySelector(SELECTORS.dialogArrow)
4149
return isSpanElement(arrow) ? arrow : null

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ describe('MastodonModalElement', () => {
142142
expect(closeButton?.className).toContain('after:rounded-none')
143143

144144
const shareText = element.querySelector('#share-text') as HTMLTextAreaElement | null
145+
expect(shareText?.readOnly).toBe(false)
145146
expect(shareText?.className).toContain('outline-none')
146147
expect(shareText?.className).toContain('focus-visible:outline-none')
147148
expect(shareText?.className).toContain('focus-visible:ring-0')
@@ -200,6 +201,25 @@ describe('MastodonModalElement', () => {
200201
})
201202
})
202203

204+
test('allows editing the share text before submit', async () => {
205+
await renderModal(async ({ element, window }) => {
206+
element.openModal('Original share text')
207+
await flushMicrotasks()
208+
209+
const textarea = element.querySelector('#share-text') as HTMLTextAreaElement | null
210+
expect(textarea).toBeTruthy()
211+
212+
if (!textarea) {
213+
return
214+
}
215+
216+
textarea.value = 'Edited share text'
217+
textarea.dispatchEvent(new window.Event('input', { bubbles: true }))
218+
219+
expect(element.shareText).toBe('Edited share text')
220+
})
221+
})
222+
203223
test('submits share request when instance is valid', async () => {
204224
await renderModal(async ({ element, window }) => {
205225
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
@@ -208,8 +228,11 @@ describe('MastodonModalElement', () => {
208228
await flushMicrotasks()
209229

210230
const input = element.querySelector('#mastodon-instance') as HTMLInputElement
231+
const textarea = element.querySelector('#share-text') as HTMLTextAreaElement
211232
input.value = 'mastodon.social'
212233
input.dispatchEvent(new window.Event('input', { bubbles: true }))
234+
textarea.value = 'Updated share copy'
235+
textarea.dispatchEvent(new window.Event('input', { bubbles: true }))
213236

214237
const rememberCheckbox = element.querySelector('#remember-instance') as HTMLInputElement
215238
rememberCheckbox.checked = true
@@ -221,6 +244,7 @@ describe('MastodonModalElement', () => {
221244
await flushMicrotasks()
222245

223246
expect(mockIsMastodonInstance).toHaveBeenCalledWith('mastodon.social')
247+
expect(mockBuildShareUrl).toHaveBeenCalledWith('mastodon.social', 'Updated share copy')
224248
expect(mockSaveInstance).toHaveBeenCalled()
225249
expect(mockSetCurrentInstance).toHaveBeenCalledWith('mastodon.social')
226250
expect(openSpy).toHaveBeenCalledWith(

0 commit comments

Comments
 (0)