@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 +167 -125
- package/dist/index.js +115 -95
- package/dist/text-size-picker.njk +1 -0
- package/index.md +4 -0
- package/package.json +4 -1
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|