@lilydesignsystem/nunjucks-text-size-picker 0.1.0
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/dist/index.d.ts +399 -0
- package/dist/index.js +237 -0
- package/dist/text-size-picker.njk +101 -0
- package/index.md +300 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// TextSizePicker client-side runtime.
|
|
2
|
+
//
|
|
3
|
+
// Pairs with text-size-picker.njk. The macro renders the markup with
|
|
4
|
+
// `data-lily-text-size-picker-*` hooks; this module picks them up in
|
|
5
|
+
// the browser and owns two things:
|
|
6
|
+
//
|
|
7
|
+
// A. The listbox INTERACTION (new in the icon-button release): open /
|
|
8
|
+
// close, focus movement, the APG listbox keyboard contract, and
|
|
9
|
+
// typeahead. None of this exists in the server markup — the button
|
|
10
|
+
// is inert until this module runs. See docs/ssr.md.
|
|
11
|
+
//
|
|
12
|
+
// B. The text-size LIFECYCLE (unchanged):
|
|
13
|
+
// 0. Read the consumer's `value` prop from
|
|
14
|
+
// `data-lily-text-size-picker-value`. This is the only channel by
|
|
15
|
+
// which `opts.value` reaches the client, and it is what keeps the
|
|
16
|
+
// pre-hydration paint honest.
|
|
17
|
+
// 1. Set `data-text-size="{slug}"` on the resolved target
|
|
18
|
+
// (default <html>).
|
|
19
|
+
// 2. Optionally persist to localStorage.
|
|
20
|
+
// 3. Mirror the active slug into the hidden input (form
|
|
21
|
+
// participation) and onto the options' aria-selected state.
|
|
22
|
+
// 4. Call opts.onChange(slug).
|
|
23
|
+
//
|
|
24
|
+
// The consumer owns the actual typography via CSS keyed on
|
|
25
|
+
// `[data-text-size="{slug}"]`. This module makes no visual decisions.
|
|
26
|
+
//
|
|
27
|
+
// There is deliberately no system-preference detection: unlike
|
|
28
|
+
// `prefers-color-scheme` (theme-picker) and `navigator.languages`
|
|
29
|
+
// (locale-picker), the web platform exposes no OS "preferred text
|
|
30
|
+
// size" signal.
|
|
31
|
+
//
|
|
32
|
+
// See spec/index.md §4.3 (client.js exports), §5 (behaviour).
|
|
33
|
+
|
|
34
|
+
/** How long the typeahead buffer survives between keystrokes, in ms. */
|
|
35
|
+
const TYPEAHEAD_RESET_MS = 500;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a size slug to its display label: each hyphen-separated word
|
|
39
|
+
* title-cased, so "x-large" renders as "X Large".
|
|
40
|
+
*
|
|
41
|
+
* Mirrors `themeName` in theme-picker and `localeName` in
|
|
42
|
+
* locale-picker. This is the JS statement of the rule the macro applies
|
|
43
|
+
* in template syntax with `| replace(r/-/g, " ") | title`; a Nunjucks
|
|
44
|
+
* macro cannot call into this module, and delegating would force every
|
|
45
|
+
* consumer to register a custom filter, so the two are kept in
|
|
46
|
+
* agreement by a test rather than by delegation.
|
|
47
|
+
*/
|
|
48
|
+
function sizeName(size) {
|
|
49
|
+
return String(size || "")
|
|
50
|
+
.split("-")
|
|
51
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
52
|
+
.join(" ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeStorageGet(key) {
|
|
56
|
+
try {
|
|
57
|
+
return localStorage.getItem(key);
|
|
58
|
+
} catch (_e) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeStorageSet(key, value) {
|
|
64
|
+
try {
|
|
65
|
+
localStorage.setItem(key, value);
|
|
66
|
+
} catch (_e) {
|
|
67
|
+
// ignore quota / privacy errors
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** jsdom and older browsers do not always implement scrollIntoView. */
|
|
72
|
+
function scrollIntoViewIfPossible(el) {
|
|
73
|
+
if (el && typeof el.scrollIntoView === "function") {
|
|
74
|
+
el.scrollIntoView({ block: "nearest" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Wire one rendered TextSizePicker root.
|
|
80
|
+
*
|
|
81
|
+
* @param {HTMLElement} root - The <div data-lily-text-size-picker-root>.
|
|
82
|
+
* @param {{onChange?: (size:string)=>void, target?: HTMLElement|null}=} opts
|
|
83
|
+
* @returns {{setSize: (size: string) => void, destroy: () => void}}
|
|
84
|
+
*/
|
|
85
|
+
function initTextSizePicker(root, opts = {}) {
|
|
86
|
+
const noop = { setSize: () => {}, destroy: () => {} };
|
|
87
|
+
if (typeof document === "undefined" || !root) return noop;
|
|
88
|
+
|
|
89
|
+
const button = root.querySelector("[data-lily-text-size-picker-button]");
|
|
90
|
+
const list = root.querySelector("[data-lily-text-size-picker-list]");
|
|
91
|
+
const input = root.querySelector("[data-lily-text-size-picker-input]");
|
|
92
|
+
if (!button || !list) return noop;
|
|
93
|
+
|
|
94
|
+
const options = Array.from(list.querySelectorAll('[role="option"]'));
|
|
95
|
+
const values = options.map((o) => o.getAttribute("data-value") || "");
|
|
96
|
+
const labels = options.map((o) => (o.textContent || "").trim());
|
|
97
|
+
|
|
98
|
+
const storageKey =
|
|
99
|
+
root.getAttribute("data-lily-text-size-picker-storage-key") || "";
|
|
100
|
+
const defaultValue =
|
|
101
|
+
root.getAttribute("data-lily-text-size-picker-default-value") || "";
|
|
102
|
+
// The consumer's `value` prop. The macro emits it as a data
|
|
103
|
+
// attribute rather than baking it into a control the browser would
|
|
104
|
+
// paint before hydration.
|
|
105
|
+
const valueAttr = root.getAttribute("data-lily-text-size-picker-value") || "";
|
|
106
|
+
|
|
107
|
+
let current = "";
|
|
108
|
+
let open = false;
|
|
109
|
+
let activeIndex = -1;
|
|
110
|
+
let typeahead = "";
|
|
111
|
+
let typeaheadTimer;
|
|
112
|
+
|
|
113
|
+
// -----------------------------------------------------------------
|
|
114
|
+
// Applying a size
|
|
115
|
+
// -----------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
// The size the DOM currently carries. Applying is idempotent: a
|
|
118
|
+
// size already applied is a no-op, so `onChange` fires once per
|
|
119
|
+
// changed value. `setSize` on the returned api is this same
|
|
120
|
+
// function, so without the guard a consumer that mirrors the value
|
|
121
|
+
// back from `onChange` re-enters it forever.
|
|
122
|
+
let appliedValue = "";
|
|
123
|
+
|
|
124
|
+
function applySize(slug) {
|
|
125
|
+
if (!slug) return;
|
|
126
|
+
if (slug === appliedValue) return;
|
|
127
|
+
appliedValue = slug;
|
|
128
|
+
current = slug;
|
|
129
|
+
const target = opts.target || document.documentElement;
|
|
130
|
+
target.setAttribute("data-text-size", slug);
|
|
131
|
+
if (storageKey) safeStorageSet(storageKey, slug);
|
|
132
|
+
// The hidden input carries the value into any enclosing form.
|
|
133
|
+
if (input) input.value = slug;
|
|
134
|
+
// Keep the listbox's selected state in sync with the applied size.
|
|
135
|
+
options.forEach((o, i) => {
|
|
136
|
+
o.setAttribute("aria-selected", values[i] === slug ? "true" : "false");
|
|
137
|
+
});
|
|
138
|
+
if (typeof opts.onChange === "function") opts.onChange(slug);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// -----------------------------------------------------------------
|
|
142
|
+
// Open / close / active-option movement
|
|
143
|
+
// -----------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function setActive(index) {
|
|
146
|
+
activeIndex = index;
|
|
147
|
+
options.forEach((o, i) => {
|
|
148
|
+
if (i === index) o.setAttribute("data-active", "");
|
|
149
|
+
else o.removeAttribute("data-active");
|
|
150
|
+
});
|
|
151
|
+
if (index >= 0 && options[index]) {
|
|
152
|
+
list.setAttribute("aria-activedescendant", options[index].id);
|
|
153
|
+
scrollIntoViewIfPossible(options[index]);
|
|
154
|
+
} else {
|
|
155
|
+
list.removeAttribute("aria-activedescendant");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function openList(startIndex) {
|
|
160
|
+
const selected = values.indexOf(current);
|
|
161
|
+
// An empty list has no option to activate; -1 keeps
|
|
162
|
+
// aria-activedescendant off rather than pointing at an id that
|
|
163
|
+
// does not exist.
|
|
164
|
+
const start =
|
|
165
|
+
options.length === 0
|
|
166
|
+
? -1
|
|
167
|
+
: typeof startIndex === "number"
|
|
168
|
+
? startIndex
|
|
169
|
+
: selected >= 0
|
|
170
|
+
? selected
|
|
171
|
+
: 0;
|
|
172
|
+
open = true;
|
|
173
|
+
list.hidden = false;
|
|
174
|
+
button.setAttribute("aria-expanded", "true");
|
|
175
|
+
setActive(start);
|
|
176
|
+
// Focus moves to the listbox; the active option is conveyed via
|
|
177
|
+
// aria-activedescendant, per the APG listbox pattern.
|
|
178
|
+
list.focus({ preventScroll: true });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function closeList(refocus = true) {
|
|
182
|
+
if (!open) return;
|
|
183
|
+
open = false;
|
|
184
|
+
list.hidden = true;
|
|
185
|
+
button.setAttribute("aria-expanded", "false");
|
|
186
|
+
setActive(-1);
|
|
187
|
+
if (refocus) button.focus({ preventScroll: true });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function choose(index) {
|
|
191
|
+
const slug = values[index];
|
|
192
|
+
if (slug) applySize(slug);
|
|
193
|
+
closeList();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function moveActive(delta) {
|
|
197
|
+
if (options.length === 0) return;
|
|
198
|
+
// Clamp rather than wrap, matching the canonical Svelte helper.
|
|
199
|
+
const next = Math.min(Math.max(activeIndex + delta, 0), options.length - 1);
|
|
200
|
+
setActive(next);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function runTypeahead(char) {
|
|
204
|
+
const lower = char.toLowerCase();
|
|
205
|
+
// APG listbox typeahead: a single character moves to the NEXT
|
|
206
|
+
// option starting with it, and repeating that character keeps
|
|
207
|
+
// cycling. Only a buffer of differing characters refines the
|
|
208
|
+
// match, and that buffer stays anchored on the active option.
|
|
209
|
+
const sameCharRun =
|
|
210
|
+
typeahead === "" || Array.from(typeahead).every((c) => c === lower);
|
|
211
|
+
typeahead += lower;
|
|
212
|
+
clearTimeout(typeaheadTimer);
|
|
213
|
+
typeaheadTimer = setTimeout(() => {
|
|
214
|
+
typeahead = "";
|
|
215
|
+
}, TYPEAHEAD_RESET_MS);
|
|
216
|
+
const query = sameCharRun ? lower : typeahead;
|
|
217
|
+
const anchor = activeIndex < 0 ? 0 : activeIndex;
|
|
218
|
+
const start = sameCharRun ? anchor + 1 : anchor;
|
|
219
|
+
// Search forward, wrapping once — typeahead wraps even though the
|
|
220
|
+
// arrows clamp, or options above the cursor would be untypable.
|
|
221
|
+
for (let n = 0; n < options.length; n++) {
|
|
222
|
+
const i = (start + n) % options.length;
|
|
223
|
+
if (labels[i].toLowerCase().startsWith(query)) {
|
|
224
|
+
setActive(i);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// -----------------------------------------------------------------
|
|
231
|
+
// Event handlers
|
|
232
|
+
// -----------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
function onButtonClick() {
|
|
235
|
+
if (open) closeList();
|
|
236
|
+
else openList();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onButtonKeydown(event) {
|
|
240
|
+
switch (event.key) {
|
|
241
|
+
case "ArrowDown":
|
|
242
|
+
case "Enter":
|
|
243
|
+
case " ":
|
|
244
|
+
event.preventDefault();
|
|
245
|
+
openList();
|
|
246
|
+
break;
|
|
247
|
+
case "ArrowUp":
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
openList(options.length - 1);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function onListKeydown(event) {
|
|
255
|
+
switch (event.key) {
|
|
256
|
+
case "ArrowDown":
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
moveActive(1);
|
|
259
|
+
break;
|
|
260
|
+
case "ArrowUp":
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
moveActive(-1);
|
|
263
|
+
break;
|
|
264
|
+
case "Home":
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
setActive(0);
|
|
267
|
+
break;
|
|
268
|
+
case "End":
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
setActive(options.length - 1);
|
|
271
|
+
break;
|
|
272
|
+
case "Enter":
|
|
273
|
+
case " ":
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
if (activeIndex >= 0) choose(activeIndex);
|
|
276
|
+
break;
|
|
277
|
+
case "Escape":
|
|
278
|
+
event.preventDefault();
|
|
279
|
+
closeList();
|
|
280
|
+
break;
|
|
281
|
+
case "PageUp":
|
|
282
|
+
event.preventDefault();
|
|
283
|
+
moveActive(-10);
|
|
284
|
+
break;
|
|
285
|
+
case "PageDown":
|
|
286
|
+
// ±10, clamped: an APG-optional key for long lists.
|
|
287
|
+
event.preventDefault();
|
|
288
|
+
moveActive(10);
|
|
289
|
+
break;
|
|
290
|
+
case "Tab":
|
|
291
|
+
// Tab moves on — but focus goes to the button FIRST, without
|
|
292
|
+
// cancelling the key. Hiding the focused list drops focus to
|
|
293
|
+
// <body>, and the browser then computes the default Tab move
|
|
294
|
+
// from the top of the document, so tabbing out of an open
|
|
295
|
+
// picker teleported the user to the page's first tab stop.
|
|
296
|
+
// From the button, the default Tab lands exactly where leaving
|
|
297
|
+
// the picker should. Guard the METHOD, not just the element:
|
|
298
|
+
// this shape has bitten these helpers before.
|
|
299
|
+
button?.focus?.({ preventScroll: true });
|
|
300
|
+
closeList(false);
|
|
301
|
+
break;
|
|
302
|
+
default:
|
|
303
|
+
if (
|
|
304
|
+
event.key.length === 1 &&
|
|
305
|
+
!event.ctrlKey &&
|
|
306
|
+
!event.metaKey &&
|
|
307
|
+
!event.altKey
|
|
308
|
+
) {
|
|
309
|
+
runTypeahead(event.key);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function onListClick(event) {
|
|
315
|
+
const li =
|
|
316
|
+
event.target && event.target.closest
|
|
317
|
+
? event.target.closest('[role="option"]')
|
|
318
|
+
: null;
|
|
319
|
+
if (!li) return;
|
|
320
|
+
const index = options.indexOf(li);
|
|
321
|
+
if (index >= 0) choose(index);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function onRootFocusOut(event) {
|
|
325
|
+
const next = event.relatedTarget;
|
|
326
|
+
if (next && root.contains(next)) return;
|
|
327
|
+
closeList(false);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function onDocumentClick(event) {
|
|
331
|
+
if (!open) return;
|
|
332
|
+
const t = event.target;
|
|
333
|
+
if (t && !root.contains(t)) closeList(false);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
button.addEventListener("click", onButtonClick);
|
|
337
|
+
button.addEventListener("keydown", onButtonKeydown);
|
|
338
|
+
list.addEventListener("keydown", onListKeydown);
|
|
339
|
+
list.addEventListener("click", onListClick);
|
|
340
|
+
root.addEventListener("focusout", onRootFocusOut);
|
|
341
|
+
document.addEventListener("click", onDocumentClick);
|
|
342
|
+
|
|
343
|
+
// -----------------------------------------------------------------
|
|
344
|
+
// §5.1 initial value resolution
|
|
345
|
+
// value attribute > storage > default > "medium" > first
|
|
346
|
+
//
|
|
347
|
+
// Unchanged by the icon-button release: `value` already beat
|
|
348
|
+
// storage here, so unlike theme-picker there is no precedence
|
|
349
|
+
// reversal to warn about.
|
|
350
|
+
// -----------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
let initial = "";
|
|
353
|
+
|
|
354
|
+
// 1. value prop — read from `data-lily-text-size-picker-value`.
|
|
355
|
+
initial = valueAttr;
|
|
356
|
+
|
|
357
|
+
// 2. storage
|
|
358
|
+
if (!initial && storageKey) initial = safeStorageGet(storageKey) || "";
|
|
359
|
+
|
|
360
|
+
// 3. default-value
|
|
361
|
+
if (!initial && defaultValue) initial = defaultValue;
|
|
362
|
+
|
|
363
|
+
// 4. "medium" if present
|
|
364
|
+
if (!initial && values.includes("medium")) initial = "medium";
|
|
365
|
+
|
|
366
|
+
// 5. first option
|
|
367
|
+
if (!initial && values.length > 0) initial = values[0];
|
|
368
|
+
|
|
369
|
+
if (initial) applySize(initial);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
setSize: applySize,
|
|
373
|
+
destroy: () => {
|
|
374
|
+
clearTimeout(typeaheadTimer);
|
|
375
|
+
button.removeEventListener("click", onButtonClick);
|
|
376
|
+
button.removeEventListener("keydown", onButtonKeydown);
|
|
377
|
+
list.removeEventListener("keydown", onListKeydown);
|
|
378
|
+
list.removeEventListener("click", onListClick);
|
|
379
|
+
root.removeEventListener("focusout", onRootFocusOut);
|
|
380
|
+
document.removeEventListener("click", onDocumentClick);
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Find every [data-lily-text-size-picker-root] and wire it.
|
|
387
|
+
*
|
|
388
|
+
* @param {{onChange?: (size:string)=>void, target?: HTMLElement|null}=} opts
|
|
389
|
+
* @returns {Array<{setSize: (size:string)=>void, destroy: ()=>void}>}
|
|
390
|
+
*/
|
|
391
|
+
function autoInit(opts = {}) {
|
|
392
|
+
if (typeof document === "undefined") return [];
|
|
393
|
+
const roots = Array.from(
|
|
394
|
+
document.querySelectorAll("[data-lily-text-size-picker-root]"),
|
|
395
|
+
);
|
|
396
|
+
return roots.map((root) => initTextSizePicker(root, opts));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
export { autoInit, initTextSizePicker, sizeName };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// lily-design-system-nunjucks-text-size-picker/text-size-picker.client.js
|
|
2
|
+
var TYPEAHEAD_RESET_MS = 500;
|
|
3
|
+
function sizeName(size) {
|
|
4
|
+
return String(size || "").split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
5
|
+
}
|
|
6
|
+
function safeStorageGet(key) {
|
|
7
|
+
try {
|
|
8
|
+
return localStorage.getItem(key);
|
|
9
|
+
} catch (_e) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function safeStorageSet(key, value) {
|
|
14
|
+
try {
|
|
15
|
+
localStorage.setItem(key, value);
|
|
16
|
+
} catch (_e) {
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function scrollIntoViewIfPossible(el) {
|
|
20
|
+
if (el && typeof el.scrollIntoView === "function") {
|
|
21
|
+
el.scrollIntoView({ block: "nearest" });
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function initTextSizePicker(root, opts = {}) {
|
|
25
|
+
const noop = { setSize: () => {
|
|
26
|
+
}, destroy: () => {
|
|
27
|
+
} };
|
|
28
|
+
if (typeof document === "undefined" || !root) return noop;
|
|
29
|
+
const button = root.querySelector("[data-lily-text-size-picker-button]");
|
|
30
|
+
const list = root.querySelector("[data-lily-text-size-picker-list]");
|
|
31
|
+
const input = root.querySelector("[data-lily-text-size-picker-input]");
|
|
32
|
+
if (!button || !list) return noop;
|
|
33
|
+
const options = Array.from(list.querySelectorAll('[role="option"]'));
|
|
34
|
+
const values = options.map((o) => o.getAttribute("data-value") || "");
|
|
35
|
+
const labels = options.map((o) => (o.textContent || "").trim());
|
|
36
|
+
const storageKey = root.getAttribute("data-lily-text-size-picker-storage-key") || "";
|
|
37
|
+
const defaultValue = root.getAttribute("data-lily-text-size-picker-default-value") || "";
|
|
38
|
+
const valueAttr = root.getAttribute("data-lily-text-size-picker-value") || "";
|
|
39
|
+
let current = "";
|
|
40
|
+
let open = false;
|
|
41
|
+
let activeIndex = -1;
|
|
42
|
+
let typeahead = "";
|
|
43
|
+
let typeaheadTimer;
|
|
44
|
+
let appliedValue = "";
|
|
45
|
+
function applySize(slug) {
|
|
46
|
+
if (!slug) return;
|
|
47
|
+
if (slug === appliedValue) return;
|
|
48
|
+
appliedValue = slug;
|
|
49
|
+
current = slug;
|
|
50
|
+
const target = opts.target || document.documentElement;
|
|
51
|
+
target.setAttribute("data-text-size", slug);
|
|
52
|
+
if (storageKey) safeStorageSet(storageKey, slug);
|
|
53
|
+
if (input) input.value = slug;
|
|
54
|
+
options.forEach((o, i) => {
|
|
55
|
+
o.setAttribute("aria-selected", values[i] === slug ? "true" : "false");
|
|
56
|
+
});
|
|
57
|
+
if (typeof opts.onChange === "function") opts.onChange(slug);
|
|
58
|
+
}
|
|
59
|
+
function setActive(index) {
|
|
60
|
+
activeIndex = index;
|
|
61
|
+
options.forEach((o, i) => {
|
|
62
|
+
if (i === index) o.setAttribute("data-active", "");
|
|
63
|
+
else o.removeAttribute("data-active");
|
|
64
|
+
});
|
|
65
|
+
if (index >= 0 && options[index]) {
|
|
66
|
+
list.setAttribute("aria-activedescendant", options[index].id);
|
|
67
|
+
scrollIntoViewIfPossible(options[index]);
|
|
68
|
+
} else {
|
|
69
|
+
list.removeAttribute("aria-activedescendant");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function openList(startIndex) {
|
|
73
|
+
const selected = values.indexOf(current);
|
|
74
|
+
const start = options.length === 0 ? -1 : typeof startIndex === "number" ? startIndex : selected >= 0 ? selected : 0;
|
|
75
|
+
open = true;
|
|
76
|
+
list.hidden = false;
|
|
77
|
+
button.setAttribute("aria-expanded", "true");
|
|
78
|
+
setActive(start);
|
|
79
|
+
list.focus({ preventScroll: true });
|
|
80
|
+
}
|
|
81
|
+
function closeList(refocus = true) {
|
|
82
|
+
if (!open) return;
|
|
83
|
+
open = false;
|
|
84
|
+
list.hidden = true;
|
|
85
|
+
button.setAttribute("aria-expanded", "false");
|
|
86
|
+
setActive(-1);
|
|
87
|
+
if (refocus) button.focus({ preventScroll: true });
|
|
88
|
+
}
|
|
89
|
+
function choose(index) {
|
|
90
|
+
const slug = values[index];
|
|
91
|
+
if (slug) applySize(slug);
|
|
92
|
+
closeList();
|
|
93
|
+
}
|
|
94
|
+
function moveActive(delta) {
|
|
95
|
+
if (options.length === 0) return;
|
|
96
|
+
const next = Math.min(Math.max(activeIndex + delta, 0), options.length - 1);
|
|
97
|
+
setActive(next);
|
|
98
|
+
}
|
|
99
|
+
function runTypeahead(char) {
|
|
100
|
+
const lower = char.toLowerCase();
|
|
101
|
+
const sameCharRun = typeahead === "" || Array.from(typeahead).every((c) => c === lower);
|
|
102
|
+
typeahead += lower;
|
|
103
|
+
clearTimeout(typeaheadTimer);
|
|
104
|
+
typeaheadTimer = setTimeout(() => {
|
|
105
|
+
typeahead = "";
|
|
106
|
+
}, TYPEAHEAD_RESET_MS);
|
|
107
|
+
const query = sameCharRun ? lower : typeahead;
|
|
108
|
+
const anchor = activeIndex < 0 ? 0 : activeIndex;
|
|
109
|
+
const start = sameCharRun ? anchor + 1 : anchor;
|
|
110
|
+
for (let n = 0; n < options.length; n++) {
|
|
111
|
+
const i = (start + n) % options.length;
|
|
112
|
+
if (labels[i].toLowerCase().startsWith(query)) {
|
|
113
|
+
setActive(i);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function onButtonClick() {
|
|
119
|
+
if (open) closeList();
|
|
120
|
+
else openList();
|
|
121
|
+
}
|
|
122
|
+
function onButtonKeydown(event) {
|
|
123
|
+
switch (event.key) {
|
|
124
|
+
case "ArrowDown":
|
|
125
|
+
case "Enter":
|
|
126
|
+
case " ":
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
openList();
|
|
129
|
+
break;
|
|
130
|
+
case "ArrowUp":
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
openList(options.length - 1);
|
|
133
|
+
break;
|
|
134
|
+
default:
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function onListKeydown(event) {
|
|
139
|
+
var _a;
|
|
140
|
+
switch (event.key) {
|
|
141
|
+
case "ArrowDown":
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
moveActive(1);
|
|
144
|
+
break;
|
|
145
|
+
case "ArrowUp":
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
moveActive(-1);
|
|
148
|
+
break;
|
|
149
|
+
case "Home":
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
setActive(0);
|
|
152
|
+
break;
|
|
153
|
+
case "End":
|
|
154
|
+
event.preventDefault();
|
|
155
|
+
setActive(options.length - 1);
|
|
156
|
+
break;
|
|
157
|
+
case "Enter":
|
|
158
|
+
case " ":
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
if (activeIndex >= 0) choose(activeIndex);
|
|
161
|
+
break;
|
|
162
|
+
case "Escape":
|
|
163
|
+
event.preventDefault();
|
|
164
|
+
closeList();
|
|
165
|
+
break;
|
|
166
|
+
case "PageUp":
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
moveActive(-10);
|
|
169
|
+
break;
|
|
170
|
+
case "PageDown":
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
moveActive(10);
|
|
173
|
+
break;
|
|
174
|
+
case "Tab":
|
|
175
|
+
(_a = button == null ? void 0 : button.focus) == null ? void 0 : _a.call(button, { preventScroll: true });
|
|
176
|
+
closeList(false);
|
|
177
|
+
break;
|
|
178
|
+
default:
|
|
179
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
180
|
+
runTypeahead(event.key);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function onListClick(event) {
|
|
185
|
+
const li = event.target && event.target.closest ? event.target.closest('[role="option"]') : null;
|
|
186
|
+
if (!li) return;
|
|
187
|
+
const index = options.indexOf(li);
|
|
188
|
+
if (index >= 0) choose(index);
|
|
189
|
+
}
|
|
190
|
+
function onRootFocusOut(event) {
|
|
191
|
+
const next = event.relatedTarget;
|
|
192
|
+
if (next && root.contains(next)) return;
|
|
193
|
+
closeList(false);
|
|
194
|
+
}
|
|
195
|
+
function onDocumentClick(event) {
|
|
196
|
+
if (!open) return;
|
|
197
|
+
const t = event.target;
|
|
198
|
+
if (t && !root.contains(t)) closeList(false);
|
|
199
|
+
}
|
|
200
|
+
button.addEventListener("click", onButtonClick);
|
|
201
|
+
button.addEventListener("keydown", onButtonKeydown);
|
|
202
|
+
list.addEventListener("keydown", onListKeydown);
|
|
203
|
+
list.addEventListener("click", onListClick);
|
|
204
|
+
root.addEventListener("focusout", onRootFocusOut);
|
|
205
|
+
document.addEventListener("click", onDocumentClick);
|
|
206
|
+
let initial = "";
|
|
207
|
+
initial = valueAttr;
|
|
208
|
+
if (!initial && storageKey) initial = safeStorageGet(storageKey) || "";
|
|
209
|
+
if (!initial && defaultValue) initial = defaultValue;
|
|
210
|
+
if (!initial && values.includes("medium")) initial = "medium";
|
|
211
|
+
if (!initial && values.length > 0) initial = values[0];
|
|
212
|
+
if (initial) applySize(initial);
|
|
213
|
+
return {
|
|
214
|
+
setSize: applySize,
|
|
215
|
+
destroy: () => {
|
|
216
|
+
clearTimeout(typeaheadTimer);
|
|
217
|
+
button.removeEventListener("click", onButtonClick);
|
|
218
|
+
button.removeEventListener("keydown", onButtonKeydown);
|
|
219
|
+
list.removeEventListener("keydown", onListKeydown);
|
|
220
|
+
list.removeEventListener("click", onListClick);
|
|
221
|
+
root.removeEventListener("focusout", onRootFocusOut);
|
|
222
|
+
document.removeEventListener("click", onDocumentClick);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
function autoInit(opts = {}) {
|
|
227
|
+
if (typeof document === "undefined") return [];
|
|
228
|
+
const roots = Array.from(
|
|
229
|
+
document.querySelectorAll("[data-lily-text-size-picker-root]")
|
|
230
|
+
);
|
|
231
|
+
return roots.map((root) => initTextSizePicker(root, opts));
|
|
232
|
+
}
|
|
233
|
+
export {
|
|
234
|
+
autoInit,
|
|
235
|
+
initTextSizePicker,
|
|
236
|
+
sizeName
|
|
237
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
{#
|
|
2
|
+
TextSizePicker macro — icon button that opens a listbox of text sizes.
|
|
3
|
+
|
|
4
|
+
HTML tag: <div> (root), <button> + <ul role="listbox">
|
|
5
|
+
CSS class: text-size-picker
|
|
6
|
+
Companion runtime: text-size-picker.client.js
|
|
7
|
+
|
|
8
|
+
BREAKING (Unreleased): this macro no longer renders a native
|
|
9
|
+
<select>. It renders an icon button (a bundled stroke-drawn "A" SVG,
|
|
10
|
+
viewBox 0 0 16 16 — reversed 2026-09-16 from the Unicode glyph
|
|
11
|
+
U+0041 LATIN CAPITAL LETTER A) that opens a listbox, matching
|
|
12
|
+
theme-picker and locale-picker. All three helpers are now the same
|
|
13
|
+
shape.
|
|
14
|
+
|
|
15
|
+
Params (single `opts` object):
|
|
16
|
+
label — string, required. aria-label for the button AND the
|
|
17
|
+
listbox. The button is icon-only, so this is the
|
|
18
|
+
ONLY accessible name it has.
|
|
19
|
+
sizes — array<string>, required. Available size slugs.
|
|
20
|
+
value — string. Initial slug, emitted as
|
|
21
|
+
data-lily-text-size-picker-value for the client to
|
|
22
|
+
read.
|
|
23
|
+
defaultValue — string. Initial slug when nothing else is supplied.
|
|
24
|
+
storageKey — string. If non-empty, client.js persists to localStorage.
|
|
25
|
+
name — string. Hidden-input `name` (default "text-size").
|
|
26
|
+
sizeLabels — object<string,string>. Pretty label per slug.
|
|
27
|
+
id — string. Id prefix for the listbox and its options.
|
|
28
|
+
Defaults to "text-size-picker-{name}". Supply an
|
|
29
|
+
explicit id when rendering two instances that share
|
|
30
|
+
a `name`.
|
|
31
|
+
classes — string. Extra CSS classes on the root <div>.
|
|
32
|
+
attributes — object. Extra HTML attributes spread onto the root.
|
|
33
|
+
|
|
34
|
+
There is deliberately NO detection prop. Unlike theme-picker's
|
|
35
|
+
`prefers-color-scheme` and locale-picker's `navigator.languages`,
|
|
36
|
+
the web platform exposes no OS "preferred text size" signal, so
|
|
37
|
+
there is nothing to detect.
|
|
38
|
+
|
|
39
|
+
Custom icon: call the macro with a `{% call %}` block and the block
|
|
40
|
+
body replaces the default icon inside the button:
|
|
41
|
+
|
|
42
|
+
{% call textSizePicker({label: "Text size", sizes: [...]}) %}
|
|
43
|
+
<svg aria-hidden="true">…</svg>
|
|
44
|
+
{% endcall %}
|
|
45
|
+
|
|
46
|
+
Usage:
|
|
47
|
+
{% from "./text-size-picker.njk" import textSizePicker %}
|
|
48
|
+
{{ textSizePicker({
|
|
49
|
+
label: "Text size",
|
|
50
|
+
sizes: ["small", "medium", "large", "x-large"],
|
|
51
|
+
storageKey: "lily-text-size"
|
|
52
|
+
}) }}
|
|
53
|
+
|
|
54
|
+
Then load text-size-picker.client.js once and call autoInit().
|
|
55
|
+
|
|
56
|
+
NOTE: the button does nothing until text-size-picker.client.js runs.
|
|
57
|
+
See docs/ssr.md — this is a real no-JS regression from the old
|
|
58
|
+
<select>.
|
|
59
|
+
|
|
60
|
+
Spec: spec/index.md §4.1 (macro parameters), §4.2 (DOM contract).
|
|
61
|
+
#}
|
|
62
|
+
{%- macro textSizePicker(opts) -%}
|
|
63
|
+
{%- set name = opts.name | default("text-size") -%}
|
|
64
|
+
{%- set value = opts.value | default("") -%}
|
|
65
|
+
{%- set storageKey = opts.storageKey | default("") -%}
|
|
66
|
+
{%- set defaultValue = opts.defaultValue | default("") -%}
|
|
67
|
+
{%- set sizeLabels = opts.sizeLabels | default({}) -%}
|
|
68
|
+
{%- set id = opts.id | default("text-size-picker-" + name) -%}
|
|
69
|
+
{%- set listId = id + "-list" -%}
|
|
70
|
+
{#- Server-side selected resolution. localStorage is client-only —
|
|
71
|
+
there is no storage at render time — so the client may correct this
|
|
72
|
+
after hydration; the point is that the server markup always marks
|
|
73
|
+
exactly one option aria-selected. -#}
|
|
74
|
+
{%- set fallback = "medium" if "medium" in opts.sizes else opts.sizes[0] -%}
|
|
75
|
+
{%- set selected = value or defaultValue or fallback -%}
|
|
76
|
+
<div
|
|
77
|
+
class="text-size-picker{% if opts.classes %} {{ opts.classes }}{% endif %}"
|
|
78
|
+
data-lily-text-size-picker-root
|
|
79
|
+
data-lily-text-size-picker-name="{{ name }}"
|
|
80
|
+
data-lily-text-size-picker-storage-key="{{ storageKey }}"
|
|
81
|
+
data-lily-text-size-picker-default-value="{{ defaultValue }}"
|
|
82
|
+
{%- if value %} data-lily-text-size-picker-value="{{ value }}"{% endif %}
|
|
83
|
+
{%- if opts.attributes %}{% for k, v in opts.attributes %} {{ k }}="{{ v }}"{% endfor %}{% endif %}
|
|
84
|
+
>
|
|
85
|
+
<input type="hidden" name="{{ name }}" value="{{ selected }}" data-lily-text-size-picker-input>
|
|
86
|
+
<button type="button" class="text-size-picker-button" aria-label="{{ opts.label }}" aria-haspopup="listbox" aria-expanded="false" aria-controls="{{ listId }}" data-lily-text-size-picker-button>
|
|
87
|
+
{%- if caller %}{{ caller() }}{% else %}<svg class="text-size-picker-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="1.05rem" height="1.05rem"><path d="M4 13 7.2 3h1.6L12 13M5.4 9.5h5.2"/></svg>{% endif -%}
|
|
88
|
+
</button>
|
|
89
|
+
<ul class="text-size-picker-list" id="{{ listId }}" role="listbox" aria-label="{{ opts.label }}" tabindex="-1" hidden data-lily-text-size-picker-list>
|
|
90
|
+
{%- for slug in opts.sizes -%}
|
|
91
|
+
<li class="text-size-picker-option" id="{{ id }}-option-{{ loop.index0 }}" role="option" aria-selected="{{ 'true' if slug == selected else 'false' }}" data-value="{{ slug }}">
|
|
92
|
+
{%- if sizeLabels[slug] -%}
|
|
93
|
+
{{ sizeLabels[slug] }}
|
|
94
|
+
{%- else -%}
|
|
95
|
+
{{ slug | replace(r/-/g, " ") | title }}
|
|
96
|
+
{%- endif -%}
|
|
97
|
+
</li>
|
|
98
|
+
{%- endfor -%}
|
|
99
|
+
</ul>
|
|
100
|
+
</div>
|
|
101
|
+
{%- endmacro -%}
|
package/index.md
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# TextSizePicker (Nunjucks helper)
|
|
2
|
+
|
|
3
|
+
A reusable, headless Nunjucks 3 + vanilla-JS text-size control that
|
|
4
|
+
applies the chosen size to the document root via `data-text-size`,
|
|
5
|
+
with optional `localStorage` persistence.
|
|
6
|
+
|
|
7
|
+
The single source of truth is [spec/index.md](./spec/index.md). This file is the
|
|
8
|
+
user guide.
|
|
9
|
+
|
|
10
|
+
> **BREAKING (Unreleased).** This helper no longer renders a native
|
|
11
|
+
> `<select>`. It renders an icon button that opens a listbox, matching
|
|
12
|
+
> `theme-picker` and `locale-picker`. Consumers must now load
|
|
13
|
+
> `text-size-picker.client.js` — without it the button is inert. See
|
|
14
|
+
> [CHANGELOG.md](./CHANGELOG.md) for the migration, and
|
|
15
|
+
> [docs/ssr.md](./docs/ssr.md) for the no-JS consequences.
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
Most text-size controls couple selection, persistence, and the actual
|
|
20
|
+
typographic scale into one opinionated widget. This one splits the
|
|
21
|
+
contract cleanly:
|
|
22
|
+
|
|
23
|
+
- **This helper** owns the `data-text-size` lifecycle, accessibility,
|
|
24
|
+
and persistence — via a Nunjucks macro for the markup and a small
|
|
25
|
+
ES module for the runtime.
|
|
26
|
+
- **Your CSS** owns the actual typography, keyed on
|
|
27
|
+
`[data-text-size="{slug}"]`.
|
|
28
|
+
- **Consumers** own the visual style of the control via the
|
|
29
|
+
`text-size-picker` class hooks.
|
|
30
|
+
|
|
31
|
+
The helper is a direct port of the Svelte canonical
|
|
32
|
+
`@lilydesignsystem/svelte-text-size-picker`. The DOM contract and
|
|
33
|
+
behaviour match clause-for-clause; only the framework idioms differ.
|
|
34
|
+
|
|
35
|
+
## How the pieces fit
|
|
36
|
+
|
|
37
|
+
The helper is a **macro + client.js** pair:
|
|
38
|
+
|
|
39
|
+
- The macro (`text-size-picker.njk`) renders the markup server-side or
|
|
40
|
+
at static-site build time.
|
|
41
|
+
- The client (`text-size-picker.client.js`) is an ES module the
|
|
42
|
+
consumer loads once per page. It picks up the markup via
|
|
43
|
+
`data-lily-text-size-picker-*` hooks and owns **both** the
|
|
44
|
+
browser-side lifecycle (storage, `data-text-size` apply, change
|
|
45
|
+
events) **and** the whole listbox interaction (open/close, focus,
|
|
46
|
+
keyboard, typeahead).
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Nunjucks render time │ Browser runtime
|
|
50
|
+
│
|
|
51
|
+
{{ textSizePicker({…}) }} │ import { autoInit } from
|
|
52
|
+
│ │ "./text-size-picker.client.js";
|
|
53
|
+
▼ │ autoInit();
|
|
54
|
+
<div class="text-size-picker" │ │
|
|
55
|
+
data-lily-text-size-picker-root │ ▼
|
|
56
|
+
data-lily-text-size-picker-*> │ finds [data-lily-text-size-picker-root]
|
|
57
|
+
<input type="hidden"> │ │
|
|
58
|
+
<button aria-haspopup="listbox"> │ ▼
|
|
59
|
+
<span aria-hidden="true">A</span> │ resolves initial slug
|
|
60
|
+
</button> │ │
|
|
61
|
+
<ul role="listbox" hidden> │ ▼
|
|
62
|
+
<li role="option">Medium</li> │ applySize(slug):
|
|
63
|
+
… │ - target.dataset.textSize = slug
|
|
64
|
+
</ul> │ - localStorage.setItem(...)
|
|
65
|
+
</div> │ - hidden input + aria-selected
|
|
66
|
+
│ - opts.onChange(slug)
|
|
67
|
+
│ …and wires the listbox keyboard
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Install
|
|
71
|
+
|
|
72
|
+
Copy the core files into your project or wire as a workspace
|
|
73
|
+
dependency:
|
|
74
|
+
|
|
75
|
+
| File | Purpose |
|
|
76
|
+
| ----------------------------- | ---------------------- |
|
|
77
|
+
| `text-size-picker.njk` | The Nunjucks macro. |
|
|
78
|
+
| `text-size-picker.client.js` | The ES-module runtime. |
|
|
79
|
+
|
|
80
|
+
Runtime dependencies: `nunjucks` ≥ 3 server-side and standard DOM
|
|
81
|
+
APIs client-side.
|
|
82
|
+
|
|
83
|
+
## Quick start
|
|
84
|
+
|
|
85
|
+
1. Render the macro in your Nunjucks template:
|
|
86
|
+
|
|
87
|
+
```njk
|
|
88
|
+
{% from "./lily-design-system-nunjucks-text-size-picker/text-size-picker.njk" import textSizePicker %}
|
|
89
|
+
|
|
90
|
+
{{ textSizePicker({
|
|
91
|
+
label: "Text size",
|
|
92
|
+
sizes: ["small", "medium", "large", "x-large"],
|
|
93
|
+
storageKey: "lily-text-size"
|
|
94
|
+
}) }}
|
|
95
|
+
|
|
96
|
+
{# The control is icon-only, so it never shows the active size.
|
|
97
|
+
Pair it with a status region — see docs/accessibility.md. #}
|
|
98
|
+
<p class="text-size-picker-status" aria-live="polite"></p>
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
2. Load the client.js once per page. **This is not optional** — the
|
|
102
|
+
button does not open without it:
|
|
103
|
+
|
|
104
|
+
```html
|
|
105
|
+
<script type="module">
|
|
106
|
+
import { autoInit } from "/path/to/text-size-picker.client.js";
|
|
107
|
+
const status = document.querySelector(".text-size-picker-status");
|
|
108
|
+
autoInit({
|
|
109
|
+
onChange(slug) {
|
|
110
|
+
status.textContent = `Text size: ${slug}`;
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
</script>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
3. Style each size in your CSS. **This is the half that actually
|
|
117
|
+
satisfies WCAG 1.4.4** — the helper only signals the choice:
|
|
118
|
+
|
|
119
|
+
```css
|
|
120
|
+
[data-text-size="small"] {
|
|
121
|
+
font-size: 0.875rem;
|
|
122
|
+
}
|
|
123
|
+
[data-text-size="medium"] {
|
|
124
|
+
font-size: 1rem;
|
|
125
|
+
}
|
|
126
|
+
[data-text-size="large"] {
|
|
127
|
+
font-size: 1.25rem;
|
|
128
|
+
}
|
|
129
|
+
[data-text-size="x-large"] {
|
|
130
|
+
font-size: 1.5rem;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Use relative units throughout. Absolute `px` defeats both this control
|
|
135
|
+
and the user's own browser settings.
|
|
136
|
+
|
|
137
|
+
When the user picks `large`, the client:
|
|
138
|
+
|
|
139
|
+
- sets `data-text-size="large"` on `<html>`,
|
|
140
|
+
- writes `"large"` to `localStorage["lily-text-size"]`,
|
|
141
|
+
- mirrors `"large"` into the hidden input and the options'
|
|
142
|
+
`aria-selected`,
|
|
143
|
+
- fires `onChange("large")` if provided.
|
|
144
|
+
|
|
145
|
+
A worked end-to-end example, including the type scale, is in
|
|
146
|
+
[`examples/01-basic.njk`](./examples/01-basic.njk).
|
|
147
|
+
|
|
148
|
+
## Markup
|
|
149
|
+
|
|
150
|
+
```html
|
|
151
|
+
<div class="text-size-picker" data-lily-text-size-picker-root …>
|
|
152
|
+
<input type="hidden" name="text-size" value="medium" />
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
class="text-size-picker-button"
|
|
156
|
+
aria-label="Text size"
|
|
157
|
+
aria-haspopup="listbox"
|
|
158
|
+
aria-expanded="false"
|
|
159
|
+
aria-controls="text-size-picker-text-size-list"
|
|
160
|
+
>
|
|
161
|
+
<svg class="text-size-picker-icon" viewBox="0 0 16 16" aria-hidden="true" width="1.05rem" height="1.05rem">…</svg>
|
|
162
|
+
</button>
|
|
163
|
+
<ul
|
|
164
|
+
class="text-size-picker-list"
|
|
165
|
+
id="text-size-picker-text-size-list"
|
|
166
|
+
role="listbox"
|
|
167
|
+
aria-label="Text size"
|
|
168
|
+
tabindex="-1"
|
|
169
|
+
hidden
|
|
170
|
+
>
|
|
171
|
+
<li
|
|
172
|
+
class="text-size-picker-option"
|
|
173
|
+
role="option"
|
|
174
|
+
aria-selected="true"
|
|
175
|
+
data-value="medium"
|
|
176
|
+
>
|
|
177
|
+
Medium
|
|
178
|
+
</li>
|
|
179
|
+
…
|
|
180
|
+
</ul>
|
|
181
|
+
</div>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The package ships **no CSS at all**, including none for positioning the
|
|
185
|
+
open listbox. Style it with `.text-size-picker-list:not([hidden])` —
|
|
186
|
+
never `display: block`, which would override the `hidden` attribute
|
|
187
|
+
that is the open-state contract.
|
|
188
|
+
|
|
189
|
+
### Why the icon is a stroke-drawn "A"
|
|
190
|
+
|
|
191
|
+
A bundled outline SVG (`viewBox="0 0 16 16"`), not a Unicode character
|
|
192
|
+
(reversed 2026-09-16). It renders identically everywhere, unlike a
|
|
193
|
+
font-dependent glyph, and reads as the conventional text-size
|
|
194
|
+
affordance.
|
|
195
|
+
|
|
196
|
+
Override it with a `{% call %}` block:
|
|
197
|
+
|
|
198
|
+
```njk
|
|
199
|
+
{% call textSizePicker({label: "Text size", sizes: ["small", "large"]}) %}
|
|
200
|
+
<span aria-hidden="true">Aa</span>
|
|
201
|
+
{% endcall %}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The block replaces the **icon**, not the label, and does not render
|
|
205
|
+
options. Keep whatever you put there `aria-hidden="true"` — the
|
|
206
|
+
accessible name must stay on `aria-label`.
|
|
207
|
+
|
|
208
|
+
## Initial size
|
|
209
|
+
|
|
210
|
+
The initial slug on `initTextSizePicker(root)` resolves to the first
|
|
211
|
+
non-empty value of:
|
|
212
|
+
|
|
213
|
+
1. `data-lily-text-size-picker-value` (i.e. `opts.value`).
|
|
214
|
+
2. `localStorage.getItem(storageKey)` (when set and readable).
|
|
215
|
+
3. `data-lily-text-size-picker-default-value` (i.e. `opts.defaultValue`).
|
|
216
|
+
4. `"medium"` if present among the option values.
|
|
217
|
+
5. The first option value, or `""` if none.
|
|
218
|
+
|
|
219
|
+
## Macro parameters
|
|
220
|
+
|
|
221
|
+
Full table in [spec/index.md §4.1](./spec/index.md#41-macro-parameters).
|
|
222
|
+
Required: `label`, `sizes`. Optional: `value`, `defaultValue`,
|
|
223
|
+
`storageKey`, `name` (default `"text-size"`), `sizeLabels`, `id`
|
|
224
|
+
(default `"text-size-picker-{name}"`), `classes`, `attributes`.
|
|
225
|
+
|
|
226
|
+
`label` is the accessible name for **both** the button and the
|
|
227
|
+
listbox. The button is icon-only, so this is the only accessible name
|
|
228
|
+
it has — it is load-bearing.
|
|
229
|
+
|
|
230
|
+
Default option labels title-case the slug per hyphen-word
|
|
231
|
+
(`x-large` → `X Large`). Pass `sizeLabels` to override any label;
|
|
232
|
+
typeahead matches the rendered label, so overrides participate.
|
|
233
|
+
|
|
234
|
+
There is deliberately **no** detection prop: unlike
|
|
235
|
+
`prefers-color-scheme` and `navigator.languages`, the web platform
|
|
236
|
+
exposes no OS "preferred text size" signal.
|
|
237
|
+
|
|
238
|
+
## Client.js API
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
import {
|
|
242
|
+
initTextSizePicker,
|
|
243
|
+
autoInit,
|
|
244
|
+
sizeName,
|
|
245
|
+
} from "./text-size-picker.client.js";
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- `autoInit(opts?)` — find every
|
|
249
|
+
`[data-lily-text-size-picker-root]` and wire it.
|
|
250
|
+
- `initTextSizePicker(root, opts?)` — wire a single root; returns
|
|
251
|
+
`{setSize, destroy}`.
|
|
252
|
+
- `sizeName(slug)` — `"x-large"` → `"X Large"`. The JS statement of
|
|
253
|
+
the label rule the macro applies in template syntax.
|
|
254
|
+
|
|
255
|
+
Optional `opts`:
|
|
256
|
+
|
|
257
|
+
- `onChange(size)` — fired once per applied change; receives the slug.
|
|
258
|
+
- `target` — element receiving `data-text-size` (defaults to
|
|
259
|
+
`<html>`).
|
|
260
|
+
|
|
261
|
+
## Keyboard
|
|
262
|
+
|
|
263
|
+
Owned entirely by the client.js; none of it works before that module
|
|
264
|
+
runs. On the button: `ArrowDown` / `Enter` / `Space` open (`ArrowUp`
|
|
265
|
+
opens on the last option). On the list: arrows move and clamp,
|
|
266
|
+
`Home` / `End` jump, `PageUp` / `PageDown` move by ten (clamped),
|
|
267
|
+
`Enter` / `Space` select, `Escape` closes unchanged, `Tab` closes and
|
|
268
|
+
moves on — focus goes to the button first, so the default Tab proceeds
|
|
269
|
+
from the picker's position — and printable characters run APG
|
|
270
|
+
typeahead (a repeated character cycles through its matches). Full
|
|
271
|
+
table in [docs/accessibility.md](./docs/accessibility.md).
|
|
272
|
+
|
|
273
|
+
## Accessibility
|
|
274
|
+
|
|
275
|
+
- WCAG 2.2 AAA target; WAI-ARIA APG listbox pattern.
|
|
276
|
+
- Directly supports WCAG 1.4.4 (Resize Text) — provided your CSS
|
|
277
|
+
actually delivers the resize.
|
|
278
|
+
- `aria-label` is the only accessible name the button has.
|
|
279
|
+
- **Without JavaScript the button cannot be operated at all**, which
|
|
280
|
+
the old native `<select>` could. This matters more for a text-size
|
|
281
|
+
control than for its siblings; browser zoom remains the backstop.
|
|
282
|
+
|
|
283
|
+
The honest tradeoffs — the `aria-label` dependency and weaker AT
|
|
284
|
+
support than a native `<select>` — are documented in
|
|
285
|
+
[docs/accessibility.md](./docs/accessibility.md), and the SSR and
|
|
286
|
+
no-JS consequences in [docs/ssr.md](./docs/ssr.md).
|
|
287
|
+
|
|
288
|
+
## Testing
|
|
289
|
+
|
|
290
|
+
`pnpm test` under a vitest + jsdom setup exercises every numbered
|
|
291
|
+
acceptance criterion in [spec/index.md §7](./spec/index.md#7-testing-acceptance-criteria).
|
|
292
|
+
|
|
293
|
+
## License
|
|
294
|
+
|
|
295
|
+
MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact
|
|
296
|
+
joel@joelparkerhenderson.com for other terms.
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
Lily™ and Lily Design System™ are trademarks.
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lilydesignsystem/nunjucks-text-size-picker",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"engines": {
|
|
5
|
+
"node": "=26"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lily Design System - Nunjucks 3 text size picker",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./template": "./dist/text-size-picker.njk"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"index.md",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"prepublishOnly": "cd .. && npm run build"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"lily",
|
|
29
|
+
"design",
|
|
30
|
+
"system",
|
|
31
|
+
"nunjucks",
|
|
32
|
+
"text-size",
|
|
33
|
+
"picker"
|
|
34
|
+
],
|
|
35
|
+
"author": "Joel Parker Henderson <joel@joelparkerhenderson.com>",
|
|
36
|
+
"license": "MIT OR Apache-2.0 OR GPL-2.0-only OR GPL-3.0-only OR BSD-3-Clause",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/LilyDesignSystem/lily-design-system-nunjucks-helpers.git",
|
|
40
|
+
"directory": "lily-design-system-nunjucks-text-size-picker"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://lilydesignsystem.com/",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/LilyDesignSystem/lily-design-system/issues"
|
|
45
|
+
}
|
|
46
|
+
}
|