@mlola-ui/behavior 1.0.4 → 1.0.6

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/README.md CHANGED
@@ -33,6 +33,12 @@ npm install @mlola-ui/behavior
33
33
  It is idempotent, so it is safe to call after a framework re-render, a turbo
34
34
  navigation, or an htmx swap.
35
35
 
36
+ ## Examples
37
+
38
+ `examples/` holds the markup for each behavior, one file per behavior, as
39
+ it should be written by hand. They are the markup the browser suite runs in
40
+ Chromium, Firefox and WebKit, and the markup the component docs show.
41
+
36
42
  ## Implemented behaviors
37
43
 
38
44
  `accordion`, `tabs`, `dropdown-menu`, `select`, `modal`, `sheet`, `tooltip`,
@@ -0,0 +1,24 @@
1
+ <div class="ml-accordion" data-ml="accordion">
2
+ <article class="ml-accordion-item" data-state="open">
3
+ <h3 class="ml-accordion-heading">
4
+ <button id="t1" class="ml-accordion-trigger" data-state="open" aria-expanded="true" aria-controls="p1">
5
+ <span>Is there a free plan?</span>
6
+ <svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
7
+ </button>
8
+ </h3>
9
+ <div id="p1" class="ml-accordion-panel" role="region" aria-labelledby="t1" data-state="open">
10
+ <p>Yes. The components in the open-source registry are free forever.</p>
11
+ </div>
12
+ </article>
13
+ <article class="ml-accordion-item" data-state="closed">
14
+ <h3 class="ml-accordion-heading">
15
+ <button id="t2" class="ml-accordion-trigger" data-state="closed" aria-expanded="false" aria-controls="p2">
16
+ <span>Can I change themes later?</span>
17
+ <svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
18
+ </button>
19
+ </h3>
20
+ <div id="p2" class="ml-accordion-panel" role="region" aria-labelledby="t2" data-state="closed" hidden>
21
+ <p>Change data-theme on any ancestor. Nothing else moves.</p>
22
+ </div>
23
+ </article>
24
+ </div>
@@ -0,0 +1,11 @@
1
+ <div class="ml-dropdown" data-ml="dropdown-menu">
2
+ <button class="ml-dropdown-trigger" type="button" aria-haspopup="menu" aria-expanded="false">
3
+ <span class="ml-dropdown-trigger-label">File</span>
4
+ <svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg>
5
+ </button>
6
+ <div class="ml-dropdown-menu" role="menu" aria-label="File" data-align="start" hidden>
7
+ <button class="ml-dropdown-item" type="button" role="menuitem">New</button>
8
+ <button class="ml-dropdown-item" type="button" role="menuitem">Open</button>
9
+ <button class="ml-dropdown-item" type="button" role="menuitem" data-danger>Delete</button>
10
+ </div>
11
+ </div>
@@ -0,0 +1,16 @@
1
+ <button class="ml-button" type="button" data-variant="danger" data-ml-opens="delete-project">
2
+ <span class="ml-button-label">Delete project</span>
3
+ </button>
4
+
5
+ <div class="ml-modal-overlay" data-ml-for="delete-project" hidden></div>
6
+ <div class="ml-modal" id="delete-project" data-ml="modal" role="alertdialog" aria-modal="true" aria-labelledby="delete-project-title" data-size="sm" tabindex="-1" hidden>
7
+ <div class="ml-modal-header">
8
+ <h2 class="ml-modal-title" id="delete-project-title">Delete project</h2>
9
+ <button class="ml-modal-close" type="button" aria-label="Close dialog"><svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
10
+ </div>
11
+ <div class="ml-modal-body">This removes the project and every deployment attached to it.</div>
12
+ <div class="ml-modal-footer">
13
+ <button class="ml-button" type="button" data-variant="secondary" data-ml-close><span class="ml-button-label">Keep it</span></button>
14
+ <button class="ml-button" type="button" data-variant="danger"><span class="ml-button-label">Delete</span></button>
15
+ </div>
16
+ </div>
@@ -0,0 +1,16 @@
1
+ <div class="ml-select-root" data-ml="select">
2
+ <span class="ml-select-label" id="fruit-label">Fruit</span>
3
+ <div class="ml-select-control">
4
+ <button class="ml-select" type="button" role="combobox" aria-expanded="false" aria-controls="fruit-list" aria-labelledby="fruit-label" data-state="closed">
5
+ <span class="ml-select-value" data-placeholder>Select…</span>
6
+ <span class="ml-select-chevron" aria-hidden="true"><svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m6 9 6 6 6-6"/></svg></span>
7
+ </button>
8
+ </div>
9
+ <div class="ml-select-popover" hidden>
10
+ <ul class="ml-select-list" id="fruit-list" role="listbox" aria-labelledby="fruit-label">
11
+ <li class="ml-select-option" id="opt-apple" role="option" aria-selected="false" data-state="unchecked">Apple</li>
12
+ <li class="ml-select-option" id="opt-pear" role="option" aria-selected="false" data-state="unchecked">Pear</li>
13
+ <li class="ml-select-option" id="opt-durian" role="option" aria-selected="false" aria-disabled="true" data-state="unchecked">Durian</li>
14
+ </ul>
15
+ </div>
16
+ </div>
@@ -0,0 +1,17 @@
1
+ <button class="ml-button" type="button" data-variant="secondary" data-ml-opens="account-settings">
2
+ <span class="ml-button-label">Settings</span>
3
+ </button>
4
+
5
+ <div class="ml-sheet-overlay" data-ml-for="account-settings" hidden></div>
6
+ <div class="ml-sheet-panel" id="account-settings" data-ml="sheet" role="dialog" aria-modal="true" aria-labelledby="account-settings-title" data-side="right" data-size="md" tabindex="-1" hidden>
7
+ <div class="ml-sheet-header">
8
+ <div class="ml-sheet-heading">
9
+ <h2 class="ml-sheet-title" id="account-settings-title">Account settings</h2>
10
+ <p class="ml-sheet-description">Changes apply instantly.</p>
11
+ </div>
12
+ <button class="ml-sheet-close" type="button" aria-label="Close panel"><svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
13
+ </div>
14
+ <div class="ml-sheet-body">
15
+ <p>Escape closes the panel, and so does the backdrop.</p>
16
+ </div>
17
+ </div>
@@ -0,0 +1,14 @@
1
+ <div class="ml-slider-field" data-ml="slider" data-size="md" data-ml-step="5">
2
+ <div class="ml-slider-header">
3
+ <span class="ml-slider-label" id="bright-label">Brightness</span>
4
+ <span class="ml-slider-output">40</span>
5
+ </div>
6
+ <div class="ml-slider">
7
+ <div class="ml-slider-track">
8
+ <span class="ml-slider-range" style="width: 40%"></span>
9
+ <span class="ml-slider-thumb" id="bright-thumb" role="slider" tabindex="0"
10
+ aria-labelledby="bright-label" aria-valuemin="0" aria-valuemax="100" aria-valuenow="40"
11
+ style="left: 40%"></span>
12
+ </div>
13
+ </div>
14
+ </div>
@@ -0,0 +1,6 @@
1
+ <span class="ml-switch-field">
2
+ <button class="ml-toggle" type="button" data-ml="switch" role="switch" aria-checked="false" aria-labelledby="notify-label" data-state="unchecked" data-size="md" data-hit="expand">
3
+ <span class="ml-toggle-thumb" aria-hidden="true"></span>
4
+ </button>
5
+ <span class="ml-switch-label" id="notify-label">Email notifications</span>
6
+ </span>
@@ -0,0 +1,10 @@
1
+ <div class="ml-tabs" data-ml="tabs" data-variant="default" data-orientation="horizontal">
2
+ <div class="ml-tabs-list" role="tablist" aria-label="Project" aria-orientation="horizontal">
3
+ <button class="ml-tabs-trigger" type="button" role="tab" id="tab-a" aria-controls="panel-a" aria-selected="true" data-state="active" tabindex="0">Overview</button>
4
+ <button class="ml-tabs-trigger" type="button" role="tab" id="tab-b" aria-controls="panel-b" aria-selected="false" data-state="inactive" tabindex="-1">Activity</button>
5
+ <button class="ml-tabs-trigger" type="button" role="tab" id="tab-c" aria-controls="panel-c" aria-selected="false" data-state="inactive" tabindex="-1">Settings</button>
6
+ </div>
7
+ <div class="ml-tabs-content" role="tabpanel" id="panel-a" aria-labelledby="tab-a" tabindex="0" data-state="active">Overview panel</div>
8
+ <div class="ml-tabs-content" role="tabpanel" id="panel-b" aria-labelledby="tab-b" tabindex="0" data-state="inactive" hidden>Activity panel</div>
9
+ <div class="ml-tabs-content" role="tabpanel" id="panel-c" aria-labelledby="tab-c" tabindex="0" data-state="inactive" hidden>Settings panel</div>
10
+ </div>
@@ -0,0 +1,8 @@
1
+ <div class="ml-toaster" data-ml="toast" role="region" aria-label="Notifications" data-position="top-right">
2
+ <div class="ml-toast" role="status" aria-live="polite" aria-atomic="true" data-tone="success" data-ml-duration="6000">
3
+ <div class="ml-toast-content">
4
+ <p class="ml-toast-message">Draft saved to your workspace.</p>
5
+ </div>
6
+ <button class="ml-toast-close" type="button" aria-label="Dismiss notification"><svg class="mlola-glyph" width="1em" height="1em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
7
+ </div>
8
+ </div>
@@ -0,0 +1,7 @@
1
+ <span class="ml-tooltip-root" data-ml="tooltip">
2
+ <button class="ml-button" type="button" data-variant="secondary" data-size="sm"><span class="ml-button-label">Share</span></button>
3
+ <span class="ml-tooltip" id="share-tip" role="tooltip" data-side="top" hidden>
4
+ Copy a link to this page
5
+ <span class="ml-tooltip-arrow" aria-hidden="true"></span>
6
+ </span>
7
+ </span>
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@mlola-ui/behavior",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
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",
7
7
  "sideEffects": false,
8
8
  "files": [
9
- "src"
9
+ "src",
10
+ "examples"
10
11
  ],
11
12
  "exports": {
12
13
  ".": "./src/index.js",
package/src/index.js CHANGED
@@ -21,10 +21,11 @@ import {
21
21
  clampToStep,
22
22
  focusTrapIndex,
23
23
  percentOf,
24
+ placeFloating,
24
25
  rovingIndex,
25
26
  sliderValueForKey,
26
27
  valueFromRatio,
27
- } from "./logic.js";
28
+ } from "./interaction.js";
28
29
  import { lockScroll } from "./document.js";
29
30
 
30
31
  const ENHANCED = "__mlolaEnhanced";
@@ -264,7 +265,9 @@ const behaviors = {
264
265
  open(opener_);
265
266
  return;
266
267
  }
267
- if (root.querySelector(".ml-modal-close, .ml-sheet-close")?.contains(event.target)) close();
268
+ // The corner button, or any control marked data-ml-close, such as Cancel.
269
+ const closer = event.target.closest(".ml-modal-close, .ml-sheet-close, [data-ml-close]");
270
+ if (closer && root.contains(closer)) close();
268
271
  if (closeOnOverlay && event.target === overlay()) close();
269
272
  }),
270
273
  on(document, "keydown", (event) => {
@@ -299,10 +302,34 @@ const behaviors = {
299
302
  (option) => option.getAttribute("aria-disabled") !== "true",
300
303
  );
301
304
 
305
+ // The listbox is fixed to the viewport so no overflow clips it, which
306
+ // means it is placed beside the trigger here, and follows it while open.
307
+ const place = () => {
308
+ const rect = trigger.getBoundingClientRect();
309
+ popover.style.minWidth = `${trigger.offsetWidth}px`;
310
+ const at = placeFloating(
311
+ { x: rect.left, y: rect.top, width: rect.width, height: rect.height },
312
+ { width: popover.offsetWidth, height: popover.offsetHeight },
313
+ { width: window.innerWidth, height: window.innerHeight },
314
+ { side: "bottom", align: "start", offset: 6 },
315
+ );
316
+ popover.style.left = `${at.x}px`;
317
+ popover.style.top = `${at.y}px`;
318
+ popover.dataset.side = at.side;
319
+ };
320
+ let frame = 0;
321
+ const follow = () => {
322
+ cancelAnimationFrame(frame);
323
+ frame = requestAnimationFrame(() => {
324
+ if (isOpen()) place();
325
+ });
326
+ };
327
+
302
328
  const setOpen = (open) => {
303
329
  trigger.dataset.state = open ? "open" : "closed";
304
330
  popover.hidden = !open;
305
331
  trigger.setAttribute("aria-expanded", String(open));
332
+ if (open) place();
306
333
  if (!open) {
307
334
  for (const option of options()) delete option.dataset.highlighted;
308
335
  trigger.removeAttribute("aria-activedescendant");
@@ -387,6 +414,9 @@ const behaviors = {
387
414
  on(document, "pointerdown", (event) => {
388
415
  if (isOpen() && !root.contains(event.target)) setOpen(false);
389
416
  }),
417
+ on(window, "scroll", follow, true),
418
+ on(window, "resize", follow),
419
+ () => cancelAnimationFrame(frame),
390
420
  ];
391
421
  },
392
422
 
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The interaction decisions: where a value snaps, which index a key moves
3
+ * to, where focus goes next, where a floating layer sits. The framework-free
4
+ * runtime imports only these, so a plain page downloads nothing else;
5
+ * everything is also exported from ./logic.js, the public entry.
6
+ */
7
+
8
+ /** Round to the precision the step implies, so 0.1 steps do not drift. */
9
+ function toStepPrecision(value, step) {
10
+ const decimals = (String(step).split(".")[1] ?? "").length;
11
+ return Number(value.toFixed(Math.min(decimals, 10)));
12
+ }
13
+
14
+ /** Clamp into range and snap onto the step grid measured from `min`. */
15
+ export function clampToStep(raw, { min = 0, max = 100, step = 1 } = {}) {
16
+ const low = Number.isFinite(min) ? min : 0;
17
+ const high = Number.isFinite(max) && max > low ? max : low + 100;
18
+ const increment = Number.isFinite(step) && step > 0 ? step : 1;
19
+ const value = Number.isFinite(raw) ? raw : low;
20
+ const clamped = Math.min(high, Math.max(low, value));
21
+ const snapped = low + Math.round((clamped - low) / increment) * increment;
22
+ return toStepPrecision(Math.min(high, Math.max(low, snapped)), increment);
23
+ }
24
+
25
+ /** Turn a 0..1 position along a track into a value in range. */
26
+ export function valueFromRatio(ratio, { min = 0, max = 100, step = 1 } = {}) {
27
+ const low = Number.isFinite(min) ? min : 0;
28
+ const high = Number.isFinite(max) && max > low ? max : low + 100;
29
+ const safe = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
30
+ return clampToStep(low + safe * (high - low), { min: low, max: high, step });
31
+ }
32
+
33
+ /** Percentage along the track, for positioning a range fill and a thumb. */
34
+ export function percentOf(value, { min = 0, max = 100 } = {}) {
35
+ const low = Number.isFinite(min) ? min : 0;
36
+ const high = Number.isFinite(max) && max > low ? max : low + 100;
37
+ if (high === low) return 0;
38
+ return ((Math.min(high, Math.max(low, value)) - low) / (high - low)) * 100;
39
+ }
40
+
41
+ /**
42
+ * The value a key produces, or undefined when the key means nothing here.
43
+ * Returning undefined rather than the current value lets a caller know whether
44
+ * to call preventDefault.
45
+ */
46
+ export function sliderValueForKey(key, current, { min = 0, max = 100, step = 1 } = {}) {
47
+ const low = Number.isFinite(min) ? min : 0;
48
+ const high = Number.isFinite(max) && max > low ? max : low + 100;
49
+ const increment = Number.isFinite(step) && step > 0 ? step : 1;
50
+ const page = Math.max(increment, (high - low) / 10);
51
+ const moves = {
52
+ ArrowLeft: -increment,
53
+ ArrowDown: -increment,
54
+ ArrowRight: increment,
55
+ ArrowUp: increment,
56
+ PageDown: -page,
57
+ PageUp: page,
58
+ };
59
+ if (key === "Home") return clampToStep(low, { min: low, max: high, step: increment });
60
+ if (key === "End") return clampToStep(high, { min: low, max: high, step: increment });
61
+ const delta = moves[key];
62
+ if (delta === undefined) return undefined;
63
+ return clampToStep(current + delta, { min: low, max: high, step: increment });
64
+ }
65
+
66
+ /**
67
+ * Where a roving-focus key lands. `enabled` reports whether an index may take
68
+ * focus, so disabled entries are stepped over rather than landed on.
69
+ * Returns -1 when the key does not move focus.
70
+ */
71
+ export function rovingIndex(
72
+ count,
73
+ index,
74
+ key,
75
+ { horizontal = true, wrap = true, enabled = () => true } = {},
76
+ ) {
77
+ if (count <= 0) return -1;
78
+ const firstEnabled = (from, direction) => {
79
+ let candidate = from;
80
+ for (let steps = 0; steps < count; steps += 1) {
81
+ if (enabled(candidate)) return candidate;
82
+ candidate += direction;
83
+ if (candidate < 0) candidate = count - 1;
84
+ if (candidate >= count) candidate = 0;
85
+ }
86
+ return -1;
87
+ };
88
+
89
+ if (key === "Home") return firstEnabled(0, 1);
90
+ if (key === "End") return firstEnabled(count - 1, -1);
91
+
92
+ const forward = horizontal ? "ArrowRight" : "ArrowDown";
93
+ const backward = horizontal ? "ArrowLeft" : "ArrowUp";
94
+ const step = key === forward ? 1 : key === backward ? -1 : 0;
95
+ if (!step) return -1;
96
+
97
+ let candidate = index;
98
+ for (let steps = 0; steps < count; steps += 1) {
99
+ candidate += step;
100
+ if (candidate < 0) {
101
+ if (!wrap) return -1;
102
+ candidate = count - 1;
103
+ }
104
+ if (candidate >= count) {
105
+ if (!wrap) return -1;
106
+ candidate = 0;
107
+ }
108
+ if (enabled(candidate)) return candidate;
109
+ }
110
+ return -1;
111
+ }
112
+
113
+ /**
114
+ * The index Tab should move to inside a trap, or -1 to let the browser handle
115
+ * it. Only the edges need intercepting; everything between them is natural.
116
+ */
117
+ export function focusTrapIndex(count, activeIndex, shiftKey) {
118
+ if (count <= 0) return -1;
119
+ if (shiftKey && activeIndex <= 0) return count - 1;
120
+ if (!shiftKey && activeIndex === count - 1) return 0;
121
+ return -1;
122
+ }
123
+
124
+ /** Which items stay open after toggling `index`. */
125
+ export function resolveDisclosure(open, index, { multiple = false, collapsible = true } = {}) {
126
+ const isOpen = open.includes(index);
127
+ if (isOpen && !collapsible && !multiple) return open;
128
+ if (multiple) {
129
+ return isOpen ? open.filter((entry) => entry !== index) : [...open, index].sort((a, b) => a - b);
130
+ }
131
+ if (isOpen) return collapsible ? [] : open;
132
+ return [index];
133
+ }
134
+
135
+ const OPPOSITE_SIDE = { top: "bottom", bottom: "top", left: "right", right: "left" };
136
+
137
+ /**
138
+ * Where a floating layer goes next to its anchor, as a pure function of
139
+ * rectangles: prefer the requested side, flip when it does not fit, and shift
140
+ * along the edge to stay inside the viewport. The React components and the
141
+ * framework-free runtime place every popover, menu and listbox with it.
142
+ */
143
+ export function placeFloating(anchor, floating, viewport, { side = "bottom", align = "center", offset = 8, padding = 8 } = {}) {
144
+ const room = {
145
+ top: anchor.y - padding,
146
+ bottom: viewport.height - (anchor.y + anchor.height) - padding,
147
+ left: anchor.x - padding,
148
+ right: viewport.width - (anchor.x + anchor.width) - padding,
149
+ };
150
+ const needs = (candidate) => (candidate === "top" || candidate === "bottom" ? floating.height : floating.width) + offset;
151
+ let chosen = side;
152
+ if (room[side] < needs(side) && room[OPPOSITE_SIDE[side]] > room[side]) chosen = OPPOSITE_SIDE[side];
153
+
154
+ const vertical = chosen === "top" || chosen === "bottom";
155
+ let x;
156
+ let y;
157
+ if (vertical) {
158
+ y = chosen === "bottom" ? anchor.y + anchor.height + offset : anchor.y - floating.height - offset;
159
+ x = align === "start" ? anchor.x : align === "end" ? anchor.x + anchor.width - floating.width : anchor.x + anchor.width / 2 - floating.width / 2;
160
+ } else {
161
+ x = chosen === "right" ? anchor.x + anchor.width + offset : anchor.x - floating.width - offset;
162
+ y = align === "start" ? anchor.y : align === "end" ? anchor.y + anchor.height - floating.height : anchor.y + anchor.height / 2 - floating.height / 2;
163
+ }
164
+ // Shift along the edge to stay on screen.
165
+ x = Math.min(Math.max(x, padding), Math.max(padding, viewport.width - floating.width - padding));
166
+ y = Math.min(Math.max(y, padding), Math.max(padding, viewport.height - floating.height - padding));
167
+ return { x: Math.round(x), y: Math.round(y), side: chosen };
168
+ }
package/src/logic.d.ts CHANGED
@@ -111,3 +111,26 @@ export function contextUsage<Segment extends ContextSegment>(
111
111
  limit: number,
112
112
  options?: { warning?: number; critical?: number },
113
113
  ): ContextUsage<Segment>;
114
+
115
+ export type Side = "top" | "bottom" | "left" | "right";
116
+ export type Align = "start" | "center" | "end";
117
+
118
+ export interface Rect {
119
+ x: number;
120
+ y: number;
121
+ width: number;
122
+ height: number;
123
+ }
124
+
125
+ export interface Placement {
126
+ x: number;
127
+ y: number;
128
+ side: Side;
129
+ }
130
+
131
+ export function placeFloating(
132
+ anchor: Rect,
133
+ floating: { width: number; height: number },
134
+ viewport: { width: number; height: number },
135
+ options?: { side?: Side; align?: Align; offset?: number; padding?: number },
136
+ ): Placement;
package/src/logic.js CHANGED
@@ -11,132 +11,7 @@
11
11
  * particular framework stops being fashionable.
12
12
  */
13
13
 
14
- /** Round to the precision the step implies, so 0.1 steps do not drift. */
15
- function toStepPrecision(value, step) {
16
- const decimals = (String(step).split(".")[1] ?? "").length;
17
- return Number(value.toFixed(Math.min(decimals, 10)));
18
- }
19
-
20
- /** Clamp into range and snap onto the step grid measured from `min`. */
21
- export function clampToStep(raw, { min = 0, max = 100, step = 1 } = {}) {
22
- const low = Number.isFinite(min) ? min : 0;
23
- const high = Number.isFinite(max) && max > low ? max : low + 100;
24
- const increment = Number.isFinite(step) && step > 0 ? step : 1;
25
- const value = Number.isFinite(raw) ? raw : low;
26
- const clamped = Math.min(high, Math.max(low, value));
27
- const snapped = low + Math.round((clamped - low) / increment) * increment;
28
- return toStepPrecision(Math.min(high, Math.max(low, snapped)), increment);
29
- }
30
-
31
- /** Turn a 0..1 position along a track into a value in range. */
32
- export function valueFromRatio(ratio, { min = 0, max = 100, step = 1 } = {}) {
33
- const low = Number.isFinite(min) ? min : 0;
34
- const high = Number.isFinite(max) && max > low ? max : low + 100;
35
- const safe = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0;
36
- return clampToStep(low + safe * (high - low), { min: low, max: high, step });
37
- }
38
-
39
- /** Percentage along the track, for positioning a range fill and a thumb. */
40
- export function percentOf(value, { min = 0, max = 100 } = {}) {
41
- const low = Number.isFinite(min) ? min : 0;
42
- const high = Number.isFinite(max) && max > low ? max : low + 100;
43
- if (high === low) return 0;
44
- return ((Math.min(high, Math.max(low, value)) - low) / (high - low)) * 100;
45
- }
46
-
47
- /**
48
- * The value a key produces, or undefined when the key means nothing here.
49
- * Returning undefined rather than the current value lets a caller know whether
50
- * to call preventDefault.
51
- */
52
- export function sliderValueForKey(key, current, { min = 0, max = 100, step = 1 } = {}) {
53
- const low = Number.isFinite(min) ? min : 0;
54
- const high = Number.isFinite(max) && max > low ? max : low + 100;
55
- const increment = Number.isFinite(step) && step > 0 ? step : 1;
56
- const page = Math.max(increment, (high - low) / 10);
57
- const moves = {
58
- ArrowLeft: -increment,
59
- ArrowDown: -increment,
60
- ArrowRight: increment,
61
- ArrowUp: increment,
62
- PageDown: -page,
63
- PageUp: page,
64
- };
65
- if (key === "Home") return clampToStep(low, { min: low, max: high, step: increment });
66
- if (key === "End") return clampToStep(high, { min: low, max: high, step: increment });
67
- const delta = moves[key];
68
- if (delta === undefined) return undefined;
69
- return clampToStep(current + delta, { min: low, max: high, step: increment });
70
- }
71
-
72
- /**
73
- * Where a roving-focus key lands. `enabled` reports whether an index may take
74
- * focus, so disabled entries are stepped over rather than landed on.
75
- * Returns -1 when the key does not move focus.
76
- */
77
- export function rovingIndex(
78
- count,
79
- index,
80
- key,
81
- { horizontal = true, wrap = true, enabled = () => true } = {},
82
- ) {
83
- if (count <= 0) return -1;
84
- const firstEnabled = (from, direction) => {
85
- let candidate = from;
86
- for (let steps = 0; steps < count; steps += 1) {
87
- if (enabled(candidate)) return candidate;
88
- candidate += direction;
89
- if (candidate < 0) candidate = count - 1;
90
- if (candidate >= count) candidate = 0;
91
- }
92
- return -1;
93
- };
94
-
95
- if (key === "Home") return firstEnabled(0, 1);
96
- if (key === "End") return firstEnabled(count - 1, -1);
97
-
98
- const forward = horizontal ? "ArrowRight" : "ArrowDown";
99
- const backward = horizontal ? "ArrowLeft" : "ArrowUp";
100
- const step = key === forward ? 1 : key === backward ? -1 : 0;
101
- if (!step) return -1;
102
-
103
- let candidate = index;
104
- for (let steps = 0; steps < count; steps += 1) {
105
- candidate += step;
106
- if (candidate < 0) {
107
- if (!wrap) return -1;
108
- candidate = count - 1;
109
- }
110
- if (candidate >= count) {
111
- if (!wrap) return -1;
112
- candidate = 0;
113
- }
114
- if (enabled(candidate)) return candidate;
115
- }
116
- return -1;
117
- }
118
-
119
- /**
120
- * The index Tab should move to inside a trap, or -1 to let the browser handle
121
- * it. Only the edges need intercepting; everything between them is natural.
122
- */
123
- export function focusTrapIndex(count, activeIndex, shiftKey) {
124
- if (count <= 0) return -1;
125
- if (shiftKey && activeIndex <= 0) return count - 1;
126
- if (!shiftKey && activeIndex === count - 1) return 0;
127
- return -1;
128
- }
129
-
130
- /** Which items stay open after toggling `index`. */
131
- export function resolveDisclosure(open, index, { multiple = false, collapsible = true } = {}) {
132
- const isOpen = open.includes(index);
133
- if (isOpen && !collapsible && !multiple) return open;
134
- if (multiple) {
135
- return isOpen ? open.filter((entry) => entry !== index) : [...open, index].sort((a, b) => a - b);
136
- }
137
- if (isOpen) return collapsible ? [] : open;
138
- return [index];
139
- }
14
+ export * from "./interaction.js";
140
15
 
141
16
  /**
142
17
  * A coarse password-strength hint, deliberately not authoritative. It exists so