@lilydesignsystem/nunjucks-text-size-picker 0.1.0 → 0.2.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 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,11 +70,136 @@ 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" });
73
+ // ---------------------------------------------------------------------
74
+ // Tooltip (purely visual; the same text is already the button's
75
+ // aria-label, so it is NOT linked with aria-describedby). Visible iff the
76
+ // popup is closed, it has not been dismissed with Escape, and the pointer
77
+ // is over the button or the tooltip (hoverable, WCAG 1.4.13) or the
78
+ // button has KEYBOARD focus (:focus-visible; a mouse click's focus does
79
+ // not count). Never interferes with the picker's own keys or closing.
80
+ // Idempotent: wiring the same tooltip twice replaces the first wiring.
81
+ // ---------------------------------------------------------------------
82
+
83
+ const tooltipWirings = new WeakMap();
84
+
85
+ function wireTooltip(root, button, selector, popup) {
86
+ const tooltip = root.querySelector(selector);
87
+ if (!tooltip || !button || !popup) return () => {};
88
+ const previous = tooltipWirings.get(tooltip);
89
+ if (previous) previous();
90
+
91
+ let hoverButton = false;
92
+ let hoverTooltip = false;
93
+ let focusButton = false;
94
+ let dismissed = false;
95
+
96
+ // WCAG 1.4.13 "dismissable": Escape must work wherever focus is while
97
+ // the tooltip is visible (pointer hover alone leaves focus elsewhere).
98
+ // The document listener exists only while visible; never preventDefault
99
+ // or stopPropagation, never move focus.
100
+ let docListening = false;
101
+ const onDocumentKeydown = (event) => {
102
+ if (event.key === "Escape") {
103
+ dismissed = true;
104
+ update();
105
+ }
106
+ };
107
+ function syncDocumentListener() {
108
+ const want = !tooltip.hidden;
109
+ if (want && !docListening) {
110
+ document.addEventListener("keydown", onDocumentKeydown);
111
+ docListening = true;
112
+ } else if (!want && docListening) {
113
+ document.removeEventListener("keydown", onDocumentKeydown);
114
+ docListening = false;
115
+ }
116
+ }
117
+
118
+ function update() {
119
+ tooltip.hidden = !(
120
+ popup.hidden &&
121
+ !dismissed &&
122
+ (hoverButton || hoverTooltip || focusButton)
123
+ );
124
+ syncDocumentListener();
75
125
  }
126
+
127
+ const onButtonEnter = () => {
128
+ hoverButton = true;
129
+ dismissed = false;
130
+ update();
131
+ };
132
+ const onButtonLeave = () => {
133
+ hoverButton = false;
134
+ update();
135
+ };
136
+ const onTooltipEnter = () => {
137
+ hoverTooltip = true;
138
+ update();
139
+ };
140
+ const onTooltipLeave = () => {
141
+ hoverTooltip = false;
142
+ update();
143
+ };
144
+ const onButtonFocus = () => {
145
+ try {
146
+ focusButton = button.matches(":focus-visible");
147
+ } catch {
148
+ focusButton = true; // engine without :focus-visible
149
+ }
150
+ update();
151
+ };
152
+ const onButtonBlur = () => {
153
+ focusButton = false;
154
+ dismissed = false;
155
+ update();
156
+ };
157
+ const onButtonKeydown = (event) => {
158
+ if (event.key === "Escape" && !tooltip.hidden) dismissed = true;
159
+ update();
160
+ };
161
+ const onButtonClick = () => {
162
+ hoverButton = false;
163
+ update();
164
+ };
165
+
166
+ button.addEventListener("mouseenter", onButtonEnter);
167
+ button.addEventListener("mouseleave", onButtonLeave);
168
+ button.addEventListener("focus", onButtonFocus);
169
+ button.addEventListener("blur", onButtonBlur);
170
+ button.addEventListener("keydown", onButtonKeydown);
171
+ button.addEventListener("click", onButtonClick);
172
+ tooltip.addEventListener("mouseenter", onTooltipEnter);
173
+ tooltip.addEventListener("mouseleave", onTooltipLeave);
174
+
175
+ // The popup's own open/close (including the programmatic API) flips its
176
+ // `hidden` attribute; follow it so the tooltip never shows over it.
177
+ const observer =
178
+ typeof MutationObserver === "function"
179
+ ? new MutationObserver(update)
180
+ : null;
181
+ if (observer) observer.observe(popup, { attributes: true, attributeFilter: ["hidden"] });
182
+
183
+ update();
184
+
185
+ const destroy = () => {
186
+ button.removeEventListener("mouseenter", onButtonEnter);
187
+ button.removeEventListener("mouseleave", onButtonLeave);
188
+ button.removeEventListener("focus", onButtonFocus);
189
+ button.removeEventListener("blur", onButtonBlur);
190
+ button.removeEventListener("keydown", onButtonKeydown);
191
+ button.removeEventListener("click", onButtonClick);
192
+ tooltip.removeEventListener("mouseenter", onTooltipEnter);
193
+ tooltip.removeEventListener("mouseleave", onTooltipLeave);
194
+ if (observer) observer.disconnect();
195
+ if (docListening) {
196
+ document.removeEventListener("keydown", onDocumentKeydown);
197
+ docListening = false;
198
+ }
199
+ if (tooltipWirings.get(tooltip) === destroy) tooltipWirings.delete(tooltip);
200
+ };
201
+ tooltipWirings.set(tooltip, destroy);
202
+ return destroy;
76
203
  }
77
204
 
78
205
  /**
@@ -93,7 +220,6 @@ function initTextSizePicker(root, opts = {}) {
93
220
 
94
221
  const options = Array.from(list.querySelectorAll('[role="option"]'));
95
222
  const values = options.map((o) => o.getAttribute("data-value") || "");
96
- const labels = options.map((o) => (o.textContent || "").trim());
97
223
 
98
224
  const storageKey =
99
225
  root.getAttribute("data-lily-text-size-picker-storage-key") || "";
@@ -106,9 +232,6 @@ function initTextSizePicker(root, opts = {}) {
106
232
 
107
233
  let current = "";
108
234
  let open = false;
109
- let activeIndex = -1;
110
- let typeahead = "";
111
- let typeaheadTimer;
112
235
 
113
236
  // -----------------------------------------------------------------
114
237
  // Applying a size
@@ -140,22 +263,13 @@ function initTextSizePicker(root, opts = {}) {
140
263
 
141
264
  // -----------------------------------------------------------------
142
265
  // Open / close / active-option movement
266
+ //
267
+ // Arrow move/clamp, Home/End, typeahead, PageUp/PageDown, and
268
+ // Enter/Space/Escape/Tab are owned by the shared listbox-behavior
269
+ // controller below (`keyboard`); this picker only decides what
270
+ // open/close/choose mean.
143
271
  // -----------------------------------------------------------------
144
272
 
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
273
  function openList(startIndex) {
160
274
  const selected = values.indexOf(current);
161
275
  // An empty list has no option to activate; -1 keeps
@@ -172,7 +286,7 @@ function initTextSizePicker(root, opts = {}) {
172
286
  open = true;
173
287
  list.hidden = false;
174
288
  button.setAttribute("aria-expanded", "true");
175
- setActive(start);
289
+ keyboard.setActive(start);
176
290
  // Focus moves to the listbox; the active option is conveyed via
177
291
  // aria-activedescendant, per the APG listbox pattern.
178
292
  list.focus({ preventScroll: true });
@@ -183,7 +297,7 @@ function initTextSizePicker(root, opts = {}) {
183
297
  open = false;
184
298
  list.hidden = true;
185
299
  button.setAttribute("aria-expanded", "false");
186
- setActive(-1);
300
+ keyboard.setActive(-1);
187
301
  if (refocus) button.focus({ preventScroll: true });
188
302
  }
189
303
 
@@ -193,39 +307,26 @@ function initTextSizePicker(root, opts = {}) {
193
307
  closeList();
194
308
  }
195
309
 
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
- }
310
+ const keyboard = createListboxKeyboard(list, {
311
+ clamp: true,
312
+ typeahead: true,
313
+ pageSize: 10,
314
+ onActivate: choose,
315
+ onEscape: () => closeList(),
316
+ onTabOut: () => {
317
+ // Tab moves on — but focus goes to the button FIRST, without
318
+ // cancelling the key (the shared controller never prevents Tab's
319
+ // default). Hiding the focused list drops focus to <body>, and
320
+ // the browser then computes the default Tab move from the top of
321
+ // the document, so tabbing out of an open picker teleported the
322
+ // user to the page's first tab stop. From the button, the default
323
+ // Tab lands exactly where leaving the picker should. Guard the
324
+ // METHOD, not just the element: this shape has bitten these
325
+ // helpers before.
326
+ button?.focus?.({ preventScroll: true });
327
+ closeList(false);
328
+ },
329
+ });
229
330
 
230
331
  // -----------------------------------------------------------------
231
332
  // Event handlers
@@ -251,66 +352,6 @@ function initTextSizePicker(root, opts = {}) {
251
352
  }
252
353
  }
253
354
 
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
355
  function onListClick(event) {
315
356
  const li =
316
357
  event.target && event.target.closest
@@ -335,7 +376,6 @@ function initTextSizePicker(root, opts = {}) {
335
376
 
336
377
  button.addEventListener("click", onButtonClick);
337
378
  button.addEventListener("keydown", onButtonKeydown);
338
- list.addEventListener("keydown", onListKeydown);
339
379
  list.addEventListener("click", onListClick);
340
380
  root.addEventListener("focusout", onRootFocusOut);
341
381
  document.addEventListener("click", onDocumentClick);
@@ -368,13 +408,15 @@ function initTextSizePicker(root, opts = {}) {
368
408
 
369
409
  if (initial) applySize(initial);
370
410
 
411
+ const tooltipDestroy = wireTooltip(root, button, ".text-size-picker-tooltip", list);
412
+
371
413
  return {
372
414
  setSize: applySize,
373
415
  destroy: () => {
374
- clearTimeout(typeaheadTimer);
416
+ tooltipDestroy();
417
+ keyboard.destroy();
375
418
  button.removeEventListener("click", onButtonClick);
376
419
  button.removeEventListener("keydown", onButtonKeydown);
377
- list.removeEventListener("keydown", onListKeydown);
378
420
  list.removeEventListener("click", onListClick);
379
421
  root.removeEventListener("focusout", onRootFocusOut);
380
422
  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,10 +16,105 @@ 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" });
19
+ var tooltipWirings = /* @__PURE__ */ new WeakMap();
20
+ function wireTooltip(root, button, selector, popup) {
21
+ const tooltip = root.querySelector(selector);
22
+ if (!tooltip || !button || !popup) return () => {
23
+ };
24
+ const previous = tooltipWirings.get(tooltip);
25
+ if (previous) previous();
26
+ let hoverButton = false;
27
+ let hoverTooltip = false;
28
+ let focusButton = false;
29
+ let dismissed = false;
30
+ let docListening = false;
31
+ const onDocumentKeydown = (event) => {
32
+ if (event.key === "Escape") {
33
+ dismissed = true;
34
+ update();
35
+ }
36
+ };
37
+ function syncDocumentListener() {
38
+ const want = !tooltip.hidden;
39
+ if (want && !docListening) {
40
+ document.addEventListener("keydown", onDocumentKeydown);
41
+ docListening = true;
42
+ } else if (!want && docListening) {
43
+ document.removeEventListener("keydown", onDocumentKeydown);
44
+ docListening = false;
45
+ }
22
46
  }
47
+ function update() {
48
+ tooltip.hidden = !(popup.hidden && !dismissed && (hoverButton || hoverTooltip || focusButton));
49
+ syncDocumentListener();
50
+ }
51
+ const onButtonEnter = () => {
52
+ hoverButton = true;
53
+ dismissed = false;
54
+ update();
55
+ };
56
+ const onButtonLeave = () => {
57
+ hoverButton = false;
58
+ update();
59
+ };
60
+ const onTooltipEnter = () => {
61
+ hoverTooltip = true;
62
+ update();
63
+ };
64
+ const onTooltipLeave = () => {
65
+ hoverTooltip = false;
66
+ update();
67
+ };
68
+ const onButtonFocus = () => {
69
+ try {
70
+ focusButton = button.matches(":focus-visible");
71
+ } catch {
72
+ focusButton = true;
73
+ }
74
+ update();
75
+ };
76
+ const onButtonBlur = () => {
77
+ focusButton = false;
78
+ dismissed = false;
79
+ update();
80
+ };
81
+ const onButtonKeydown = (event) => {
82
+ if (event.key === "Escape" && !tooltip.hidden) dismissed = true;
83
+ update();
84
+ };
85
+ const onButtonClick = () => {
86
+ hoverButton = false;
87
+ update();
88
+ };
89
+ button.addEventListener("mouseenter", onButtonEnter);
90
+ button.addEventListener("mouseleave", onButtonLeave);
91
+ button.addEventListener("focus", onButtonFocus);
92
+ button.addEventListener("blur", onButtonBlur);
93
+ button.addEventListener("keydown", onButtonKeydown);
94
+ button.addEventListener("click", onButtonClick);
95
+ tooltip.addEventListener("mouseenter", onTooltipEnter);
96
+ tooltip.addEventListener("mouseleave", onTooltipLeave);
97
+ const observer = typeof MutationObserver === "function" ? new MutationObserver(update) : null;
98
+ if (observer) observer.observe(popup, { attributes: true, attributeFilter: ["hidden"] });
99
+ update();
100
+ const destroy = () => {
101
+ button.removeEventListener("mouseenter", onButtonEnter);
102
+ button.removeEventListener("mouseleave", onButtonLeave);
103
+ button.removeEventListener("focus", onButtonFocus);
104
+ button.removeEventListener("blur", onButtonBlur);
105
+ button.removeEventListener("keydown", onButtonKeydown);
106
+ button.removeEventListener("click", onButtonClick);
107
+ tooltip.removeEventListener("mouseenter", onTooltipEnter);
108
+ tooltip.removeEventListener("mouseleave", onTooltipLeave);
109
+ if (observer) observer.disconnect();
110
+ if (docListening) {
111
+ document.removeEventListener("keydown", onDocumentKeydown);
112
+ docListening = false;
113
+ }
114
+ if (tooltipWirings.get(tooltip) === destroy) tooltipWirings.delete(tooltip);
115
+ };
116
+ tooltipWirings.set(tooltip, destroy);
117
+ return destroy;
23
118
  }
24
119
  function initTextSizePicker(root, opts = {}) {
25
120
  const noop = { setSize: () => {
@@ -32,15 +127,11 @@ function initTextSizePicker(root, opts = {}) {
32
127
  if (!button || !list) return noop;
33
128
  const options = Array.from(list.querySelectorAll('[role="option"]'));
34
129
  const values = options.map((o) => o.getAttribute("data-value") || "");
35
- const labels = options.map((o) => (o.textContent || "").trim());
36
130
  const storageKey = root.getAttribute("data-lily-text-size-picker-storage-key") || "";
37
131
  const defaultValue = root.getAttribute("data-lily-text-size-picker-default-value") || "";
38
132
  const valueAttr = root.getAttribute("data-lily-text-size-picker-value") || "";
39
133
  let current = "";
40
134
  let open = false;
41
- let activeIndex = -1;
42
- let typeahead = "";
43
- let typeaheadTimer;
44
135
  let appliedValue = "";
45
136
  function applySize(slug) {
46
137
  if (!slug) return;
@@ -56,26 +147,13 @@ function initTextSizePicker(root, opts = {}) {
56
147
  });
57
148
  if (typeof opts.onChange === "function") opts.onChange(slug);
58
149
  }
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
150
  function openList(startIndex) {
73
151
  const selected = values.indexOf(current);
74
152
  const start = options.length === 0 ? -1 : typeof startIndex === "number" ? startIndex : selected >= 0 ? selected : 0;
75
153
  open = true;
76
154
  list.hidden = false;
77
155
  button.setAttribute("aria-expanded", "true");
78
- setActive(start);
156
+ keyboard.setActive(start);
79
157
  list.focus({ preventScroll: true });
80
158
  }
81
159
  function closeList(refocus = true) {
@@ -83,7 +161,7 @@ function initTextSizePicker(root, opts = {}) {
83
161
  open = false;
84
162
  list.hidden = true;
85
163
  button.setAttribute("aria-expanded", "false");
86
- setActive(-1);
164
+ keyboard.setActive(-1);
87
165
  if (refocus) button.focus({ preventScroll: true });
88
166
  }
89
167
  function choose(index) {
@@ -91,30 +169,18 @@ function initTextSizePicker(root, opts = {}) {
91
169
  if (slug) applySize(slug);
92
170
  closeList();
93
171
  }
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
- }
172
+ const keyboard = createListboxKeyboard(list, {
173
+ clamp: true,
174
+ typeahead: true,
175
+ pageSize: 10,
176
+ onActivate: choose,
177
+ onEscape: () => closeList(),
178
+ onTabOut: () => {
179
+ var _a;
180
+ (_a = button == null ? void 0 : button.focus) == null ? void 0 : _a.call(button, { preventScroll: true });
181
+ closeList(false);
116
182
  }
117
- }
183
+ });
118
184
  function onButtonClick() {
119
185
  if (open) closeList();
120
186
  else openList();
@@ -135,52 +201,6 @@ function initTextSizePicker(root, opts = {}) {
135
201
  break;
136
202
  }
137
203
  }
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
204
  function onListClick(event) {
185
205
  const li = event.target && event.target.closest ? event.target.closest('[role="option"]') : null;
186
206
  if (!li) return;
@@ -199,7 +219,6 @@ function initTextSizePicker(root, opts = {}) {
199
219
  }
200
220
  button.addEventListener("click", onButtonClick);
201
221
  button.addEventListener("keydown", onButtonKeydown);
202
- list.addEventListener("keydown", onListKeydown);
203
222
  list.addEventListener("click", onListClick);
204
223
  root.addEventListener("focusout", onRootFocusOut);
205
224
  document.addEventListener("click", onDocumentClick);
@@ -210,13 +229,14 @@ function initTextSizePicker(root, opts = {}) {
210
229
  if (!initial && values.includes("medium")) initial = "medium";
211
230
  if (!initial && values.length > 0) initial = values[0];
212
231
  if (initial) applySize(initial);
232
+ const tooltipDestroy = wireTooltip(root, button, ".text-size-picker-tooltip", list);
213
233
  return {
214
234
  setSize: applySize,
215
235
  destroy: () => {
216
- clearTimeout(typeaheadTimer);
236
+ tooltipDestroy();
237
+ keyboard.destroy();
217
238
  button.removeEventListener("click", onButtonClick);
218
239
  button.removeEventListener("keydown", onButtonKeydown);
219
- list.removeEventListener("keydown", onListKeydown);
220
240
  list.removeEventListener("click", onListClick);
221
241
  root.removeEventListener("focusout", onRootFocusOut);
222
242
  document.removeEventListener("click", onDocumentClick);
@@ -86,6 +86,7 @@
86
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
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
88
  </button>
89
+ <div class="text-size-picker-tooltip" role="tooltip" id="{{ id }}-tooltip" hidden>{{ opts.label }}</div>
89
90
  <ul class="text-size-picker-list" id="{{ listId }}" role="listbox" aria-label="{{ opts.label }}" tabindex="-1" hidden data-lily-text-size-picker-list>
90
91
  {%- for slug in opts.sizes -%}
91
92
  <li class="text-size-picker-option" id="{{ id }}-option-{{ loop.index0 }}" role="option" aria-selected="{{ 'true' if slug == selected else 'false' }}" data-value="{{ slug }}">
package/index.md CHANGED
@@ -295,6 +295,10 @@ acceptance criterion in [spec/index.md §7](./spec/index.md#7-testing-acceptance
295
295
  MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact
296
296
  joel@joelparkerhenderson.com for other terms.
297
297
 
298
+ ## Tooltip
299
+
300
+ `.text-size-picker-tooltip` (`<div role="tooltip" id="{id}-tooltip" hidden>`) is rendered right after the icon button, holding the button's `label`. `text-size-picker.client.js` shows it on pointer hover (button or tooltip) or keyboard focus (`:focus-visible`), hides it on `Escape` (focus stays) and never shows it while the popup is open. Not linked with `aria-describedby`. See `spec/index.md` (Tooltip).
301
+
298
302
  ---
299
303
 
300
304
  Lily™ and Lily Design System™ are trademarks.
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.2.0",
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",