@usequeek/theme-kit 0.1.22 → 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 +111 -5
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +39 -8
- package/package.json +1 -1
- package/tests/fixtures/external-theme.tsx +7 -2
- package/tests/language-menu.test.tsx +598 -0
- package/tests/menu-placement.test.ts +63 -0
- package/utils/menu-placement.ts +91 -0
|
@@ -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
|
+
}
|
|
@@ -1,21 +1,29 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useContext, type JSX, type MouseEvent, type ReactNode } from 'react';
|
|
3
|
+
import { useContext, useState, type JSX, type MouseEvent, type ReactNode } from 'react';
|
|
4
4
|
import { StorefrontContext, useThemeStrings } from '../provider';
|
|
5
5
|
import { Link } from '../navigation';
|
|
6
6
|
import { useLocales, type LocalesState } from '../hooks/use-locales';
|
|
7
7
|
import type { StoreLocale } from '../utils/locale-switch';
|
|
8
|
+
import type { MenuAlign, MenuSide } from '../utils/menu-placement';
|
|
9
|
+
import { LanguageMenu } from './language-menu';
|
|
8
10
|
import './language-switcher.css';
|
|
9
11
|
|
|
10
12
|
export interface LanguageSwitcherProps {
|
|
11
13
|
/** Extra class on the root, to position or restyle the control. */
|
|
12
14
|
className?: string;
|
|
13
15
|
/**
|
|
14
|
-
* `'
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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.
|
|
17
21
|
*/
|
|
18
|
-
variant?: 'select' | 'inline-list';
|
|
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;
|
|
19
27
|
/** Append the language code, e.g. `Français · FR`. Default false. */
|
|
20
28
|
showCode?: boolean;
|
|
21
29
|
/** Label each language in its own language (`Français`) rather than English (`French`). Default true. */
|
|
@@ -32,6 +40,9 @@ export interface LanguageSwitcherProps {
|
|
|
32
40
|
export interface LanguageSwitcherState extends LocalesState {
|
|
33
41
|
/** "Language" — the group label. */
|
|
34
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;
|
|
35
46
|
/** "Language: French" — names the control and the current language. */
|
|
36
47
|
labelFor: (locale: StoreLocale) => string;
|
|
37
48
|
/** The text to show for one language, honouring `showNativeName` / `showCode`. */
|
|
@@ -60,13 +71,16 @@ export function LanguageSwitcher(props: LanguageSwitcherProps): JSX.Element | nu
|
|
|
60
71
|
function LanguageSwitcherInner({
|
|
61
72
|
className,
|
|
62
73
|
variant = 'select',
|
|
74
|
+
side = 'bottom',
|
|
75
|
+
align = 'end',
|
|
63
76
|
showCode = false,
|
|
64
77
|
showNativeName = true,
|
|
65
78
|
children,
|
|
66
79
|
}: LanguageSwitcherProps): JSX.Element | null {
|
|
67
80
|
const t = useThemeStrings();
|
|
68
|
-
const
|
|
69
|
-
const
|
|
81
|
+
const base = useLocales();
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const { locales, active, hasMultiple, hrefFor, switchTo } = base;
|
|
70
84
|
|
|
71
85
|
if (!hasMultiple) return null;
|
|
72
86
|
|
|
@@ -77,10 +91,27 @@ function LanguageSwitcherInner({
|
|
|
77
91
|
return showCode ? `${name} · ${locale.locale.toUpperCase()}` : name;
|
|
78
92
|
};
|
|
79
93
|
|
|
80
|
-
|
|
94
|
+
const state: LanguageSwitcherState = { ...base, label, open, setOpen, labelFor, displayName };
|
|
95
|
+
|
|
96
|
+
if (children) return <>{children(state)}</>;
|
|
81
97
|
|
|
82
98
|
const rootClass = ['qn-lang', `qn-lang--${variant}`, className].filter(Boolean).join(' ');
|
|
83
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
|
+
|
|
84
115
|
if (variant === 'inline-list') {
|
|
85
116
|
return (
|
|
86
117
|
<nav className={rootClass} aria-label={label} data-qn-language-switcher="" data-variant="inline-list">
|
package/package.json
CHANGED
|
@@ -37,8 +37,13 @@ export function Footer({ copyright }: FooterProps): JSX.Element {
|
|
|
37
37
|
<footer>
|
|
38
38
|
{copyright}
|
|
39
39
|
<LanguageSwitcher variant="inline-list" showCode />
|
|
40
|
-
<LanguageSwitcher
|
|
41
|
-
|
|
40
|
+
<LanguageSwitcher variant="menu" side="top" align="start" showCode />
|
|
41
|
+
<LanguageSwitcher variant="menu">
|
|
42
|
+
{({ active, displayName, open, setOpen }) => (
|
|
43
|
+
<button type="button" aria-expanded={open} onClick={() => setOpen(!open)}>
|
|
44
|
+
{displayName(active)}
|
|
45
|
+
</button>
|
|
46
|
+
)}
|
|
42
47
|
</LanguageSwitcher>
|
|
43
48
|
{hasMultiple ? locales.map((l) => <a key={l.locale} href={hrefFor(l)}>{label(l)}</a>) : null}
|
|
44
49
|
</footer>
|