@usequeek/theme-kit 0.1.21 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/README.md +293 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-menu.css +349 -0
  8. package/components/language-menu.tsx +371 -0
  9. package/components/language-switcher.css +131 -0
  10. package/components/language-switcher.tsx +175 -0
  11. package/components/markdown.tsx +1 -1
  12. package/components/powered-by-queek.tsx +4 -4
  13. package/components/product-media-frame.tsx +1 -1
  14. package/components/product-metafields.tsx +2 -3
  15. package/components/variant-picker.tsx +6 -9
  16. package/demo.ts +10 -14
  17. package/design-token-preview.tsx +0 -2
  18. package/edit-mode-active.tsx +3 -4
  19. package/hooks/use-announcement.ts +1 -1
  20. package/hooks/use-auth-flow.tsx +1 -1
  21. package/hooks/use-locales.ts +107 -0
  22. package/hooks/use-posts.ts +1 -1
  23. package/hooks/use-product-detail.ts +1 -2
  24. package/hooks/use-product-sections.ts +4 -6
  25. package/hooks/use-products.ts +5 -8
  26. package/index.ts +13 -16
  27. package/locales/en.default.json +4 -0
  28. package/next-reference.d.ts +2 -4
  29. package/package.json +3 -2
  30. package/page-renderer.tsx +5 -6
  31. package/provider.tsx +22 -2
  32. package/scripts/verify-package.ts +6 -8
  33. package/sdk/client.ts +13 -12
  34. package/sdk/error-handler.ts +1 -1
  35. package/sdk/platform.ts +6 -8
  36. package/sdk/queek-client.ts +11 -12
  37. package/sdk/server-store-client.ts +13 -17
  38. package/shared-blocks/metaobjects.tsx +1 -1
  39. package/stores/cart-store.ts +2 -2
  40. package/stores/checkout-store.ts +1 -1
  41. package/stores/edit-preview-store.ts +5 -6
  42. package/stores/user-store.ts +4 -5
  43. package/strings/pseudo-locale.ts +3 -4
  44. package/strings/theme-strings.ts +9 -7
  45. package/tests/auth-email-errors.test.tsx +2 -2
  46. package/tests/currency-contract.test.ts +2 -3
  47. package/tests/display-format.test.ts +17 -25
  48. package/tests/fixtures/external-theme.tsx +23 -2
  49. package/tests/language-menu.test.tsx +598 -0
  50. package/tests/language-switcher.test.tsx +350 -0
  51. package/tests/list-pagination.test.tsx +8 -8
  52. package/tests/locale-switch.test.ts +197 -0
  53. package/tests/locale-transport.test.tsx +3 -3
  54. package/tests/markdown-render.test.ts +5 -6
  55. package/tests/menu-placement.test.ts +63 -0
  56. package/tests/orders-walk.test.ts +5 -5
  57. package/tests/page-renderer-signature.test.tsx +1 -1
  58. package/tests/powered-by-queek.test.tsx +3 -3
  59. package/tests/powered-by-url.test.ts +2 -2
  60. package/tests/public-surface.test.ts +1 -4
  61. package/tests/shopper-chrome-strings.test.tsx +5 -6
  62. package/tests/storefront-client-scope.test.ts +5 -5
  63. package/tests/theme-hook-placement.test.tsx +2 -2
  64. package/tests/theme-strings-client.test.tsx +5 -5
  65. package/tests/theme-strings-pseudo.test.tsx +1 -1
  66. package/tests/theme-strings-typed.test.tsx +1 -1
  67. package/tests/theme-strings.test.tsx +8 -11
  68. package/tests/use-shop-prefetch.test.tsx +1 -1
  69. package/theme-mount.tsx +6 -11
  70. package/types/block.ts +7 -7
  71. package/types/media.ts +8 -9
  72. package/types/product.ts +8 -12
  73. package/types/theme.ts +26 -25
  74. package/types/vendor.ts +10 -12
  75. package/utils/announcement.ts +3 -3
  76. package/utils/benefit-icon.tsx +9 -11
  77. package/utils/brand.ts +7 -10
  78. package/utils/format.ts +5 -7
  79. package/utils/image-srcset.ts +2 -2
  80. package/utils/locale-switch.ts +208 -0
  81. package/utils/locale.ts +10 -11
  82. package/utils/menu-link.ts +1 -1
  83. package/utils/menu-placement.ts +91 -0
  84. package/utils/product-media.ts +1 -1
  85. package/utils/product-normalizer.ts +2 -2
  86. package/utils/vendor-host.ts +7 -11
@@ -0,0 +1,371 @@
1
+ 'use client';
2
+
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useId,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type JSX,
11
+ type KeyboardEvent,
12
+ type MouseEvent,
13
+ } from 'react';
14
+ import type { LocalesState } from '../hooks/use-locales';
15
+ import type { StoreLocale } from '../utils/locale-switch';
16
+ import { computePlacement, type MenuAlign, type MenuSide } from '../utils/menu-placement';
17
+ import './language-menu.css';
18
+
19
+ /** Below this width the menu is a bottom sheet. The same value is in language-menu.css. */
20
+ export const LANGUAGE_MENU_SHEET_QUERY = '(max-width: 639.98px)';
21
+
22
+ /** More languages than this: the root gets `data-many="true"` (a theme may use two columns). */
23
+ const MANY_LANGUAGES = 10;
24
+ const TYPEAHEAD_RESET_MS = 500;
25
+ const LOCK_CLASS = 'qn-lang-lock';
26
+
27
+ type Mode = 'popover' | 'sheet';
28
+ type Focus = 'selected' | 'last';
29
+
30
+ export interface LanguageMenuProps {
31
+ state: LocalesState & { open: boolean; setOpen: (open: boolean) => void; label: string };
32
+ rootClass: string;
33
+ showCode: boolean;
34
+ side: MenuSide;
35
+ align: MenuAlign;
36
+ /** The language's name as shown (native or English). */
37
+ nameOf: (locale: StoreLocale) => string;
38
+ /** "Language: <shown name>" — the trigger's accessible name. */
39
+ triggerLabelFor: (locale: StoreLocale) => string;
40
+ }
41
+
42
+ function codeOf(locale: StoreLocale): string {
43
+ return locale.locale.toUpperCase();
44
+ }
45
+
46
+ function isSheetViewport(): boolean {
47
+ return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
48
+ ? window.matchMedia(LANGUAGE_MENU_SHEET_QUERY).matches
49
+ : false;
50
+ }
51
+
52
+ function isRtl(element: Element): boolean {
53
+ const attr = element.closest('[dir]')?.getAttribute('dir');
54
+ if (attr === 'rtl' || attr === 'ltr') return attr === 'rtl';
55
+ return getComputedStyle(element).direction === 'rtl';
56
+ }
57
+
58
+ function isPlainPrimaryClick(event: MouseEvent): boolean {
59
+ return event.button === 0 && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
60
+ }
61
+
62
+ /**
63
+ * The `menu` variant of `LanguageSwitcher`: a disclosure button and a list of
64
+ * language links — a popover on wide screens, a bottom sheet on narrow ones.
65
+ * The items navigate, so they are plain links (`aria-current` marks the
66
+ * current one), not listbox options. Arrow keys, Home/End and typeahead move
67
+ * focus between the links; Enter follows the focused link natively.
68
+ *
69
+ * The wide/narrow LAYOUT is CSS only (one media query), so the markup is the
70
+ * same in both and server and client agree. JavaScript asks the media query
71
+ * only while the menu is opening or open: to lock page scroll behind a sheet
72
+ * and to run collision handling for a popover.
73
+ */
74
+ export function LanguageMenu({
75
+ state,
76
+ rootClass,
77
+ showCode,
78
+ side,
79
+ align,
80
+ nameOf,
81
+ triggerLabelFor,
82
+ }: LanguageMenuProps): JSX.Element {
83
+ const { locales, active, hrefFor, switchTo, open, setOpen, label } = state;
84
+ const uid = useId();
85
+ const popoverId = `${uid}-popover`;
86
+ const titleId = `${uid}-title`;
87
+
88
+ const rootRef = useRef<HTMLDivElement>(null);
89
+ const triggerRef = useRef<HTMLButtonElement>(null);
90
+ const popoverRef = useRef<HTMLDivElement>(null);
91
+ const optionRefs = useRef<Array<HTMLAnchorElement | null>>([]);
92
+ const pendingFocus = useRef<Focus>('selected');
93
+ const typeahead = useRef({ text: '', timer: 0 });
94
+
95
+ const [mode, setMode] = useState<Mode>('popover');
96
+ const [placement, setPlacement] = useState<{ side: MenuSide; align: MenuAlign; shift: number; available: number | null }>({
97
+ side,
98
+ align,
99
+ shift: 0,
100
+ available: null,
101
+ });
102
+ const activeIndex = Math.max(0, locales.findIndex((locale) => locale.locale === active.locale));
103
+ const [rovingIndex, setRovingIndex] = useState(activeIndex);
104
+
105
+ const placed = open && mode === 'popover' ? placement : { side, align, shift: 0, available: null };
106
+
107
+ const close = useCallback(
108
+ (returnFocus: boolean) => {
109
+ setOpen(false);
110
+ if (returnFocus) triggerRef.current?.focus();
111
+ },
112
+ [setOpen],
113
+ );
114
+
115
+ const openMenu = useCallback(
116
+ (focus: Focus) => {
117
+ pendingFocus.current = focus;
118
+ setMode(isSheetViewport() ? 'sheet' : 'popover');
119
+ setRovingIndex(focus === 'last' ? locales.length - 1 : activeIndex);
120
+ setOpen(true);
121
+ },
122
+ [setOpen, locales.length, activeIndex],
123
+ );
124
+
125
+ const focusOption = useCallback(
126
+ (index: number) => {
127
+ const count = locales.length;
128
+ if (count === 0) return;
129
+ const next = (index + count) % count;
130
+ setRovingIndex(next);
131
+ optionRefs.current[next]?.focus();
132
+ },
133
+ [locales.length],
134
+ );
135
+
136
+ const select = useCallback(
137
+ (locale: StoreLocale) => {
138
+ close(true);
139
+ if (locale.locale !== active.locale) switchTo(locale);
140
+ },
141
+ [close, active.locale, switchTo],
142
+ );
143
+
144
+ // Focus moves into the list on open.
145
+ useEffect(() => {
146
+ if (!open) return;
147
+ const index = pendingFocus.current === 'last' ? locales.length - 1 : activeIndex;
148
+ const option = optionRefs.current[index];
149
+ option?.focus();
150
+ option?.scrollIntoView?.({ block: 'nearest' });
151
+ // Only when the menu opens.
152
+ // eslint-disable-next-line react-hooks/exhaustive-deps
153
+ }, [open]);
154
+
155
+ // Collision handling for the popover (plain measurements, no portal).
156
+ useLayoutEffect(() => {
157
+ if (!open || mode !== 'popover') return;
158
+ const root = rootRef.current;
159
+ const trigger = triggerRef.current;
160
+ const popover = popoverRef.current;
161
+ if (!root || !trigger || !popover) return;
162
+
163
+ const run = () => {
164
+ const t = trigger.getBoundingClientRect();
165
+ const p = popover.getBoundingClientRect();
166
+ const next = computePlacement({
167
+ trigger: { top: t.top, right: t.right, bottom: t.bottom, left: t.left },
168
+ popover: { width: p.width, height: p.height },
169
+ viewport: { width: window.innerWidth, height: window.innerHeight },
170
+ side,
171
+ align,
172
+ dir: isRtl(root) ? 'rtl' : 'ltr',
173
+ });
174
+ setPlacement((prev) =>
175
+ prev.side === next.side && prev.align === next.align && prev.shift === next.shift && prev.available === next.available
176
+ ? prev
177
+ : next,
178
+ );
179
+ };
180
+ run();
181
+ window.addEventListener('resize', run);
182
+ return () => window.removeEventListener('resize', run);
183
+ }, [open, mode, side, align, locales.length]);
184
+
185
+ // Follow the viewport crossing the breakpoint while open.
186
+ useEffect(() => {
187
+ if (!open || typeof window.matchMedia !== 'function') return;
188
+ const query = window.matchMedia(LANGUAGE_MENU_SHEET_QUERY);
189
+ const onChange = () => setMode(query.matches ? 'sheet' : 'popover');
190
+ query.addEventListener?.('change', onChange);
191
+ return () => query.removeEventListener?.('change', onChange);
192
+ }, [open]);
193
+
194
+ // A sheet locks page scroll behind it.
195
+ useEffect(() => {
196
+ if (!open || mode !== 'sheet') return;
197
+ document.body.classList.add(LOCK_CLASS);
198
+ return () => document.body.classList.remove(LOCK_CLASS);
199
+ }, [open, mode]);
200
+
201
+ // Outside tap or click closes without taking focus.
202
+ useEffect(() => {
203
+ if (!open) return;
204
+ const onPointerDown = (event: Event) => {
205
+ if (rootRef.current && event.target instanceof Node && !rootRef.current.contains(event.target)) close(false);
206
+ };
207
+ document.addEventListener('pointerdown', onPointerDown);
208
+ return () => document.removeEventListener('pointerdown', onPointerDown);
209
+ }, [open, close]);
210
+
211
+ useEffect(() => () => window.clearTimeout(typeahead.current.timer), []);
212
+
213
+ const onTriggerKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
214
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
215
+ event.preventDefault();
216
+ const focus: Focus = event.key === 'ArrowUp' ? 'last' : 'selected';
217
+ if (!open) return openMenu(focus);
218
+ focusOption(focus === 'last' ? locales.length - 1 : activeIndex);
219
+ };
220
+
221
+ const onRootKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
222
+ if (!open) return;
223
+ if (event.key === 'Escape') {
224
+ event.preventDefault();
225
+ return close(true);
226
+ }
227
+ if (event.key === 'Tab') return close(false);
228
+
229
+ const target = event.target instanceof Element ? event.target.closest('a') : null;
230
+ const index = target ? optionRefs.current.indexOf(target as HTMLAnchorElement) : -1;
231
+ if (index < 0) return;
232
+
233
+ switch (event.key) {
234
+ case 'ArrowDown':
235
+ event.preventDefault();
236
+ return focusOption(index + 1);
237
+ case 'ArrowUp':
238
+ event.preventDefault();
239
+ return focusOption(index - 1);
240
+ case 'Home':
241
+ event.preventDefault();
242
+ return focusOption(0);
243
+ case 'End':
244
+ event.preventDefault();
245
+ return focusOption(locales.length - 1);
246
+ case ' ':
247
+ event.preventDefault();
248
+ return select(locales[index]!);
249
+ default:
250
+ break;
251
+ }
252
+
253
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
254
+ const buffer = typeahead.current;
255
+ buffer.text += event.key.toLocaleLowerCase();
256
+ window.clearTimeout(buffer.timer);
257
+ buffer.timer = window.setTimeout(() => {
258
+ buffer.text = '';
259
+ }, TYPEAHEAD_RESET_MS);
260
+ const found = locales.findIndex(
261
+ (locale) =>
262
+ nameOf(locale).toLocaleLowerCase().startsWith(buffer.text) ||
263
+ locale.name.toLocaleLowerCase().startsWith(buffer.text) ||
264
+ locale.locale.toLocaleLowerCase().startsWith(buffer.text),
265
+ );
266
+ if (found >= 0) focusOption(found);
267
+ }
268
+ };
269
+
270
+ const dataState = open ? 'open' : 'closed';
271
+ const popoverStyle: Record<string, string> = {};
272
+ if (placed.shift) popoverStyle['--qn-lang-shift'] = `${placed.shift}px`;
273
+ if (placed.available !== null) popoverStyle['--qn-lang-avail'] = `${placed.available}px`;
274
+
275
+ return (
276
+ <div
277
+ ref={rootRef}
278
+ className={rootClass}
279
+ data-qn-language-switcher=""
280
+ data-variant="menu"
281
+ data-state={dataState}
282
+ data-side={placed.side}
283
+ data-align={placed.align}
284
+ data-mode={open ? mode : undefined}
285
+ data-count={locales.length}
286
+ data-many={locales.length > MANY_LANGUAGES ? 'true' : undefined}
287
+ data-show-code={showCode ? 'true' : undefined}
288
+ onKeyDown={onRootKeyDown}
289
+ >
290
+ <button
291
+ ref={triggerRef}
292
+ type="button"
293
+ className="qn-lang__trigger"
294
+ data-part="trigger"
295
+ aria-expanded={open}
296
+ aria-controls={popoverId}
297
+ aria-label={triggerLabelFor(active)}
298
+ onClick={() => (open ? close(false) : openMenu('selected'))}
299
+ onKeyDown={onTriggerKeyDown}
300
+ >
301
+ <span className="qn-lang__trigger-label" data-part="trigger-label">
302
+ {nameOf(active)}
303
+ </span>
304
+ <span className="qn-lang__trigger-code" data-part="trigger-code">
305
+ {codeOf(active)}
306
+ </span>
307
+ <svg className="qn-lang__chevron" data-part="chevron" aria-hidden="true" viewBox="0 0 10 10" focusable="false">
308
+ <path d="M1 3.5 5 7.5 9 3.5" />
309
+ </svg>
310
+ </button>
311
+ <div className="qn-lang__scrim" data-part="scrim" aria-hidden="true" hidden={!open} onClick={() => close(false)} />
312
+ <div
313
+ ref={popoverRef}
314
+ id={popoverId}
315
+ className="qn-lang__popover"
316
+ data-part="popover"
317
+ data-state={dataState}
318
+ hidden={!open}
319
+ style={popoverStyle}
320
+ >
321
+ <div className="qn-lang__handle" data-part="handle" aria-hidden="true" />
322
+ <div id={titleId} className="qn-lang__title" data-part="title">
323
+ {label}
324
+ </div>
325
+ <ul className="qn-lang__options" data-part="list" aria-labelledby={titleId}>
326
+ {locales.map((locale, index) => {
327
+ const current = locale.locale === active.locale;
328
+ return (
329
+ <li key={locale.locale} className="qn-lang__item" data-part="item">
330
+ <a
331
+ ref={(node) => {
332
+ optionRefs.current[index] = node;
333
+ }}
334
+ className="qn-lang__option"
335
+ data-part="option"
336
+ data-current={current ? 'true' : undefined}
337
+ href={hrefFor(locale)}
338
+ lang={locale.html_lang}
339
+ hrefLang={locale.hreflang}
340
+ aria-current={current ? 'true' : undefined}
341
+ tabIndex={index === rovingIndex ? 0 : -1}
342
+ onFocus={() => setRovingIndex(index)}
343
+ onClick={(event) => {
344
+ // Modified clicks keep the browser default (new tab, copy link).
345
+ if (event.defaultPrevented || !isPlainPrimaryClick(event)) return;
346
+ event.preventDefault();
347
+ select(locale);
348
+ }}
349
+ >
350
+ <span className="qn-lang__name" data-part="name">
351
+ <bdi lang={locale.html_lang} dir={locale.dir === 'rtl' ? 'rtl' : undefined}>
352
+ {nameOf(locale)}
353
+ </bdi>
354
+ </span>
355
+ <span className="qn-lang__code" data-part="code">
356
+ {codeOf(locale)}
357
+ </span>
358
+ <span className="qn-lang__mark" data-part="mark" aria-hidden="true">
359
+ <svg viewBox="0 0 16 16" focusable="false">
360
+ <path d="m3 8.5 3.2 3.2L13 5" />
361
+ </svg>
362
+ </span>
363
+ </a>
364
+ </li>
365
+ );
366
+ })}
367
+ </ul>
368
+ </div>
369
+ </div>
370
+ );
371
+ }
@@ -0,0 +1,131 @@
1
+ /* ============================================================
2
+ LanguageSwitcher — the kit's language control. Prefix: qn-lang.
3
+ Colours, radius and type come from the --qn-* tokens, each with a
4
+ neutral fallback, so it sits in a header or a footer without theme
5
+ CSS. Logical properties only: it mirrors itself in RTL storefronts.
6
+ ============================================================ */
7
+
8
+ .qn-lang {
9
+ display: inline-flex;
10
+ align-items: center;
11
+ gap: 8px;
12
+ max-inline-size: 100%;
13
+ color: var(--qn-text, inherit);
14
+ font: inherit;
15
+ }
16
+
17
+ .qn-lang__icon {
18
+ inline-size: 18px;
19
+ block-size: 18px;
20
+ flex: none;
21
+ fill: none;
22
+ stroke: currentColor;
23
+ stroke-width: 1.6;
24
+ opacity: 0.75;
25
+ }
26
+
27
+ /* ── select ─────────────────────────────────────────────── */
28
+
29
+ .qn-lang__field {
30
+ position: relative;
31
+ display: inline-flex;
32
+ align-items: center;
33
+ min-inline-size: 0;
34
+ }
35
+
36
+ .qn-lang__select {
37
+ appearance: none;
38
+ min-inline-size: 44px;
39
+ min-block-size: 44px;
40
+ max-inline-size: min(100%, 16rem);
41
+ padding-block: 8px;
42
+ padding-inline: 12px 34px;
43
+ border: 1px solid var(--qn-border, color-mix(in srgb, currentColor 22%, transparent));
44
+ border-radius: var(--qn-radius-sm, 6px);
45
+ background-color: var(--qn-surface, transparent);
46
+ color: inherit;
47
+ font: inherit;
48
+ line-height: 1.25;
49
+ text-overflow: ellipsis;
50
+ cursor: pointer;
51
+ }
52
+
53
+ /* The chevron is an element, not a background, so it follows the inline-end
54
+ edge in both directions. */
55
+ .qn-lang__chevron {
56
+ position: absolute;
57
+ inset-inline-end: 12px;
58
+ inline-size: 10px;
59
+ block-size: 10px;
60
+ fill: none;
61
+ stroke: currentColor;
62
+ stroke-width: 1.8;
63
+ pointer-events: none;
64
+ }
65
+
66
+ .qn-lang__select:hover {
67
+ border-color: var(--qn-border-strong, currentColor);
68
+ }
69
+
70
+ .qn-lang__select:focus-visible {
71
+ outline: 2px solid var(--qn-focus, currentColor);
72
+ outline-offset: 2px;
73
+ }
74
+
75
+ .qn-lang__select option {
76
+ color: initial;
77
+ background: initial;
78
+ }
79
+
80
+ /* ── inline-list ────────────────────────────────────────── */
81
+
82
+ .qn-lang--inline-list {
83
+ display: block;
84
+ }
85
+
86
+ .qn-lang__list {
87
+ display: flex;
88
+ flex-wrap: wrap;
89
+ align-items: center;
90
+ gap: 4px 8px;
91
+ margin: 0;
92
+ padding: 0;
93
+ list-style: none;
94
+ }
95
+
96
+ .qn-lang__link {
97
+ display: inline-flex;
98
+ align-items: center;
99
+ min-inline-size: 44px;
100
+ min-block-size: 44px;
101
+ padding-inline: 10px;
102
+ border-radius: var(--qn-radius-sm, 6px);
103
+ color: inherit;
104
+ font: inherit;
105
+ text-align: center;
106
+ text-decoration: none;
107
+ justify-content: center;
108
+ }
109
+
110
+ .qn-lang__link:hover {
111
+ text-decoration: underline;
112
+ text-underline-offset: 3px;
113
+ }
114
+
115
+ .qn-lang__link[aria-current='true'] {
116
+ font-weight: 600;
117
+ text-decoration: underline;
118
+ text-underline-offset: 3px;
119
+ }
120
+
121
+ .qn-lang__link:focus-visible {
122
+ outline: 2px solid var(--qn-focus, currentColor);
123
+ outline-offset: 2px;
124
+ }
125
+
126
+ @media (prefers-reduced-motion: no-preference) {
127
+ .qn-lang__select,
128
+ .qn-lang__link {
129
+ transition: border-color 0.15s ease;
130
+ }
131
+ }
@@ -0,0 +1,175 @@
1
+ 'use client';
2
+
3
+ import { useContext, useState, type JSX, type MouseEvent, type ReactNode } from 'react';
4
+ import { StorefrontContext, useThemeStrings } from '../provider';
5
+ import { Link } from '../navigation';
6
+ import { useLocales, type LocalesState } from '../hooks/use-locales';
7
+ import type { StoreLocale } from '../utils/locale-switch';
8
+ import type { MenuAlign, MenuSide } from '../utils/menu-placement';
9
+ import { LanguageMenu } from './language-menu';
10
+ import './language-switcher.css';
11
+
12
+ export interface LanguageSwitcherProps {
13
+ /** Extra class on the root, to position or restyle the control. */
14
+ className?: string;
15
+ /**
16
+ * `'menu'`: a trigger button and a list of language links — a popover on
17
+ * wide screens, a bottom sheet on narrow ones; fully restylable through
18
+ * documented parts and `--qn-lang-*` variables. `'select'` (default, legacy):
19
+ * one native dropdown. `'inline-list'`: every language as a link, wrapping
20
+ * onto new lines on narrow screens; suits a footer with up to four languages.
21
+ */
22
+ variant?: 'select' | 'inline-list' | 'menu';
23
+ /** `menu` only: open below (`'bottom'`, default) or above (`'top'`, for footers) the trigger. Flips on its own when it does not fit. */
24
+ side?: MenuSide;
25
+ /** `menu` only: line the popover up with the trigger's inline `'end'` (default) or `'start'` edge. */
26
+ align?: MenuAlign;
27
+ /** Append the language code, e.g. `Français · FR`. Default false. */
28
+ showCode?: boolean;
29
+ /** Label each language in its own language (`Français`) rather than English (`French`). Default true. */
30
+ showNativeName?: boolean;
31
+ /**
32
+ * Take over the presentation. Called with the languages, the active one
33
+ * and `hrefFor` / `switchTo`; whatever it returns renders instead of the
34
+ * default control. Still renders nothing on a single-language store.
35
+ */
36
+ children?: (state: LanguageSwitcherState) => ReactNode;
37
+ }
38
+
39
+ /** What the render-prop receives: the `useLocales()` state plus the translated labels. */
40
+ export interface LanguageSwitcherState extends LocalesState {
41
+ /** "Language" — the group label. */
42
+ label: string;
43
+ /** Whether the `menu` variant is open. A custom UI may drive it with `setOpen`. */
44
+ open: boolean;
45
+ setOpen: (open: boolean) => void;
46
+ /** "Language: French" — names the control and the current language. */
47
+ labelFor: (locale: StoreLocale) => string;
48
+ /** The text to show for one language, honouring `showNativeName` / `showCode`. */
49
+ displayName: (locale: StoreLocale) => string;
50
+ }
51
+
52
+ /**
53
+ * The storefront's language control, placed by the theme where it wants it
54
+ * (a header, a footer, a menu). Renders nothing outside a storefront or when
55
+ * the store has a single language, so it is safe to place unconditionally.
56
+ *
57
+ * Switching keeps the page, the query string and the hash. Labels come from
58
+ * the theme strings (`language.label`, `language.named`) with English
59
+ * defaults. Colours and radius come from the `--qn-*` tokens; the layout is
60
+ * direction-agnostic, so it mirrors in right-to-left storefronts.
61
+ *
62
+ * A theme that places this sets `languageSwitcher: 'theme'` in its manifest,
63
+ * so the storefront does not also render its own floating control.
64
+ */
65
+ export function LanguageSwitcher(props: LanguageSwitcherProps): JSX.Element | null {
66
+ const inStorefront = useContext(StorefrontContext) !== null;
67
+ if (!inStorefront) return null;
68
+ return <LanguageSwitcherInner {...props} />;
69
+ }
70
+
71
+ function LanguageSwitcherInner({
72
+ className,
73
+ variant = 'select',
74
+ side = 'bottom',
75
+ align = 'end',
76
+ showCode = false,
77
+ showNativeName = true,
78
+ children,
79
+ }: LanguageSwitcherProps): JSX.Element | null {
80
+ const t = useThemeStrings();
81
+ const base = useLocales();
82
+ const [open, setOpen] = useState(false);
83
+ const { locales, active, hasMultiple, hrefFor, switchTo } = base;
84
+
85
+ if (!hasMultiple) return null;
86
+
87
+ const label = t('language.label');
88
+ const labelFor = (locale: StoreLocale): string => t('language.named', { name: locale.name });
89
+ const displayName = (locale: StoreLocale): string => {
90
+ const name = showNativeName ? locale.native_name : locale.name;
91
+ return showCode ? `${name} · ${locale.locale.toUpperCase()}` : name;
92
+ };
93
+
94
+ const state: LanguageSwitcherState = { ...base, label, open, setOpen, labelFor, displayName };
95
+
96
+ if (children) return <>{children(state)}</>;
97
+
98
+ const rootClass = ['qn-lang', `qn-lang--${variant}`, className].filter(Boolean).join(' ');
99
+
100
+ if (variant === 'menu') {
101
+ const nameOf = (locale: StoreLocale): string => (showNativeName ? locale.native_name : locale.name);
102
+ return (
103
+ <LanguageMenu
104
+ state={state}
105
+ rootClass={rootClass}
106
+ showCode={showCode}
107
+ side={side}
108
+ align={align}
109
+ nameOf={nameOf}
110
+ triggerLabelFor={(locale) => t('language.named', { name: nameOf(locale) })}
111
+ />
112
+ );
113
+ }
114
+
115
+ if (variant === 'inline-list') {
116
+ return (
117
+ <nav className={rootClass} aria-label={label} data-qn-language-switcher="" data-variant="inline-list">
118
+ <ul className="qn-lang__list">
119
+ {locales.map((locale) => {
120
+ const isActive = locale.locale === active.locale;
121
+ return (
122
+ <li key={locale.locale}>
123
+ <Link
124
+ className="qn-lang__link"
125
+ href={hrefFor(locale)}
126
+ prefetch={false}
127
+ lang={locale.html_lang}
128
+ hrefLang={locale.hreflang}
129
+ aria-current={isActive ? 'true' : undefined}
130
+ aria-label={isActive ? labelFor(locale) : undefined}
131
+ onClick={(event: MouseEvent<HTMLAnchorElement>) => {
132
+ // Plain clicks go through `switchTo`, which reads the live
133
+ // query string; modified clicks keep the browser default.
134
+ if (event.defaultPrevented || event.button !== 0) return;
135
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
136
+ event.preventDefault();
137
+ switchTo(locale);
138
+ }}
139
+ >
140
+ {displayName(locale)}
141
+ </Link>
142
+ </li>
143
+ );
144
+ })}
145
+ </ul>
146
+ </nav>
147
+ );
148
+ }
149
+
150
+ return (
151
+ <div className={rootClass} role="group" aria-label={label} data-qn-language-switcher="" data-variant="select">
152
+ <svg className="qn-lang__icon" aria-hidden="true" viewBox="0 0 24 24" focusable="false">
153
+ <circle cx="12" cy="12" r="9" />
154
+ <path d="M3.5 12h17M12 3c2.3 2.4 3.5 5.4 3.5 9s-1.2 6.6-3.5 9c-2.3-2.4-3.5-5.4-3.5-9S9.7 5.4 12 3Z" />
155
+ </svg>
156
+ <span className="qn-lang__field">
157
+ <select
158
+ className="qn-lang__select"
159
+ aria-label={labelFor(active)}
160
+ value={active.locale}
161
+ onChange={(event) => switchTo(event.target.value)}
162
+ >
163
+ {locales.map((locale) => (
164
+ <option key={locale.locale} value={locale.locale} lang={locale.html_lang} dir={locale.dir}>
165
+ {displayName(locale)}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ <svg className="qn-lang__chevron" aria-hidden="true" viewBox="0 0 10 10" focusable="false">
170
+ <path d="M1 3.5 5 7.5 9 3.5" />
171
+ </svg>
172
+ </span>
173
+ </div>
174
+ );
175
+ }
@@ -16,7 +16,7 @@ import { Image } from './image';
16
16
  * Inline markdown images are routed through the core <Image> component so the
17
17
  * Smart Placeholder / fallback system applies (never a bare, unoptimized
18
18
  * <img>). Short-form snippets (footers, product blurbs) keep using the
19
- * lightweight `renderMarkdown` helper in `lib/core/utils/markdown`.
19
+ * lightweight `renderMarkdown` helper in `utils/markdown`.
20
20
  */
21
21
  function MarkdownImage({ src, alt }: ComponentPropsWithoutRef<'img'>): JSX.Element | null {
22
22
  if (!src || typeof src !== 'string') {