@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 CHANGED
@@ -346,8 +346,10 @@ keeps the page, the query string and the hash.
346
346
  ```tsx
347
347
  import { LanguageSwitcher } from '@usequeek/theme-kit/components/language-switcher';
348
348
 
349
- <LanguageSwitcher /> // native <select> (default)
350
- <LanguageSwitcher variant="inline-list" /> // every language as a link
349
+ <LanguageSwitcher variant="menu" /> // button + popover (sheet on phones) — recommended
350
+ <LanguageSwitcher variant="menu" side="top" /> // a footer: opens upward
351
+ <LanguageSwitcher variant="inline-list" /> // every language as a link (footer, up to four)
352
+ <LanguageSwitcher /> // native <select> — legacy
351
353
  ```
352
354
 
353
355
  Place it unconditionally, in the header or the footer: it renders nothing on a
@@ -357,10 +359,114 @@ store's languages.
357
359
  | Prop | Default | |
358
360
  | --- | --- | --- |
359
361
  | `className` | | Extra class on the root, to position or restyle it. |
360
- | `variant` | `'select'` | `'select'`: one native dropdown (best keyboard, touch and screen-reader behaviour). `'inline-list'`: a link per language that wraps on narrow screens; suits a footer. |
362
+ | `variant` | `'select'` | `'menu'`: a trigger button and a list of language links (below). `'inline-list'`: a link per language that wraps on narrow screens. `'select'`: one native dropdown, **legacy** — it works and is unchanged, but its open list is the operating system's menu and cannot be styled; prefer `menu`. |
363
+ | `side` | `'bottom'` | `menu` only. `'top'` opens above the trigger, for footers. Flips by itself when the preferred side does not fit. |
364
+ | `align` | `'end'` | `menu` only. Line the popover up with the trigger's inline `'end'` or `'start'` edge (mirrors in right-to-left). Flips and shifts to stay inside the viewport. |
361
365
  | `showNativeName` | `true` | Label languages in their own language (`Français`) instead of English (`French`). |
362
- | `showCode` | `false` | Append the code: `Français · FR`. |
363
- | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)` and `displayName(locale)`. |
366
+ | `showCode` | `false` | Show the language code (`FR`) next to the name. |
367
+ | `children` | | Render-prop `(state) => ReactNode`: replaces the default control entirely (never called on a single-language store). `state` is `useLocales()` plus `label`, `labelFor(locale)`, `displayName(locale)`, `open` and `setOpen`. |
368
+
369
+ ### The `menu` variant
370
+
371
+ A real `<button>` opens a list of language **links** (`<a href>` built from the
372
+ current page, so open-in-new-tab and copy-link work). On screens wider than
373
+ 640px it is a popover under the trigger; below 640px it becomes a bottom sheet
374
+ with a scrim, safe-area padding, and page scroll locked while it is open. The
375
+ wide/narrow layout switch is CSS only (one media query), so the markup is the
376
+ same on both and server and client agree; JavaScript only consults the media
377
+ query while the menu opens, to lock scroll and to place the popover.
378
+ With more than four languages in a footer, use `menu` with `side="top"` rather
379
+ than `inline-list`. 2 to 20 languages are fine: the list scrolls past about
380
+ eight rows, and a long list can use two columns on wide screens (below).
381
+
382
+ It is the *disclosure* pattern, not a listbox, because choosing an item
383
+ navigates: the button has `aria-expanded` and `aria-controls` and the name
384
+ "Language: English"; the list is a `<ul>` labelled by its title; the current
385
+ language's link has `aria-current="true"`; there are no `option` roles.
386
+
387
+ | Key | |
388
+ | --- | --- |
389
+ | Enter, Space, ArrowDown on the button | Open, focus on the current language |
390
+ | ArrowUp on the button | Open, focus on the last language |
391
+ | ArrowDown / ArrowUp in the list | Next / previous link (wraps) |
392
+ | Home / End | First / last link |
393
+ | Typing letters | Jump to the language whose name or code starts with them (500 ms buffer) |
394
+ | Enter on a link | Follow it (native); Space does the same |
395
+ | Escape | Close and return focus to the button |
396
+ | Tab | Close the menu |
397
+ | Click outside, scrim tap | Close |
398
+
399
+ Only the focused link is in the tab order (roving tabindex), so Tab leaves the
400
+ list and closes it. Choosing a language closes the menu, returns focus to the
401
+ button and navigates (a modified click — new tab, copy link — is left to the
402
+ browser). Motion respects `prefers-reduced-motion` (an 80 ms fade only).
403
+
404
+ **Styling surface.** Every part has a class and a `data-part` attribute; the
405
+ root also carries state attributes:
406
+
407
+ | Part | Class | `data-part` |
408
+ | --- | --- | --- |
409
+ | Root | `qn-lang qn-lang--menu` | `data-qn-language-switcher`, `data-variant="menu"` |
410
+ | Trigger button | `qn-lang__trigger` | `trigger` |
411
+ | Trigger name / code / chevron | `qn-lang__trigger-label` / `__trigger-code` / `__chevron` | `trigger-label` / `trigger-code` / `chevron` |
412
+ | Scrim (sheet) | `qn-lang__scrim` | `scrim` |
413
+ | Popover / sheet | `qn-lang__popover` | `popover` |
414
+ | Sheet grabber | `qn-lang__handle` | `handle` |
415
+ | Group label | `qn-lang__title` | `title` |
416
+ | List / item | `qn-lang__options` / `__item` | `list` / `item` |
417
+ | Link (current: `aria-current="true"`, `data-current="true"`) | `qn-lang__option` | `option` |
418
+ | Name / code / current marker | `qn-lang__name` / `__code` / `__mark` | `name` / `code` / `mark` |
419
+
420
+ Root attributes: `data-state="open|closed"`, `data-side="bottom|top"`,
421
+ `data-align="start|end"`, `data-mode="popover|sheet"` (while open),
422
+ `data-count="<n>"`, `data-many="true"` (more than ten languages),
423
+ `data-show-code="true"`. The code spans are always rendered and hidden by
424
+ default, so a theme can show a code-only trigger with
425
+ `.my-header [data-part='trigger-label'] { display: none }` plus
426
+ `.my-header [data-part='trigger-code'] { display: inline }`.
427
+
428
+ | Variable | Default | Controls |
429
+ | --- | --- | --- |
430
+ | `--qn-lang-surface` | `--qn-surface` | popover / sheet background |
431
+ | `--qn-lang-ink` | `--qn-text` | text colour |
432
+ | `--qn-lang-muted` | `--qn-text-muted` | title and code colour |
433
+ | `--qn-lang-border` | `--qn-border` | hairlines |
434
+ | `--qn-lang-radius` | `--qn-radius-md` (8px) | trigger / popover corners |
435
+ | `--qn-lang-item-radius` | `--qn-radius-sm` (6px) | link corners |
436
+ | `--qn-lang-item-hover` / `--qn-lang-current-bg` | 6% ink / none | hover and current-link backgrounds |
437
+ | `--qn-lang-accent` | `--qn-accent` | current marker colour |
438
+ | `--qn-lang-focus` | `--qn-focus` | focus ring |
439
+ | `--qn-lang-shadow` | subtle | popover shadow |
440
+ | `--qn-lang-row-height` / `--qn-lang-sheet-row-height` | 44px / 52px | link height (popover / sheet) |
441
+ | `--qn-lang-gap` / `--qn-lang-pad` | 0 / 4px | space between links / inside the popover |
442
+ | `--qn-lang-offset` | 8px | gap between trigger and popover |
443
+ | `--qn-lang-popover-width` | 240px | popover width |
444
+ | `--qn-lang-many-width` / `--qn-lang-many-columns` | popover width / 1 | width and columns when `data-many="true"` (set `--qn-lang-many-columns: 2` and a wider width for two columns) |
445
+ | `--qn-lang-list-max` | 8.5 rows | height at which the list scrolls |
446
+ | `--qn-lang-sheet-radius` | 16px | sheet top corners |
447
+ | `--qn-lang-scrim` | 40% black | scrim colour |
448
+ | `--qn-lang-z` | 50 | z-index |
449
+ | `--qn-lang-duration` / `--qn-lang-ease` | 160ms / ease-out curve | open motion |
450
+
451
+ Each variable falls back to the matching `--qn-*` token, then a neutral
452
+ value, and is never set by the kit itself — set it on the root, on an
453
+ ancestor, or on `:root`. The sheet breakpoint (640px) is fixed.
454
+
455
+ Restyling recipe — no override of internals needed:
456
+
457
+ ```css
458
+ .site-header [data-qn-language-switcher] {
459
+ --qn-lang-surface: #f3f0e9;
460
+ --qn-lang-ink: #1b2029;
461
+ --qn-lang-radius: 0;
462
+ --qn-lang-item-radius: 0;
463
+ --qn-lang-popover-width: 280px;
464
+ --qn-lang-sheet-radius: 0;
465
+ --qn-lang-duration: 120ms;
466
+ }
467
+ .site-header [data-part='trigger'] { border: 0; letter-spacing: 0.12em; text-transform: uppercase; }
468
+ .site-header [data-part='option'][aria-current='true'] { font-style: italic; }
469
+ ```
364
470
 
365
471
  **Tell the storefront the theme places it.** Set `languageSwitcher: 'theme'` in
366
472
  the theme manifest. Without it the storefront keeps rendering its own floating
@@ -0,0 +1,349 @@
1
+ /* ============================================================
2
+ LanguageSwitcher variant="menu" — a trigger and a list of language links:
3
+ a popover on wide screens, a bottom sheet below 640px. Prefix: qn-lang.
4
+
5
+ STYLING SURFACE. Every part has a class AND a data-part attribute:
6
+ root .qn-lang--menu [data-qn-language-switcher][data-variant="menu"]
7
+ trigger .qn-lang__trigger [data-part="trigger"]
8
+ label/code .qn-lang__trigger-label [data-part="trigger-label"]
9
+ .qn-lang__trigger-code [data-part="trigger-code"]
10
+ chevron .qn-lang__chevron [data-part="chevron"]
11
+ scrim .qn-lang__scrim [data-part="scrim"]
12
+ popover/sheet .qn-lang__popover [data-part="popover"]
13
+ grabber .qn-lang__handle [data-part="handle"]
14
+ group label .qn-lang__title [data-part="title"]
15
+ list .qn-lang__options [data-part="list"]
16
+ item .qn-lang__item [data-part="item"]
17
+ link .qn-lang__option [data-part="option"] (aria-current / data-current on the current one)
18
+ name/code .qn-lang__name / __code [data-part="name"] / [data-part="code"]
19
+ marker .qn-lang__mark [data-part="mark"]
20
+ State on the root: data-state="open|closed", data-side="bottom|top",
21
+ data-align="start|end", data-mode="popover|sheet" (while open),
22
+ data-count="<n>", data-many="true" (more than 10), data-show-code="true".
23
+
24
+ VARIABLES (--qn-lang-*): each is read as var(--qn-lang-x, var(--qn-token, fallback)),
25
+ and none is declared on the root, so set them on the root, an ancestor or :root.
26
+ surface ink muted border radius item-radius item-hover current-bg accent
27
+ focus shadow row-height sheet-row-height gap pad offset popover-width
28
+ many-width many-columns list-max sheet-radius scrim z duration ease
29
+ Logical properties only: it mirrors itself in right-to-left storefronts.
30
+ ============================================================ */
31
+
32
+ .qn-lang--menu {
33
+ position: relative;
34
+ display: inline-block;
35
+ max-inline-size: 100%;
36
+ }
37
+
38
+ /* ── trigger ───────────────────────────────────────────── */
39
+
40
+ .qn-lang__trigger {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ gap: 8px;
44
+ min-inline-size: 44px;
45
+ min-block-size: 44px;
46
+ padding-inline: 12px;
47
+ border: 1px solid var(--qn-lang-border, var(--qn-border, color-mix(in srgb, currentColor 22%, transparent)));
48
+ border-radius: var(--qn-lang-radius, var(--qn-radius-sm, 6px));
49
+ background: transparent;
50
+ color: var(--qn-lang-ink, var(--qn-text, inherit));
51
+ font: inherit;
52
+ line-height: 1.25;
53
+ white-space: nowrap;
54
+ cursor: pointer;
55
+ }
56
+
57
+ .qn-lang__trigger:hover {
58
+ border-color: var(--qn-lang-ink, var(--qn-text, currentColor));
59
+ }
60
+
61
+ .qn-lang__trigger:focus-visible,
62
+ .qn-lang__option:focus-visible {
63
+ outline: 2px solid var(--qn-lang-focus, var(--qn-focus, currentColor));
64
+ outline-offset: 2px;
65
+ }
66
+
67
+ .qn-lang__trigger-code,
68
+ .qn-lang__code {
69
+ display: none;
70
+ color: var(--qn-lang-muted, var(--qn-text-muted, color-mix(in srgb, currentColor 60%, transparent)));
71
+ font-size: 0.8em;
72
+ letter-spacing: 0.04em;
73
+ }
74
+
75
+ .qn-lang--menu[data-show-code='true'] .qn-lang__trigger-code,
76
+ .qn-lang--menu[data-show-code='true'] .qn-lang__code {
77
+ display: inline;
78
+ }
79
+
80
+ .qn-lang__trigger .qn-lang__chevron {
81
+ position: static;
82
+ inline-size: 10px;
83
+ block-size: 10px;
84
+ flex: none;
85
+ fill: none;
86
+ stroke: currentColor;
87
+ stroke-width: 1.8;
88
+ }
89
+
90
+ /* ── popover ───────────────────────────────────────────── */
91
+
92
+ .qn-lang__popover {
93
+ position: absolute;
94
+ z-index: var(--qn-lang-z, 50);
95
+ display: flex;
96
+ flex-direction: column;
97
+ box-sizing: border-box;
98
+ inline-size: var(--qn-lang-popover-width, 240px);
99
+ max-inline-size: calc(100vw - 32px);
100
+ max-block-size: var(--qn-lang-avail, none);
101
+ padding: var(--qn-lang-pad, 4px);
102
+ overflow: hidden;
103
+ border: 1px solid var(--qn-lang-border, var(--qn-border, color-mix(in srgb, currentColor 16%, transparent)));
104
+ border-radius: var(--qn-lang-radius, var(--qn-radius-md, 8px));
105
+ background: var(--qn-lang-surface, var(--qn-surface, Canvas));
106
+ color: var(--qn-lang-ink, var(--qn-text, CanvasText));
107
+ box-shadow: var(--qn-lang-shadow, 0 4px 16px rgb(0 0 0 / 0.08));
108
+ font: inherit;
109
+ text-align: start;
110
+ translate: var(--qn-lang-shift, 0px) 0;
111
+ }
112
+
113
+ .qn-lang__popover[hidden],
114
+ .qn-lang__scrim[hidden] {
115
+ display: none;
116
+ }
117
+
118
+ .qn-lang--menu[data-side='bottom'] > .qn-lang__popover {
119
+ inset-block-start: calc(100% + var(--qn-lang-offset, 8px));
120
+ }
121
+
122
+ .qn-lang--menu[data-side='top'] > .qn-lang__popover {
123
+ inset-block-end: calc(100% + var(--qn-lang-offset, 8px));
124
+ }
125
+
126
+ .qn-lang--menu[data-align='end'] > .qn-lang__popover {
127
+ inset-inline-end: 0;
128
+ }
129
+
130
+ .qn-lang--menu[data-align='start'] > .qn-lang__popover {
131
+ inset-inline-start: 0;
132
+ }
133
+
134
+ .qn-lang--menu[data-many='true'] > .qn-lang__popover {
135
+ inline-size: var(--qn-lang-many-width, var(--qn-lang-popover-width, 240px));
136
+ }
137
+
138
+ .qn-lang__handle,
139
+ .qn-lang__scrim {
140
+ display: none;
141
+ }
142
+
143
+ .qn-lang__title {
144
+ padding: 8px 12px 6px;
145
+ color: var(--qn-lang-muted, var(--qn-text-muted, color-mix(in srgb, currentColor 60%, transparent)));
146
+ font-size: 0.75em;
147
+ font-weight: 600;
148
+ letter-spacing: 0.06em;
149
+ text-transform: uppercase;
150
+ }
151
+
152
+ /* ── list ──────────────────────────────────────────────── */
153
+
154
+ .qn-lang__options {
155
+ display: grid;
156
+ grid-template-columns: repeat(var(--qn-lang-many-columns, 1), minmax(0, 1fr));
157
+ gap: var(--qn-lang-gap, 0);
158
+ flex: 1 1 auto;
159
+ min-block-size: 0;
160
+ max-block-size: var(--qn-lang-list-max, calc(var(--qn-lang-row-height, 44px) * 8.5));
161
+ margin: 0;
162
+ padding: 0;
163
+ overflow-y: auto;
164
+ overscroll-behavior: contain;
165
+ list-style: none;
166
+ }
167
+
168
+ /* The extra columns only apply to long lists. */
169
+ .qn-lang--menu:not([data-many='true']) .qn-lang__options {
170
+ grid-template-columns: minmax(0, 1fr);
171
+ }
172
+
173
+ .qn-lang__item {
174
+ display: block;
175
+ margin: 0;
176
+ padding: 0;
177
+ }
178
+
179
+ .qn-lang__option {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 12px;
183
+ box-sizing: border-box;
184
+ min-block-size: var(--qn-lang-row-height, 44px);
185
+ padding-inline: 12px;
186
+ border-radius: var(--qn-lang-item-radius, var(--qn-radius-sm, 6px));
187
+ color: inherit;
188
+ font: inherit;
189
+ text-decoration: none;
190
+ cursor: pointer;
191
+ }
192
+
193
+ .qn-lang__option:hover {
194
+ background: var(--qn-lang-item-hover, color-mix(in srgb, currentColor 6%, transparent));
195
+ }
196
+
197
+ .qn-lang__option[aria-current='true'] {
198
+ background: var(--qn-lang-current-bg, transparent);
199
+ font-weight: 600;
200
+ }
201
+
202
+ .qn-lang__option:focus-visible {
203
+ outline-offset: -2px;
204
+ }
205
+
206
+ .qn-lang__name {
207
+ flex: 1 1 auto;
208
+ min-inline-size: 0;
209
+ overflow: hidden;
210
+ text-overflow: ellipsis;
211
+ white-space: nowrap;
212
+ }
213
+
214
+ .qn-lang__code {
215
+ flex: none;
216
+ }
217
+
218
+ .qn-lang__mark {
219
+ display: inline-flex;
220
+ inline-size: 16px;
221
+ block-size: 16px;
222
+ flex: none;
223
+ color: var(--qn-lang-accent, var(--qn-accent, currentColor));
224
+ visibility: hidden;
225
+ }
226
+
227
+ .qn-lang__option[aria-current='true'] .qn-lang__mark {
228
+ visibility: visible;
229
+ }
230
+
231
+ .qn-lang__mark svg {
232
+ inline-size: 100%;
233
+ block-size: 100%;
234
+ fill: none;
235
+ stroke: currentColor;
236
+ stroke-width: 1.8;
237
+ stroke-linecap: round;
238
+ stroke-linejoin: round;
239
+ }
240
+
241
+ /* ── motion ────────────────────────────────────────────── */
242
+
243
+ @keyframes qn-lang-pop-down {
244
+ from { opacity: 0; transform: translateY(-4px); }
245
+ to { opacity: 1; transform: none; }
246
+ }
247
+
248
+ @keyframes qn-lang-pop-up {
249
+ from { opacity: 0; transform: translateY(4px); }
250
+ to { opacity: 1; transform: none; }
251
+ }
252
+
253
+ @keyframes qn-lang-sheet-in {
254
+ from { transform: translateY(100%); }
255
+ to { transform: none; }
256
+ }
257
+
258
+ @keyframes qn-lang-fade-in {
259
+ from { opacity: 0; }
260
+ to { opacity: 1; }
261
+ }
262
+
263
+ .qn-lang--menu[data-side='bottom'] > .qn-lang__popover:not([hidden]) {
264
+ animation: qn-lang-pop-down var(--qn-lang-duration, 160ms) var(--qn-lang-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
265
+ }
266
+
267
+ .qn-lang--menu[data-side='top'] > .qn-lang__popover:not([hidden]) {
268
+ animation: qn-lang-pop-up var(--qn-lang-duration, 160ms) var(--qn-lang-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
269
+ }
270
+
271
+ @media (prefers-reduced-motion: no-preference) {
272
+ .qn-lang__chevron {
273
+ transition: rotate var(--qn-lang-duration, 160ms) var(--qn-lang-ease, ease);
274
+ }
275
+ }
276
+
277
+ .qn-lang--menu[data-state='open'] .qn-lang__trigger .qn-lang__chevron {
278
+ rotate: 180deg;
279
+ }
280
+
281
+ /* ── bottom sheet (narrow screens) ─────────────────────── */
282
+
283
+ @media (max-width: 639.98px) {
284
+ .qn-lang__scrim:not([hidden]) {
285
+ position: fixed;
286
+ inset: 0;
287
+ z-index: calc(var(--qn-lang-z, 50) - 1);
288
+ display: block;
289
+ background: var(--qn-lang-scrim, rgb(0 0 0 / 0.4));
290
+ animation: qn-lang-fade-in var(--qn-lang-duration, 160ms) linear;
291
+ }
292
+
293
+ /* [data-side][data-align] outranks the popover placement rules above. */
294
+ .qn-lang--menu[data-side][data-align] > .qn-lang__popover {
295
+ position: fixed;
296
+ inset-block: auto 0;
297
+ inset-inline: 0;
298
+ inline-size: auto;
299
+ max-inline-size: none;
300
+ max-block-size: 85vh;
301
+ max-block-size: 85dvh;
302
+ padding: var(--qn-lang-pad, 4px) var(--qn-lang-pad, 4px) calc(var(--qn-lang-pad, 4px) + env(safe-area-inset-bottom, 0px));
303
+ border-inline-width: 0;
304
+ border-block-end-width: 0;
305
+ border-start-start-radius: var(--qn-lang-sheet-radius, 16px);
306
+ border-start-end-radius: var(--qn-lang-sheet-radius, 16px);
307
+ border-end-start-radius: 0;
308
+ border-end-end-radius: 0;
309
+ translate: none;
310
+ }
311
+
312
+ .qn-lang--menu[data-side][data-align] > .qn-lang__popover:not([hidden]) {
313
+ animation: qn-lang-sheet-in calc(var(--qn-lang-duration, 160ms) * 1.5) var(--qn-lang-ease, cubic-bezier(0.2, 0.8, 0.2, 1));
314
+ }
315
+
316
+ .qn-lang__handle {
317
+ display: block;
318
+ inline-size: 36px;
319
+ block-size: 4px;
320
+ margin: 8px auto 4px;
321
+ border-radius: 2px;
322
+ background: color-mix(in srgb, currentColor 25%, transparent);
323
+ }
324
+
325
+ .qn-lang--menu .qn-lang__options,
326
+ .qn-lang--menu:not([data-many='true']) .qn-lang__options {
327
+ grid-template-columns: minmax(0, 1fr);
328
+ max-block-size: none;
329
+ }
330
+
331
+ .qn-lang__option {
332
+ min-block-size: var(--qn-lang-sheet-row-height, 52px);
333
+ }
334
+ }
335
+
336
+ /* Page scroll is locked behind an open sheet. */
337
+ body.qn-lang-lock {
338
+ overflow: hidden;
339
+ }
340
+
341
+ @media (prefers-reduced-motion: reduce) {
342
+ .qn-lang--menu[data-side][data-align] > .qn-lang__popover:not([hidden]) {
343
+ animation: qn-lang-fade-in 80ms linear;
344
+ }
345
+
346
+ .qn-lang__scrim:not([hidden]) {
347
+ animation-duration: 80ms;
348
+ }
349
+ }