Skip to content

Commit a7884ec

Browse files
committed
Add carousel and hover/focus tooltips with description to theme picker
1 parent 8e49133 commit a7884ec

9 files changed

Lines changed: 422 additions & 165 deletions

File tree

_TODO.md

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -210,50 +210,3 @@ Google Calendar, Apple Calendar, Yahoo Calender, Microsoft 365, Outlook, and T
210210

211211
`https://github.com/add2cal/add-to-calendar-button`
212212
`https://add-to-calendar-button.com/`
213-
214-
## Markdown
215-
216-
## Code Block and Highlighting
217-
218-
## Astro includes shiki, tweak config
219-
220-
### Custom version of the code block integration from Astro Docs. "Beautiful code blocks for your Astro site". Applied to the code blocks created in `.mdx` files
221-
222-
[`astro-code-blocks`](https://www.npmjs.com/package/@thewebforge/astro-code-blocks)
223-
224-
225-
#### Code tabs plugin so Javascript and Typescript examples can both be show.
226-
227-
There can only be white space between two code blocks. Display name is set by `tabName` and can only contain characters in [A-Za-z0-9_]. Syntax for the first line of the code block is:
228-
229-
```js [group:tabName]
230-
```
231-
232-
`markdown-it-codetabs`
233-
234-
#### Add copy button to code blocks
235-
236-
`markdown-it-copy`
237-
238-
Options for "copy" button added to code blocks
239-
240-
```javascript
241-
const markdownCodeCopyConfig = {
242-
/** Text shown on copy button */
243-
btnText: `Copy`,
244-
/** Text shown on copy failure */
245-
failText: `Copy Failed`,
246-
/** Text shown on copy success */
247-
successText: `Success!`, // 'copy success' | copy-success text
248-
/** Amount of time to show success message */
249-
successTextDelay: 2000,
250-
/** An HTML fragment included before <button> */
251-
extraHtmlBeforeBtn: ``,
252-
/** An HTML fragment included after <button> */
253-
extraHtmlAfterBtn: ``,
254-
/** Whether to show code language before the copy button */
255-
showCodeLanguage: false,
256-
/** Test to append after the copied text like a copyright notice */
257-
attachText: ``,
258-
}
259-
```

src/components/Header/index.astro

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const { path } = Astro.props
6464
{/* Theme picker toggle button to show modal */}
6565
<span
6666
id="header__theme-icon"
67-
class="w-[calc(var(--header-icon-size)*2+0.5em)] lg:w-(--header-icon-size) relative z-(--z-content-overlay)"
67+
class="ml-2 shrink-0 w-(--header-icon-size) relative z-(--z-content-overlay)"
6868
>
6969
<ThemeButton />
7070
</span>

src/components/Navigation/index.astro

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ const { path } = Astro.props
1313
<site-navigation class="contents" data-testid="site-navigation">
1414
<div id="mobile-nav-focus-container" class="contents">
1515
{/* Main Navigation Menu */}
16-
<span id="header__main-nav" class="lg:z-(--z-nav) lg:flex-1 lg:mx-8">
16+
<span id="header__main-nav" class="flex-1 min-w-0 lg:z-(--z-nav) lg:mx-8">
1717
<Menu path={path} />
1818
</span>
1919
{/* Mobile navigation toggle button to show menu on full-page splash screen */}
2020
<span
2121
id="header__nav-icon"
22-
class="block fixed right-4 sm:right-6 top-2 z-(--z-mobile-close-btn) lg:hidden"
22+
class="block shrink-0 z-(--z-content-overlay) lg:hidden"
2323
>
2424
<NavToggle />
2525
</span>

src/components/ThemePicker/client/__tests__/index.spec.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ const getThemeModal = (root: ThemePickerElement) =>
3737
const getThemeCloseButton = (root: ThemePickerElement) =>
3838
queryElement<HTMLButtonElement>(root, '[data-theme-close]')
3939
const getThemeList = (root: ThemePickerElement) => queryElement<HTMLElement>(root, '#theme-menu')
40+
const getEmblaViewport = (root: ThemePickerElement) =>
41+
queryElement<HTMLElement>(root, '[data-theme-embla-viewport]')
42+
const getEmblaPrevButton = (root: ThemePickerElement) =>
43+
queryElement<HTMLButtonElement>(root, '[data-theme-embla-prev]')
44+
const getEmblaNextButton = (root: ThemePickerElement) =>
45+
queryElement<HTMLButtonElement>(root, '[data-theme-embla-next]')
4046
const getThemeListItems = (root: ThemePickerElement) =>
4147
queryElements<HTMLLIElement>(root, '.themepicker__item')
4248
const getThemeButtons = (root: ThemePickerElement) =>
@@ -119,6 +125,19 @@ describe('ThemePicker Component', () => {
119125
const themes = themeButtons.map((btn) => btn.getAttribute('data-theme'))
120126
expect(themes).toContain('light')
121127
expect(themes).toContain('dark')
128+
expect(themes).toContain('a11y')
129+
})
130+
})
131+
132+
it('should render embla viewport and nav buttons', async () => {
133+
await renderThemePickerDom(({ element }) => {
134+
const viewport = getEmblaViewport(element)
135+
expect(viewport.classList.contains('embla__viewport')).toBe(true)
136+
137+
const prev = getEmblaPrevButton(element)
138+
const next = getEmblaNextButton(element)
139+
expect(prev.getAttribute('aria-label')).toBe('Previous theme')
140+
expect(next.getAttribute('aria-label')).toBe('Next theme')
122141
})
123142
})
124143

@@ -212,7 +231,7 @@ describe('ThemePicker Component', () => {
212231
buttons.forEach((button) => {
213232
expect(button.hasAttribute('data-theme')).toBe(true)
214233
const theme = button.getAttribute('data-theme')
215-
expect(['light', 'dark', 'holiday']).toContain(theme)
234+
expect(['light', 'dark', 'holiday', 'a11y']).toContain(theme)
216235
})
217236
})
218237
})
@@ -223,6 +242,7 @@ describe('ThemePicker Component', () => {
223242
await renderThemePickerDom(({ element }) => {
224243
const list = getThemeList(element)
225244
expect(list.classList.contains('themepicker__list')).toBe(true)
245+
expect(list.classList.contains('embla__container')).toBe(true)
226246
})
227247
})
228248

@@ -301,18 +321,17 @@ describe('ThemePicker Component', () => {
301321
})
302322

303323
describe('Responsive Design', () => {
304-
it('should have horizontal scroll on theme list', async () => {
324+
it('should use Embla viewport with overflow hidden', async () => {
305325
await renderThemePickerDom(({ element }) => {
306-
const list = queryElement<HTMLElement>(element, '.themepicker__list')
307-
expect(list.classList.contains('overflow-x-auto')).toBe(true)
308-
expect(list.classList.contains('overflow-y-hidden')).toBe(true)
326+
const viewport = getEmblaViewport(element)
327+
expect(viewport.classList.contains('overflow-hidden')).toBe(true)
309328
})
310329
})
311330

312-
it('should have whitespace-nowrap for horizontal layout', async () => {
331+
it('should render tooltip for themes with descriptions', async () => {
313332
await renderThemePickerDom(({ element }) => {
314-
const list = queryElement<HTMLElement>(element, '.themepicker__list')
315-
expect(list.classList.contains('whitespace-nowrap')).toBe(true)
333+
const tooltips = queryElements<HTMLElement>(element, '[data-theme-tooltip]')
334+
expect(tooltips.length).toBeGreaterThan(0)
316335
})
317336
})
318337
})

src/components/ThemePicker/client/index.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { LitElement } from 'lit'
8+
import EmblaCarousel, { type EmblaCarouselType, type EmblaOptionsType } from 'embla-carousel'
89
import {
910
setTheme,
1011
toggleThemePicker,
@@ -30,6 +31,14 @@ export const CLASSES = {
3031
active: 'is-active',
3132
}
3233

34+
const THEME_PICKER_EMBLA_OPTIONS: EmblaOptionsType = {
35+
loop: false,
36+
align: 'center',
37+
containScroll: 'trimSnaps',
38+
skipSnaps: false,
39+
dragFree: false,
40+
}
41+
3342
/**
3443
* ThemePicker Custom Element (Lit-based)
3544
* Uses Light DOM (no Shadow DOM) with Astro-rendered templates
@@ -51,6 +60,16 @@ export class ThemePickerElement extends LitElement {
5160
private closeBtn!: HTMLButtonElement
5261
private themeSelectBtns!: NodeListOf<HTMLButtonElement>
5362

63+
private emblaViewport: HTMLElement | null = null
64+
private emblaPrevBtn: HTMLButtonElement | null = null
65+
private emblaNextBtn: HTMLButtonElement | null = null
66+
private emblaApi: EmblaCarouselType | null = null
67+
private emblaEvents: AbortController | null = null
68+
private lastIsOpen: boolean | null = null
69+
private lastTheme: ThemeId | null = null
70+
71+
private readonly emblaUpdateHandler = () => this.updateEmblaNavState()
72+
5473
// Track View Transitions
5574
private isTransitioning = false
5675
private isInitialized = false
@@ -72,6 +91,11 @@ export class ThemePickerElement extends LitElement {
7291
}
7392
}
7493

94+
override disconnectedCallback(): void {
95+
super.disconnectedCallback()
96+
this.teardownEmbla()
97+
}
98+
7599
/**
76100
* Initialize the theme picker after DOM is ready
77101
*/
@@ -124,6 +148,8 @@ export class ThemePickerElement extends LitElement {
124148

125149
// Update active theme button
126150
this.updateActiveTheme(currentTheme)
151+
152+
this.syncThemeCarousel(isOpen, currentTheme)
127153
}
128154

129155
/**
@@ -136,6 +162,145 @@ export class ThemePickerElement extends LitElement {
136162
this.toggleBtn = getThemePickerToggleBtn()
137163
this.closeBtn = getThemePickerCloseBtn(this)
138164
this.themeSelectBtns = getThemeSelectBtns(this)
165+
166+
this.emblaViewport = this.querySelector('[data-theme-embla-viewport]')
167+
this.emblaPrevBtn = this.querySelector('[data-theme-embla-prev]')
168+
this.emblaNextBtn = this.querySelector('[data-theme-embla-next]')
169+
}
170+
171+
private syncThemeCarousel(isOpen: boolean, currentTheme: ThemeId): void {
172+
const shouldInit = isOpen && !!this.emblaViewport
173+
174+
if (!shouldInit) {
175+
if (this.lastIsOpen) {
176+
this.teardownEmbla()
177+
}
178+
this.lastIsOpen = isOpen
179+
this.lastTheme = currentTheme
180+
return
181+
}
182+
183+
const openChanged = this.lastIsOpen !== isOpen
184+
const themeChanged = this.lastTheme !== currentTheme
185+
186+
if (openChanged) {
187+
this.setupEmbla()
188+
// Modal just opened; wait a frame so Embla sees correct sizing.
189+
requestAnimationFrame(() => {
190+
try {
191+
this.emblaApi?.reInit()
192+
this.updateEmblaNavState()
193+
this.scrollThemeIntoView(currentTheme)
194+
} catch (error) {
195+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:reInit' })
196+
}
197+
})
198+
} else if (themeChanged) {
199+
this.scrollThemeIntoView(currentTheme)
200+
}
201+
202+
this.lastIsOpen = isOpen
203+
this.lastTheme = currentTheme
204+
}
205+
206+
private setupEmbla(): void {
207+
this.teardownEmbla()
208+
if (!this.emblaViewport) return
209+
210+
try {
211+
this.emblaApi = EmblaCarousel(this.emblaViewport, THEME_PICKER_EMBLA_OPTIONS)
212+
this.updateEmblaNavState()
213+
214+
const emblaWithEvents = this.emblaApi as EmblaCarouselType & {
215+
on: (_event: string, _handler: () => void) => EmblaCarouselType
216+
}
217+
218+
emblaWithEvents.on('select', this.emblaUpdateHandler)
219+
emblaWithEvents.on('reInit', this.emblaUpdateHandler)
220+
221+
this.emblaEvents = new AbortController()
222+
const { signal } = this.emblaEvents
223+
224+
if (this.emblaPrevBtn) {
225+
this.emblaPrevBtn.addEventListener('click', (event) => {
226+
event.preventDefault()
227+
try {
228+
this.emblaApi?.scrollPrev()
229+
} catch (error) {
230+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollPrev' })
231+
}
232+
}, { signal })
233+
}
234+
235+
if (this.emblaNextBtn) {
236+
this.emblaNextBtn.addEventListener('click', (event) => {
237+
event.preventDefault()
238+
try {
239+
this.emblaApi?.scrollNext()
240+
} catch (error) {
241+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollNext' })
242+
}
243+
}, { signal })
244+
}
245+
} catch (error) {
246+
this.teardownEmbla()
247+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:setup' })
248+
}
249+
}
250+
251+
private teardownEmbla(): void {
252+
if (this.emblaEvents) {
253+
this.emblaEvents.abort()
254+
this.emblaEvents = null
255+
}
256+
257+
if (this.emblaApi) {
258+
this.emblaApi.destroy()
259+
this.emblaApi = null
260+
}
261+
}
262+
263+
private updateEmblaNavState(): void {
264+
if (!this.emblaApi || !this.emblaPrevBtn || !this.emblaNextBtn) return
265+
266+
const hasOverflow = this.emblaApi.scrollSnapList().length > 1
267+
if (!hasOverflow) {
268+
this.emblaPrevBtn.setAttribute('hidden', '')
269+
this.emblaNextBtn.setAttribute('hidden', '')
270+
this.emblaPrevBtn.setAttribute('disabled', 'true')
271+
this.emblaNextBtn.setAttribute('disabled', 'true')
272+
return
273+
}
274+
275+
this.emblaPrevBtn.removeAttribute('hidden')
276+
this.emblaNextBtn.removeAttribute('hidden')
277+
278+
if (this.emblaApi.canScrollPrev()) {
279+
this.emblaPrevBtn.removeAttribute('disabled')
280+
} else {
281+
this.emblaPrevBtn.setAttribute('disabled', 'true')
282+
}
283+
284+
if (this.emblaApi.canScrollNext()) {
285+
this.emblaNextBtn.removeAttribute('disabled')
286+
} else {
287+
this.emblaNextBtn.setAttribute('disabled', 'true')
288+
}
289+
}
290+
291+
private scrollThemeIntoView(themeId: ThemeId): void {
292+
if (!this.emblaApi) return
293+
294+
const buttons = Array.from(this.themeSelectBtns)
295+
const index = buttons.findIndex(button => button.dataset['theme'] === themeId)
296+
if (index < 0) return
297+
298+
try {
299+
this.emblaApi.scrollTo(index, true)
300+
this.updateEmblaNavState()
301+
} catch (error) {
302+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollToTheme' })
303+
}
139304
}
140305

141306
/**

0 commit comments

Comments
 (0)