@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.
@@ -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
+ }