@mlola-ui/behavior 1.1.11 → 1.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlola-ui/behavior",
3
- "version": "1.1.11",
3
+ "version": "1.1.13",
4
4
  "description": "Framework-free behavior for Mlola UI markup: standard DOM, no runtime peers",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  clampToStep,
22
22
  focusTrapIndex,
23
23
  percentOf,
24
- fitMenu,
25
24
  fitTooltip,
26
25
  isSidewaysDrag,
27
26
  placeFloating,
@@ -53,6 +52,55 @@ function on(target, type, handler, options) {
53
52
  return () => target.removeEventListener(type, handler, options);
54
53
  }
55
54
 
55
+ /**
56
+ * A layer that opens beside its anchor (a menu, a listbox): placed with
57
+ * placeFloating, and followed while open. Where the browser has the top
58
+ * layer (the Popover API) it opens there, so no overflow, transform or
59
+ * backdrop-filter of a card, panel or modal can cut it; elsewhere it is
60
+ * fixed to the viewport. It stays where it is in the DOM, so it keeps its
61
+ * theme and its place in the widget.
62
+ */
63
+ function floatingLayer(layer, anchor, options) {
64
+ const topLayer = typeof layer.showPopover === "function";
65
+ if (topLayer && !layer.hasAttribute("popover")) layer.setAttribute("popover", "manual");
66
+ let open = false;
67
+ let frame = 0;
68
+ const place = () => {
69
+ const rect = anchor.getBoundingClientRect();
70
+ const { minWidth, ...placement } = options();
71
+ if (minWidth) layer.style.minWidth = `${minWidth}px`;
72
+ const at = placeFloating(
73
+ { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
74
+ { width: layer.offsetWidth, height: layer.offsetHeight },
75
+ { width: window.innerWidth, height: window.innerHeight },
76
+ placement,
77
+ );
78
+ layer.style.left = `${at.x}px`;
79
+ layer.style.top = `${at.y}px`;
80
+ layer.dataset.side = at.side;
81
+ };
82
+ const follow = () => {
83
+ cancelAnimationFrame(frame);
84
+ frame = requestAnimationFrame(() => {
85
+ if (open) place();
86
+ });
87
+ };
88
+ return {
89
+ show() {
90
+ open = true;
91
+ layer.hidden = false;
92
+ if (topLayer && layer.isConnected && !layer.matches(":popover-open")) layer.showPopover();
93
+ place();
94
+ },
95
+ hide() {
96
+ open = false;
97
+ if (topLayer && layer.matches(":popover-open")) layer.hidePopover();
98
+ layer.hidden = true;
99
+ },
100
+ cleanups: [on(window, "scroll", follow, true), on(window, "resize", follow), () => cancelAnimationFrame(frame)],
101
+ };
102
+ }
103
+
56
104
  /** Element-shaped wrapper over the shared rovingIndex decision. */
57
105
  function roving(items, index, key, options = {}) {
58
106
  return rovingIndex(items.length, index, key, {
@@ -157,24 +205,13 @@ const behaviors = {
157
205
  (item) => !item.disabled && item.getAttribute("aria-disabled") !== "true",
158
206
  );
159
207
 
160
- // CSS hangs the menu under the trigger; this keeps it on screen.
161
- const fit = () => {
162
- const rect = root.getBoundingClientRect();
163
- const { side, shift } = fitMenu(
164
- { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
165
- { width: menu.offsetWidth, height: menu.offsetHeight },
166
- { width: window.innerWidth, height: window.innerHeight },
167
- menu.dataset.align === "end" ? "end" : "start",
168
- );
169
- menu.style.setProperty("--ml-dropdown-shift", `${shift}px`);
170
- menu.dataset.side = side;
171
- };
208
+ const layer = floatingLayer(menu, trigger, () => ({ side: "bottom", align: menu.dataset.align === "end" ? "end" : "start", offset: 6 }));
172
209
  const setOpen = (open) => {
173
210
  root.dataset.state = open ? "open" : "closed";
174
211
  menu.dataset.state = open ? "open" : "closed";
175
- menu.hidden = !open;
212
+ if (open) layer.show();
213
+ else layer.hide();
176
214
  trigger.setAttribute("aria-expanded", String(open));
177
- if (open) fit();
178
215
  if (!open) for (const item of items()) delete item.dataset.highlighted;
179
216
  };
180
217
  const isOpen = () => root.dataset.state === "open";
@@ -190,10 +227,20 @@ const behaviors = {
190
227
  setOpen(false);
191
228
 
192
229
  return [
193
- on(trigger, "click", () => setOpen(!isOpen())),
230
+ // Opened by a press, focus moves to the first item, as the React menu does, so the keys (and Escape) reach it.
231
+ on(trigger, "click", () => {
232
+ if (isOpen()) {
233
+ setOpen(false);
234
+ return;
235
+ }
236
+ setOpen(true);
237
+ highlight(items()[0]);
238
+ }),
194
239
  on(root, "keydown", (event) => {
195
240
  const all = items();
196
241
  if (event.key === "Escape") {
242
+ if (!isOpen()) return;
243
+ event.preventDefault();
197
244
  setOpen(false);
198
245
  trigger.focus();
199
246
  return;
@@ -221,9 +268,7 @@ const behaviors = {
221
268
  on(document, "pointerdown", (event) => {
222
269
  if (isOpen() && !root.contains(event.target)) setOpen(false);
223
270
  }),
224
- on(window, "resize", () => {
225
- if (isOpen()) fit();
226
- }),
271
+ ...layer.cleanups,
227
272
  ];
228
273
  },
229
274
 
@@ -291,7 +336,8 @@ const behaviors = {
291
336
  }),
292
337
  on(document, "keydown", (event) => {
293
338
  if (root.hidden) return;
294
- if (event.key === "Escape" && closeOnEscape) {
339
+ // A menu or listbox open above the dialog closes first: it marks the Escape it used.
340
+ if (event.key === "Escape" && closeOnEscape && !event.defaultPrevented) {
295
341
  event.preventDefault();
296
342
  close();
297
343
  return;
@@ -321,34 +367,14 @@ const behaviors = {
321
367
  (option) => option.getAttribute("aria-disabled") !== "true",
322
368
  );
323
369
 
324
- // The listbox is fixed to the viewport so no overflow clips it, which
325
- // means it is placed beside the trigger here, and follows it while open.
326
- const place = () => {
327
- const rect = trigger.getBoundingClientRect();
328
- popover.style.minWidth = `${trigger.offsetWidth}px`;
329
- const at = placeFloating(
330
- { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
331
- { width: popover.offsetWidth, height: popover.offsetHeight },
332
- { width: window.innerWidth, height: window.innerHeight },
333
- { side: "bottom", align: "start", offset: 6 },
334
- );
335
- popover.style.left = `${at.x}px`;
336
- popover.style.top = `${at.y}px`;
337
- popover.dataset.side = at.side;
338
- };
339
- let frame = 0;
340
- const follow = () => {
341
- cancelAnimationFrame(frame);
342
- frame = requestAnimationFrame(() => {
343
- if (isOpen()) place();
344
- });
345
- };
370
+ // The listbox opens as a layer beside the trigger, at least as wide as it.
371
+ const layer = floatingLayer(popover, trigger, () => ({ side: "bottom", align: "start", offset: 6, minWidth: trigger.offsetWidth }));
346
372
 
347
373
  const setOpen = (open) => {
348
374
  trigger.dataset.state = open ? "open" : "closed";
349
- popover.hidden = !open;
375
+ if (open) layer.show();
376
+ else layer.hide();
350
377
  trigger.setAttribute("aria-expanded", String(open));
351
- if (open) place();
352
378
  if (!open) {
353
379
  for (const option of options()) delete option.dataset.highlighted;
354
380
  trigger.removeAttribute("aria-activedescendant");
@@ -395,6 +421,8 @@ const behaviors = {
395
421
  on(root, "keydown", (event) => {
396
422
  const all = options();
397
423
  if (event.key === "Escape") {
424
+ if (!isOpen()) return;
425
+ event.preventDefault();
398
426
  setOpen(false);
399
427
  trigger.focus();
400
428
  return;
@@ -433,9 +461,7 @@ const behaviors = {
433
461
  on(document, "pointerdown", (event) => {
434
462
  if (isOpen() && !root.contains(event.target)) setOpen(false);
435
463
  }),
436
- on(window, "scroll", follow, true),
437
- on(window, "resize", follow),
438
- () => cancelAnimationFrame(frame),
464
+ ...layer.cleanups,
439
465
  ];
440
466
  },
441
467
 
package/src/logic.d.ts CHANGED
@@ -136,7 +136,11 @@ export function placeFloating(
136
136
  options?: { side?: Side; align?: Align; offset?: number; padding?: number },
137
137
  ): Placement;
138
138
 
139
- /** A CSS-anchored menu's side (bottom, or top without room) and its sideways slide to stay on screen. */
139
+ /**
140
+ * A CSS-anchored menu's side (bottom, or top without room) and its sideways slide to stay on screen.
141
+ * @deprecated Since 1.1.13 the dropdown menu is a floating layer placed with `placeFloating`, which escapes any
142
+ * container that clips; `fitMenu` kept a menu on screen but not out of a clipping card. Kept for 1.x.
143
+ */
140
144
  export function fitMenu(
141
145
  anchor: Rect,
142
146
  menu: { width: number; height: number },