Skip to content

Commit 543bf21

Browse files
authored
Merge pull request #520 from webstackdev/feature/theme-picker-carousel
Feature/theme picker carousel
2 parents 8e49133 + 11ef77c commit 543bf21

10 files changed

Lines changed: 426 additions & 166 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/Consent/Banner/client/__tests__/selectors.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11

2-
import { beforeEach, describe, expect, it } from 'vitest'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
33
import { experimental_AstroContainer as AstroContainer } from 'astro/container'
44
import { TestError } from '@test/errors'
55
import ConsentBanner from '@components/Consent/Banner/index.astro'
@@ -17,6 +17,13 @@ import {
1717
} from '@components/Consent/Banner/client/selectors'
1818
import { ClientScriptError } from '@components/scripts/errors'
1919

20+
vi.mock('@components/scripts/store', () => ({
21+
allowAllConsentCookies: vi.fn(),
22+
hideConsentBanner: vi.fn(),
23+
initConsentCookies: vi.fn(() => true),
24+
showConsentBanner: vi.fn(),
25+
}))
26+
2027
type ConsentBannerModule = WebComponentModule<ConsentBannerElement>
2128

2229
const CONSENT_READY_TIMEOUT_MS = 2_000

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: 161 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 emblaControlsBound = false
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,141 @@ 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+
if (!this.emblaControlsBound) {
222+
if (this.emblaPrevBtn) {
223+
addButtonEventListeners(this.emblaPrevBtn, (event) => {
224+
if (event.cancelable && !event.defaultPrevented) event.preventDefault()
225+
try {
226+
this.emblaApi?.scrollPrev()
227+
} catch (error) {
228+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollPrev' })
229+
}
230+
}, this)
231+
}
232+
233+
if (this.emblaNextBtn) {
234+
addButtonEventListeners(this.emblaNextBtn, (event) => {
235+
if (event.cancelable && !event.defaultPrevented) event.preventDefault()
236+
try {
237+
this.emblaApi?.scrollNext()
238+
} catch (error) {
239+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollNext' })
240+
}
241+
}, this)
242+
}
243+
244+
this.emblaControlsBound = true
245+
}
246+
} catch (error) {
247+
this.teardownEmbla()
248+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:setup' })
249+
}
250+
}
251+
252+
private teardownEmbla(): void {
253+
if (this.emblaApi) {
254+
this.emblaApi.destroy()
255+
this.emblaApi = null
256+
}
257+
}
258+
259+
private updateEmblaNavState(): void {
260+
if (!this.emblaApi || !this.emblaPrevBtn || !this.emblaNextBtn) return
261+
262+
const hasOverflow = this.emblaApi.scrollSnapList().length > 1
263+
if (!hasOverflow) {
264+
this.emblaPrevBtn.setAttribute('hidden', '')
265+
this.emblaNextBtn.setAttribute('hidden', '')
266+
this.emblaPrevBtn.setAttribute('disabled', 'true')
267+
this.emblaNextBtn.setAttribute('disabled', 'true')
268+
return
269+
}
270+
271+
this.emblaPrevBtn.removeAttribute('hidden')
272+
this.emblaNextBtn.removeAttribute('hidden')
273+
274+
if (this.emblaApi.canScrollPrev()) {
275+
this.emblaPrevBtn.removeAttribute('disabled')
276+
} else {
277+
this.emblaPrevBtn.setAttribute('disabled', 'true')
278+
}
279+
280+
if (this.emblaApi.canScrollNext()) {
281+
this.emblaNextBtn.removeAttribute('disabled')
282+
} else {
283+
this.emblaNextBtn.setAttribute('disabled', 'true')
284+
}
285+
}
286+
287+
private scrollThemeIntoView(themeId: ThemeId): void {
288+
if (!this.emblaApi) return
289+
290+
const buttons = Array.from(this.themeSelectBtns)
291+
const index = buttons.findIndex(button => button.dataset['theme'] === themeId)
292+
if (index < 0) return
293+
294+
try {
295+
this.emblaApi.scrollTo(index, true)
296+
this.updateEmblaNavState()
297+
} catch (error) {
298+
handleScriptError(error, { scriptName: 'ThemePickerElement', operation: 'embla:scrollToTheme' })
299+
}
139300
}
140301

141302
/**

0 commit comments

Comments
 (0)