@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.
- package/README.md +293 -117
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -0
- package/components/checkout/checkout-contact.tsx +3 -3
- package/components/image.tsx +5 -5
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +175 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +1 -1
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +13 -16
- package/locales/en.default.json +4 -0
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +3 -4
- package/strings/theme-strings.ts +9 -7
- package/tests/auth-email-errors.test.tsx +2 -2
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +17 -25
- package/tests/fixtures/external-theme.tsx +23 -2
- package/tests/language-menu.test.tsx +598 -0
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/menu-placement.test.ts +63 -0
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +5 -6
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +2 -2
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +1 -1
- package/tests/theme-strings-typed.test.tsx +1 -1
- package/tests/theme-strings.test.tsx +8 -11
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +5 -7
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +10 -11
- package/utils/menu-link.ts +1 -1
- package/utils/menu-placement.ts +91 -0
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- 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
|
+
}
|
package/components/markdown.tsx
CHANGED
|
@@ -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 `
|
|
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') {
|