@lilydesignsystem/nunjucks-text-size-picker 0.1.1 → 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
@@ -70,6 +70,138 @@ function safeStorageSet(key, value) {
70
70
  }
71
71
  }
72
72
 
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();
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;
203
+ }
204
+
73
205
  /**
74
206
  * Wire one rendered TextSizePicker root.
75
207
  *
@@ -276,9 +408,12 @@ function initTextSizePicker(root, opts = {}) {
276
408
 
277
409
  if (initial) applySize(initial);
278
410
 
411
+ const tooltipDestroy = wireTooltip(root, button, ".text-size-picker-tooltip", list);
412
+
279
413
  return {
280
414
  setSize: applySize,
281
415
  destroy: () => {
416
+ tooltipDestroy();
282
417
  keyboard.destroy();
283
418
  button.removeEventListener("click", onButtonClick);
284
419
  button.removeEventListener("keydown", onButtonKeydown);
package/dist/index.js CHANGED
@@ -16,6 +16,106 @@ function safeStorageSet(key, value) {
16
16
  } catch (_e) {
17
17
  }
18
18
  }
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
+ }
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;
118
+ }
19
119
  function initTextSizePicker(root, opts = {}) {
20
120
  const noop = { setSize: () => {
21
121
  }, destroy: () => {
@@ -129,9 +229,11 @@ function initTextSizePicker(root, opts = {}) {
129
229
  if (!initial && values.includes("medium")) initial = "medium";
130
230
  if (!initial && values.length > 0) initial = values[0];
131
231
  if (initial) applySize(initial);
232
+ const tooltipDestroy = wireTooltip(root, button, ".text-size-picker-tooltip", list);
132
233
  return {
133
234
  setSize: applySize,
134
235
  destroy: () => {
236
+ tooltipDestroy();
135
237
  keyboard.destroy();
136
238
  button.removeEventListener("click", onButtonClick);
137
239
  button.removeEventListener("keydown", onButtonKeydown);
@@ -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.1",
3
+ "version": "0.2.0",
4
4
  "engines": {
5
5
  "node": "=26"
6
6
  },