@lilydesignsystem/nunjucks-text-size-picker 0.1.0 → 0.1.1

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 CHANGED
@@ -1,3 +1,5 @@
1
+ import { createListboxKeyboard } from '@lilydesignsystem/nunjucks-listbox-behavior';
2
+
1
3
  // TextSizePicker client-side runtime.
2
4
  //
3
5
  // Pairs with text-size-picker.njk. The macro renders the markup with
@@ -5,9 +7,11 @@
5
7
  // the browser and owns two things:
6
8
  //
7
9
  // 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.
10
+ // close and focus movement here; the APG listbox keyboard contract
11
+ // itself is delegated to the shared
12
+ // @lilydesignsystem/nunjucks-listbox-behavior module. None of this
13
+ // exists in the server markup — the button is inert until this
14
+ // module runs. See docs/ssr.md.
11
15
  //
12
16
  // B. The text-size LIFECYCLE (unchanged):
13
17
  // 0. Read the consumer's `value` prop from
@@ -31,8 +35,6 @@
31
35
  //
32
36
  // See spec/index.md §4.3 (client.js exports), §5 (behaviour).
33
37
 
34
- /** How long the typeahead buffer survives between keystrokes, in ms. */
35
- const TYPEAHEAD_RESET_MS = 500;
36
38
 
37
39
  /**
38
40
  * Resolve a size slug to its display label: each hyphen-separated word
@@ -68,13 +70,6 @@ function safeStorageSet(key, value) {
68
70
  }
69
71
  }
70
72
 
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
73
  /**
79
74
  * Wire one rendered TextSizePicker root.
80
75
  *
@@ -93,7 +88,6 @@ function initTextSizePicker(root, opts = {}) {
93
88
 
94
89
  const options = Array.from(list.querySelectorAll('[role="option"]'));
95
90
  const values = options.map((o) => o.getAttribute("data-value") || "");
96
- const labels = options.map((o) => (o.textContent || "").trim());
97
91
 
98
92
  const storageKey =
99
93
  root.getAttribute("data-lily-text-size-picker-storage-key") || "";
@@ -106,9 +100,6 @@ function initTextSizePicker(root, opts = {}) {
106
100
 
107
101
  let current = "";
108
102
  let open = false;
109
- let activeIndex = -1;
110
- let typeahead = "";
111
- let typeaheadTimer;
112
103
 
113
104
  // -----------------------------------------------------------------
114
105
  // Applying a size
@@ -140,22 +131,13 @@ function initTextSizePicker(root, opts = {}) {
140
131
 
141
132
  // -----------------------------------------------------------------
142
133
  // Open / close / active-option movement
134
+ //
135
+ // Arrow move/clamp, Home/End, typeahead, PageUp/PageDown, and
136
+ // Enter/Space/Escape/Tab are owned by the shared listbox-behavior
137
+ // controller below (`keyboard`); this picker only decides what
138
+ // open/close/choose mean.
143
139
  // -----------------------------------------------------------------
144
140
 
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
141
  function openList(startIndex) {
160
142
  const selected = values.indexOf(current);
161
143
  // An empty list has no option to activate; -1 keeps
@@ -172,7 +154,7 @@ function initTextSizePicker(root, opts = {}) {
172
154
  open = true;
173
155
  list.hidden = false;
174
156
  button.setAttribute("aria-expanded", "true");
175
- setActive(start);
157
+ keyboard.setActive(start);
176
158
  // Focus moves to the listbox; the active option is conveyed via
177
159
  // aria-activedescendant, per the APG listbox pattern.
178
160
  list.focus({ preventScroll: true });
@@ -183,7 +165,7 @@ function initTextSizePicker(root, opts = {}) {
183
165
  open = false;
184
166
  list.hidden = true;
185
167
  button.setAttribute("aria-expanded", "false");
186
- setActive(-1);
168
+ keyboard.setActive(-1);
187
169
  if (refocus) button.focus({ preventScroll: true });
188
170
  }
189
171
 
@@ -193,39 +175,26 @@ function initTextSizePicker(root, opts = {}) {
193
175
  closeList();
194
176
  }
195
177
 
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
- }
178
+ const keyboard = createListboxKeyboard(list, {
179
+ clamp: true,
180
+ typeahead: true,
181
+ pageSize: 10,
182
+ onActivate: choose,
183
+ onEscape: () => closeList(),
184
+ onTabOut: () => {
185
+ // Tab moves on — but focus goes to the button FIRST, without
186
+ // cancelling the key (the shared controller never prevents Tab's
187
+ // default). Hiding the focused list drops focus to <body>, and
188
+ // the browser then computes the default Tab move from the top of
189
+ // the document, so tabbing out of an open picker teleported the
190
+ // user to the page's first tab stop. From the button, the default
191
+ // Tab lands exactly where leaving the picker should. Guard the
192
+ // METHOD, not just the element: this shape has bitten these
193
+ // helpers before.
194
+ button?.focus?.({ preventScroll: true });
195
+ closeList(false);
196
+ },
197
+ });
229
198
 
230
199
  // -----------------------------------------------------------------
231
200
  // Event handlers
@@ -251,66 +220,6 @@ function initTextSizePicker(root, opts = {}) {
251
220
  }
252
221
  }
253
222
 
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
223
  function onListClick(event) {
315
224
  const li =
316
225
  event.target && event.target.closest
@@ -335,7 +244,6 @@ function initTextSizePicker(root, opts = {}) {
335
244
 
336
245
  button.addEventListener("click", onButtonClick);
337
246
  button.addEventListener("keydown", onButtonKeydown);
338
- list.addEventListener("keydown", onListKeydown);
339
247
  list.addEventListener("click", onListClick);
340
248
  root.addEventListener("focusout", onRootFocusOut);
341
249
  document.addEventListener("click", onDocumentClick);
@@ -371,10 +279,9 @@ function initTextSizePicker(root, opts = {}) {
371
279
  return {
372
280
  setSize: applySize,
373
281
  destroy: () => {
374
- clearTimeout(typeaheadTimer);
282
+ keyboard.destroy();
375
283
  button.removeEventListener("click", onButtonClick);
376
284
  button.removeEventListener("keydown", onButtonKeydown);
377
- list.removeEventListener("keydown", onListKeydown);
378
285
  list.removeEventListener("click", onListClick);
379
286
  root.removeEventListener("focusout", onRootFocusOut);
380
287
  document.removeEventListener("click", onDocumentClick);
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // lily-design-system-nunjucks-text-size-picker/text-size-picker.client.js
2
- var TYPEAHEAD_RESET_MS = 500;
2
+ import { createListboxKeyboard } from "@lilydesignsystem/nunjucks-listbox-behavior";
3
3
  function sizeName(size) {
4
4
  return String(size || "").split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
5
5
  }
@@ -16,11 +16,6 @@ function safeStorageSet(key, value) {
16
16
  } catch (_e) {
17
17
  }
18
18
  }
19
- function scrollIntoViewIfPossible(el) {
20
- if (el && typeof el.scrollIntoView === "function") {
21
- el.scrollIntoView({ block: "nearest" });
22
- }
23
- }
24
19
  function initTextSizePicker(root, opts = {}) {
25
20
  const noop = { setSize: () => {
26
21
  }, destroy: () => {
@@ -32,15 +27,11 @@ function initTextSizePicker(root, opts = {}) {
32
27
  if (!button || !list) return noop;
33
28
  const options = Array.from(list.querySelectorAll('[role="option"]'));
34
29
  const values = options.map((o) => o.getAttribute("data-value") || "");
35
- const labels = options.map((o) => (o.textContent || "").trim());
36
30
  const storageKey = root.getAttribute("data-lily-text-size-picker-storage-key") || "";
37
31
  const defaultValue = root.getAttribute("data-lily-text-size-picker-default-value") || "";
38
32
  const valueAttr = root.getAttribute("data-lily-text-size-picker-value") || "";
39
33
  let current = "";
40
34
  let open = false;
41
- let activeIndex = -1;
42
- let typeahead = "";
43
- let typeaheadTimer;
44
35
  let appliedValue = "";
45
36
  function applySize(slug) {
46
37
  if (!slug) return;
@@ -56,26 +47,13 @@ function initTextSizePicker(root, opts = {}) {
56
47
  });
57
48
  if (typeof opts.onChange === "function") opts.onChange(slug);
58
49
  }
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
50
  function openList(startIndex) {
73
51
  const selected = values.indexOf(current);
74
52
  const start = options.length === 0 ? -1 : typeof startIndex === "number" ? startIndex : selected >= 0 ? selected : 0;
75
53
  open = true;
76
54
  list.hidden = false;
77
55
  button.setAttribute("aria-expanded", "true");
78
- setActive(start);
56
+ keyboard.setActive(start);
79
57
  list.focus({ preventScroll: true });
80
58
  }
81
59
  function closeList(refocus = true) {
@@ -83,7 +61,7 @@ function initTextSizePicker(root, opts = {}) {
83
61
  open = false;
84
62
  list.hidden = true;
85
63
  button.setAttribute("aria-expanded", "false");
86
- setActive(-1);
64
+ keyboard.setActive(-1);
87
65
  if (refocus) button.focus({ preventScroll: true });
88
66
  }
89
67
  function choose(index) {
@@ -91,30 +69,18 @@ function initTextSizePicker(root, opts = {}) {
91
69
  if (slug) applySize(slug);
92
70
  closeList();
93
71
  }
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
- }
72
+ const keyboard = createListboxKeyboard(list, {
73
+ clamp: true,
74
+ typeahead: true,
75
+ pageSize: 10,
76
+ onActivate: choose,
77
+ onEscape: () => closeList(),
78
+ onTabOut: () => {
79
+ var _a;
80
+ (_a = button == null ? void 0 : button.focus) == null ? void 0 : _a.call(button, { preventScroll: true });
81
+ closeList(false);
116
82
  }
117
- }
83
+ });
118
84
  function onButtonClick() {
119
85
  if (open) closeList();
120
86
  else openList();
@@ -135,52 +101,6 @@ function initTextSizePicker(root, opts = {}) {
135
101
  break;
136
102
  }
137
103
  }
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
104
  function onListClick(event) {
185
105
  const li = event.target && event.target.closest ? event.target.closest('[role="option"]') : null;
186
106
  if (!li) return;
@@ -199,7 +119,6 @@ function initTextSizePicker(root, opts = {}) {
199
119
  }
200
120
  button.addEventListener("click", onButtonClick);
201
121
  button.addEventListener("keydown", onButtonKeydown);
202
- list.addEventListener("keydown", onListKeydown);
203
122
  list.addEventListener("click", onListClick);
204
123
  root.addEventListener("focusout", onRootFocusOut);
205
124
  document.addEventListener("click", onDocumentClick);
@@ -213,10 +132,9 @@ function initTextSizePicker(root, opts = {}) {
213
132
  return {
214
133
  setSize: applySize,
215
134
  destroy: () => {
216
- clearTimeout(typeaheadTimer);
135
+ keyboard.destroy();
217
136
  button.removeEventListener("click", onButtonClick);
218
137
  button.removeEventListener("keydown", onButtonKeydown);
219
- list.removeEventListener("keydown", onListKeydown);
220
138
  list.removeEventListener("click", onListClick);
221
139
  root.removeEventListener("focusout", onRootFocusOut);
222
140
  document.removeEventListener("click", onDocumentClick);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lilydesignsystem/nunjucks-text-size-picker",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "engines": {
5
5
  "node": "=26"
6
6
  },
@@ -24,6 +24,9 @@
24
24
  "scripts": {
25
25
  "prepublishOnly": "cd .. && npm run build"
26
26
  },
27
+ "dependencies": {
28
+ "@lilydesignsystem/nunjucks-listbox-behavior": "^0.1.0"
29
+ },
27
30
  "keywords": [
28
31
  "lily",
29
32
  "design",