@rio-m3e/core 0.1.0 → 0.3.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/README.md +2 -0
- package/dist/index.d.ts +837 -29
- package/dist/index.js +2378 -185
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,3 +1,296 @@
|
|
|
1
|
+
// src/utils/object.ts
|
|
2
|
+
/**
|
|
3
|
+
* A copy without the properties that are explicitly `undefined`, so spreading it over defaults
|
|
4
|
+
* keeps the defaults: `{ ...defaults, ...omitUndefined(props) }`.
|
|
5
|
+
*/
|
|
6
|
+
function omitUndefined(object) {
|
|
7
|
+
return Object.fromEntries(Object.entries(object).filter(([, value]) => value !== void 0));
|
|
8
|
+
}
|
|
9
|
+
// src/button-group/button-group.ts
|
|
10
|
+
/**
|
|
11
|
+
* M3E button group variants. A standard group spaces its buttons and lets a pressed button push
|
|
12
|
+
* its neighbors; a connected group joins them with 2px gaps and shared corners, and fills the
|
|
13
|
+
* width it is given.
|
|
14
|
+
*/
|
|
15
|
+
const BUTTON_GROUP_VARIANTS = ["standard", "connected"];
|
|
16
|
+
/** Whether the group's buttons can be selected: none, one at a time, or any number. */
|
|
17
|
+
const BUTTON_GROUP_SELECTIONS = [
|
|
18
|
+
"none",
|
|
19
|
+
"single",
|
|
20
|
+
"multiple"
|
|
21
|
+
];
|
|
22
|
+
const buttonGroupDefaults = {
|
|
23
|
+
variant: "standard",
|
|
24
|
+
size: "small",
|
|
25
|
+
shape: "round",
|
|
26
|
+
selection: "none",
|
|
27
|
+
required: false
|
|
28
|
+
};
|
|
29
|
+
/** How the adapter renders the button group: one root, with the buttons as its direct children. */
|
|
30
|
+
const buttonGroupSkeleton = {
|
|
31
|
+
part: "root",
|
|
32
|
+
tag: "div",
|
|
33
|
+
content: true
|
|
34
|
+
};
|
|
35
|
+
const valuesOf$1 = (selected) => selected === void 0 ? [] : Array.isArray(selected) ? selected : [selected];
|
|
36
|
+
function createButtonGroup(options) {
|
|
37
|
+
const props = () => ({
|
|
38
|
+
...buttonGroupDefaults,
|
|
39
|
+
...omitUndefined(options.props())
|
|
40
|
+
});
|
|
41
|
+
const isSelected = (value) => {
|
|
42
|
+
const p = props();
|
|
43
|
+
return p.selection !== "none" && valuesOf$1(p.selected).includes(value);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
parts: { root() {
|
|
47
|
+
const p = props();
|
|
48
|
+
return {
|
|
49
|
+
class: "rio-button-group",
|
|
50
|
+
role: "group",
|
|
51
|
+
"data-variant": p.variant,
|
|
52
|
+
"data-size": p.size,
|
|
53
|
+
"data-shape": p.shape
|
|
54
|
+
};
|
|
55
|
+
} },
|
|
56
|
+
size: () => props().size,
|
|
57
|
+
shape: () => props().shape,
|
|
58
|
+
selection: () => props().selection,
|
|
59
|
+
isSelected,
|
|
60
|
+
select(value, selected) {
|
|
61
|
+
const p = props();
|
|
62
|
+
if (p.selection === "none" || isSelected(value) === selected) return;
|
|
63
|
+
const current = valuesOf$1(p.selected);
|
|
64
|
+
if (!selected && p.required && current.length === 1) return;
|
|
65
|
+
if (p.selection === "single") options.onChange(selected ? value : void 0);
|
|
66
|
+
else options.onChange(selected ? [...current, value] : current.filter((other) => other !== value));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// src/primitives/spring/spring.ts
|
|
71
|
+
/**
|
|
72
|
+
* Where a spring is `t` seconds after it starts at `from` with `velocity`, heading to `to`.
|
|
73
|
+
*/
|
|
74
|
+
function solveSpring({ dampingRatio: zeta, stiffness }, from, to, velocity, t) {
|
|
75
|
+
const w0 = Math.sqrt(stiffness);
|
|
76
|
+
const x0 = from - to;
|
|
77
|
+
const v0 = velocity;
|
|
78
|
+
if (Math.abs(zeta - 1) < 1e-6) {
|
|
79
|
+
const b = v0 + w0 * x0;
|
|
80
|
+
const decay = Math.exp(-w0 * t);
|
|
81
|
+
return {
|
|
82
|
+
value: to + (x0 + b * t) * decay,
|
|
83
|
+
velocity: (b - w0 * (x0 + b * t)) * decay
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (zeta < 1) {
|
|
87
|
+
const a = zeta * w0;
|
|
88
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
89
|
+
const b = (v0 + a * x0) / wd;
|
|
90
|
+
const decay = Math.exp(-a * t);
|
|
91
|
+
const cos = Math.cos(wd * t);
|
|
92
|
+
const sin = Math.sin(wd * t);
|
|
93
|
+
return {
|
|
94
|
+
value: to + decay * (x0 * cos + b * sin),
|
|
95
|
+
velocity: decay * ((wd * b - a * x0) * cos - (a * b + wd * x0) * sin)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const root = w0 * Math.sqrt(zeta * zeta - 1);
|
|
99
|
+
const r1 = -zeta * w0 + root;
|
|
100
|
+
const r2 = -zeta * w0 - root;
|
|
101
|
+
const c2 = (v0 - r1 * x0) / (r2 - r1);
|
|
102
|
+
const c1 = x0 - c2;
|
|
103
|
+
const e1 = Math.exp(r1 * t);
|
|
104
|
+
const e2 = Math.exp(r2 * t);
|
|
105
|
+
return {
|
|
106
|
+
value: to + c1 * e1 + c2 * e2,
|
|
107
|
+
velocity: r1 * c1 * e1 + r2 * c2 * e2
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const now = () => performance.now() / 1e3;
|
|
111
|
+
/** A spring that animates a single number with `requestAnimationFrame`. */
|
|
112
|
+
function createSpring(options) {
|
|
113
|
+
const precision = options.precision ?? .01;
|
|
114
|
+
let from = options.value;
|
|
115
|
+
let target = options.value;
|
|
116
|
+
let startVelocity = 0;
|
|
117
|
+
let params;
|
|
118
|
+
let startedAt = 0;
|
|
119
|
+
let frame;
|
|
120
|
+
const state = () => params && frame !== void 0 ? solveSpring(params, from, target, startVelocity, now() - startedAt) : {
|
|
121
|
+
value: target,
|
|
122
|
+
velocity: 0
|
|
123
|
+
};
|
|
124
|
+
const settle = () => {
|
|
125
|
+
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
126
|
+
frame = void 0;
|
|
127
|
+
from = target;
|
|
128
|
+
startVelocity = 0;
|
|
129
|
+
options.onUpdate(target);
|
|
130
|
+
options.onRest?.();
|
|
131
|
+
};
|
|
132
|
+
const tick = () => {
|
|
133
|
+
const { value, velocity } = state();
|
|
134
|
+
if (Math.abs(value - target) < precision && Math.abs(velocity) < precision * 10) {
|
|
135
|
+
settle();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
options.onUpdate(value);
|
|
139
|
+
frame = requestAnimationFrame(tick);
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
get value() {
|
|
143
|
+
return state().value;
|
|
144
|
+
},
|
|
145
|
+
get velocity() {
|
|
146
|
+
return state().velocity;
|
|
147
|
+
},
|
|
148
|
+
get target() {
|
|
149
|
+
return target;
|
|
150
|
+
},
|
|
151
|
+
get moving() {
|
|
152
|
+
return frame !== void 0;
|
|
153
|
+
},
|
|
154
|
+
to(next, nextParams, velocity) {
|
|
155
|
+
const current = state();
|
|
156
|
+
from = current.value;
|
|
157
|
+
startVelocity = velocity ?? current.velocity;
|
|
158
|
+
target = next;
|
|
159
|
+
params = nextParams;
|
|
160
|
+
startedAt = now();
|
|
161
|
+
if (frame === void 0) frame = requestAnimationFrame(tick);
|
|
162
|
+
},
|
|
163
|
+
jump(value) {
|
|
164
|
+
target = value;
|
|
165
|
+
settle();
|
|
166
|
+
},
|
|
167
|
+
stop() {
|
|
168
|
+
const { value } = state();
|
|
169
|
+
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
170
|
+
frame = void 0;
|
|
171
|
+
from = target = value;
|
|
172
|
+
startVelocity = 0;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether the user asked for reduced motion. Spatial springs should jump straight to their target
|
|
178
|
+
* (or be skipped) when it is on; see `docs/foundations.md`, section 3.
|
|
179
|
+
*/
|
|
180
|
+
function prefersReducedMotion() {
|
|
181
|
+
return typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Reads a motion token's spring from CSS variables on an element, for example
|
|
185
|
+
* `--rio-motion-fast-spatial-damping` and `-stiffness`. Reading them from the element, rather than
|
|
186
|
+
* from the tokens package, lets a subtree that swaps motion schemes change its springs too.
|
|
187
|
+
* Returns `undefined` when the theme doesn't define them.
|
|
188
|
+
*/
|
|
189
|
+
function readSpringParams(element, token) {
|
|
190
|
+
const style = getComputedStyle(element);
|
|
191
|
+
const dampingRatio = parseFloat(style.getPropertyValue(`--rio-motion-${token}-damping`));
|
|
192
|
+
const stiffness = parseFloat(style.getPropertyValue(`--rio-motion-${token}-stiffness`));
|
|
193
|
+
return Number.isFinite(dampingRatio) && Number.isFinite(stiffness) ? {
|
|
194
|
+
dampingRatio,
|
|
195
|
+
stiffness
|
|
196
|
+
} : void 0;
|
|
197
|
+
}
|
|
198
|
+
// src/button-group/interaction.ts
|
|
199
|
+
const PRESSED_WIDTH_RATIO = .15;
|
|
200
|
+
function attachButtonGroup(root, options = {}) {
|
|
201
|
+
const ratio = options.pressedWidthRatio ?? PRESSED_WIDTH_RATIO;
|
|
202
|
+
const items = () => [...root.children].map((el) => el.classList.contains("rio-tooltip-trigger") ? el.firstElementChild : el).filter((el) => el instanceof HTMLElement);
|
|
203
|
+
const onKeydown = (event) => {
|
|
204
|
+
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
205
|
+
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
|
206
|
+
const focusable = items().filter((el) => !el.matches(":disabled") && el.tabIndex >= 0);
|
|
207
|
+
const index = focusable.findIndex((el) => el.contains(document.activeElement));
|
|
208
|
+
if (index === -1) return;
|
|
209
|
+
const next = focusable[index + (event.key === "ArrowRight" !== (getComputedStyle(root).direction === "rtl") ? 1 : -1)];
|
|
210
|
+
if (!next) return;
|
|
211
|
+
event.preventDefault();
|
|
212
|
+
next.focus();
|
|
213
|
+
};
|
|
214
|
+
root.addEventListener("keydown", onKeydown);
|
|
215
|
+
const springs = /* @__PURE__ */ new Map();
|
|
216
|
+
const natural = /* @__PURE__ */ new Map();
|
|
217
|
+
const setWidth = (el, value) => value === void 0 ? el.style.removeProperty("inline-size") : el.style.setProperty("inline-size", `${value}px`);
|
|
218
|
+
const springOf = (el) => {
|
|
219
|
+
let spring = springs.get(el);
|
|
220
|
+
if (!spring) {
|
|
221
|
+
spring = createSpring({
|
|
222
|
+
value: natural.get(el),
|
|
223
|
+
onUpdate: (value) => setWidth(el, value),
|
|
224
|
+
onRest: () => {
|
|
225
|
+
if (springs.get(el)?.target === natural.get(el)) setWidth(el, void 0);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
springs.set(el, spring);
|
|
229
|
+
}
|
|
230
|
+
return spring;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* A button's own width, without the width a spring wrote on it. The inline width comes off and
|
|
234
|
+
* goes back within the same task, so it never shows.
|
|
235
|
+
*/
|
|
236
|
+
const measure = (el) => {
|
|
237
|
+
const inline = el.style.inlineSize;
|
|
238
|
+
el.style.removeProperty("inline-size");
|
|
239
|
+
const width = el.getBoundingClientRect().width;
|
|
240
|
+
el.style.setProperty("inline-size", inline);
|
|
241
|
+
return width;
|
|
242
|
+
};
|
|
243
|
+
/** Sets every button's target width. `instant` skips the springs, for changes of size. */
|
|
244
|
+
const layout = (instant = false) => {
|
|
245
|
+
const all = items();
|
|
246
|
+
for (const el of all) natural.set(el, measure(el));
|
|
247
|
+
const targets = new Map(all.map((el) => [el, natural.get(el)]));
|
|
248
|
+
const pressed = all.findIndex((el) => el.hasAttribute("data-pressed"));
|
|
249
|
+
const standard = root.getAttribute("data-variant") !== "connected";
|
|
250
|
+
if (pressed !== -1 && standard && !prefersReducedMotion()) {
|
|
251
|
+
const growth = natural.get(all[pressed]) * ratio;
|
|
252
|
+
const neighbors = [all[pressed - 1], all[pressed + 1]].filter((el) => el !== void 0);
|
|
253
|
+
let gained = 0;
|
|
254
|
+
for (const el of neighbors) {
|
|
255
|
+
const width = natural.get(el);
|
|
256
|
+
const given = Math.min(growth / neighbors.length, width * ratio);
|
|
257
|
+
targets.set(el, width - given);
|
|
258
|
+
gained += given;
|
|
259
|
+
}
|
|
260
|
+
targets.set(all[pressed], natural.get(all[pressed]) + gained);
|
|
261
|
+
}
|
|
262
|
+
const params = readSpringParams(root, "fast-spatial");
|
|
263
|
+
for (const [el, target] of targets) {
|
|
264
|
+
const spring = springOf(el);
|
|
265
|
+
if (!el.style.inlineSize && spring.value !== natural.get(el)) spring.jump(natural.get(el));
|
|
266
|
+
if (spring.target === target) continue;
|
|
267
|
+
if (params && !instant && !prefersReducedMotion()) spring.to(target, params);
|
|
268
|
+
else spring.jump(target);
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const observer = new MutationObserver((mutations) => {
|
|
272
|
+
const current = items();
|
|
273
|
+
const own = mutations.filter((m) => current.includes(m.target));
|
|
274
|
+
if (own.length > 0) layout(own.some((m) => m.attributeName !== "data-pressed"));
|
|
275
|
+
});
|
|
276
|
+
observer.observe(root, {
|
|
277
|
+
subtree: true,
|
|
278
|
+
attributes: true,
|
|
279
|
+
attributeFilter: [
|
|
280
|
+
"data-pressed",
|
|
281
|
+
"data-size",
|
|
282
|
+
"data-width"
|
|
283
|
+
]
|
|
284
|
+
});
|
|
285
|
+
return { detach() {
|
|
286
|
+
root.removeEventListener("keydown", onKeydown);
|
|
287
|
+
observer.disconnect();
|
|
288
|
+
for (const [el, spring] of springs) {
|
|
289
|
+
spring.stop();
|
|
290
|
+
setWidth(el, void 0);
|
|
291
|
+
}
|
|
292
|
+
} };
|
|
293
|
+
}
|
|
1
294
|
// src/button/button.ts
|
|
2
295
|
const BUTTON_VARIANTS = [
|
|
3
296
|
"filled",
|
|
@@ -24,28 +317,63 @@ const buttonDefaults = {
|
|
|
24
317
|
softDisabled: false,
|
|
25
318
|
type: "button"
|
|
26
319
|
};
|
|
320
|
+
/** How the adapter renders the button's parts. The icon holds the leading icon. */
|
|
321
|
+
const buttonSkeleton = {
|
|
322
|
+
part: "root",
|
|
323
|
+
tag: "button",
|
|
324
|
+
children: [
|
|
325
|
+
{
|
|
326
|
+
part: "touch",
|
|
327
|
+
tag: "span"
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
part: "icon",
|
|
331
|
+
tag: "span",
|
|
332
|
+
when: "showsIcon",
|
|
333
|
+
content: true
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
part: "label",
|
|
337
|
+
tag: "span",
|
|
338
|
+
content: true
|
|
339
|
+
}
|
|
340
|
+
]
|
|
341
|
+
};
|
|
27
342
|
const isToggle = (p) => !!p.toggle && p.variant !== "text";
|
|
28
343
|
/** Defaults under the given props, skipping props that are explicitly `undefined`. */
|
|
29
344
|
const resolve = (props) => ({
|
|
30
345
|
...buttonDefaults,
|
|
31
|
-
...
|
|
346
|
+
...omitUndefined(props)
|
|
32
347
|
});
|
|
33
348
|
function createButton(options) {
|
|
34
349
|
return {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
350
|
+
parts: {
|
|
351
|
+
root() {
|
|
352
|
+
const p = resolve(options.props());
|
|
353
|
+
const toggle = isToggle(p);
|
|
354
|
+
return {
|
|
355
|
+
class: "rio-button rio-state-layer rio-focus-ring",
|
|
356
|
+
type: p.type,
|
|
357
|
+
disabled: p.disabled ? "" : void 0,
|
|
358
|
+
"aria-disabled": p.softDisabled && !p.disabled ? "true" : void 0,
|
|
359
|
+
"aria-pressed": toggle ? String(p.selected) : void 0,
|
|
360
|
+
"data-variant": p.variant,
|
|
361
|
+
"data-size": p.size,
|
|
362
|
+
"data-shape": p.shape,
|
|
363
|
+
"data-selected": toggle ? String(p.selected) : void 0
|
|
364
|
+
};
|
|
365
|
+
},
|
|
366
|
+
touch: () => ({
|
|
367
|
+
class: "rio-button__touch",
|
|
368
|
+
"aria-hidden": "true"
|
|
369
|
+
}),
|
|
370
|
+
icon: () => ({
|
|
371
|
+
class: "rio-button__icon",
|
|
372
|
+
"aria-hidden": "true"
|
|
373
|
+
}),
|
|
374
|
+
label: () => ({ class: "rio-button__label" })
|
|
48
375
|
},
|
|
376
|
+
showsIcon: () => !!options.content?.().icon,
|
|
49
377
|
handleClick(event) {
|
|
50
378
|
const p = options.props();
|
|
51
379
|
if (p.disabled || p.softDisabled) {
|
|
@@ -53,7 +381,449 @@ function createButton(options) {
|
|
|
53
381
|
return;
|
|
54
382
|
}
|
|
55
383
|
options.onClick(event);
|
|
56
|
-
if (isToggle(p)) options.
|
|
384
|
+
if (isToggle(p)) options.onChange(!p.selected);
|
|
385
|
+
}
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
// src/dialog/dialog.ts
|
|
389
|
+
/**
|
|
390
|
+
* The dialog's ARIA role: `alertdialog` for a prompt that asks for a response, such as a
|
|
391
|
+
* confirmation, and `dialog` for one that holds a list or a form.
|
|
392
|
+
*/
|
|
393
|
+
const DIALOG_ROLES = ["alertdialog", "dialog"];
|
|
394
|
+
const dialogDefaults = {
|
|
395
|
+
open: false,
|
|
396
|
+
role: "alertdialog"
|
|
397
|
+
};
|
|
398
|
+
/**
|
|
399
|
+
* The markup of an open dialog. The parts inside render from opening until `attachDialog`'s
|
|
400
|
+
* `onClosed`; a closed dialog is the empty `<dialog>`.
|
|
401
|
+
*/
|
|
402
|
+
const dialogSkeleton = {
|
|
403
|
+
part: "root",
|
|
404
|
+
tag: "dialog",
|
|
405
|
+
children: [
|
|
406
|
+
{
|
|
407
|
+
part: "icon",
|
|
408
|
+
tag: "div",
|
|
409
|
+
when: "showsIcon",
|
|
410
|
+
content: true
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
part: "headline",
|
|
414
|
+
tag: "h2",
|
|
415
|
+
when: "showsHeadline",
|
|
416
|
+
content: true
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
part: "content",
|
|
420
|
+
tag: "div",
|
|
421
|
+
content: true
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
part: "actions",
|
|
425
|
+
tag: "div",
|
|
426
|
+
when: "showsActions",
|
|
427
|
+
content: true
|
|
428
|
+
}
|
|
429
|
+
]
|
|
430
|
+
};
|
|
431
|
+
const NO_CONTENT$2 = {
|
|
432
|
+
icon: false,
|
|
433
|
+
headline: false,
|
|
434
|
+
actions: false
|
|
435
|
+
};
|
|
436
|
+
function createDialog(options) {
|
|
437
|
+
const props = () => ({
|
|
438
|
+
...dialogDefaults,
|
|
439
|
+
...omitUndefined(options.props())
|
|
440
|
+
});
|
|
441
|
+
const content = () => options.content?.() ?? NO_CONTENT$2;
|
|
442
|
+
const showsIcon = () => content().icon;
|
|
443
|
+
const showsHeadline = () => content().headline;
|
|
444
|
+
const showsActions = () => content().actions;
|
|
445
|
+
const { ids } = options;
|
|
446
|
+
return {
|
|
447
|
+
parts: {
|
|
448
|
+
root() {
|
|
449
|
+
const alert = props().role === "alertdialog";
|
|
450
|
+
return {
|
|
451
|
+
class: "rio-dialog",
|
|
452
|
+
role: alert ? "alertdialog" : void 0,
|
|
453
|
+
"aria-labelledby": showsHeadline() ? ids.headline : void 0,
|
|
454
|
+
"aria-describedby": alert ? ids.content : void 0,
|
|
455
|
+
"data-icon": showsIcon() ? "" : void 0
|
|
456
|
+
};
|
|
457
|
+
},
|
|
458
|
+
icon: () => ({
|
|
459
|
+
class: "rio-dialog__icon",
|
|
460
|
+
"aria-hidden": "true"
|
|
461
|
+
}),
|
|
462
|
+
headline: () => ({
|
|
463
|
+
class: "rio-dialog__headline",
|
|
464
|
+
id: ids.headline
|
|
465
|
+
}),
|
|
466
|
+
content: () => ({
|
|
467
|
+
class: "rio-dialog__content rio-focus-ring",
|
|
468
|
+
id: ids.content,
|
|
469
|
+
"data-focus-ring": "inner"
|
|
470
|
+
}),
|
|
471
|
+
actions: () => ({ class: "rio-dialog__actions" })
|
|
472
|
+
},
|
|
473
|
+
showsIcon,
|
|
474
|
+
showsHeadline,
|
|
475
|
+
showsActions
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// src/primitives/overlay/overlay.ts
|
|
479
|
+
const registries = /* @__PURE__ */ new WeakMap();
|
|
480
|
+
/** What a `contain`ed overlay keeps in */
|
|
481
|
+
const PRESSES = [
|
|
482
|
+
"click",
|
|
483
|
+
"auxclick",
|
|
484
|
+
"dblclick",
|
|
485
|
+
"pointerdown",
|
|
486
|
+
"pointerup",
|
|
487
|
+
"mousedown",
|
|
488
|
+
"mouseup",
|
|
489
|
+
"contextmenu"
|
|
490
|
+
];
|
|
491
|
+
const stopPropagation = (event) => event.stopPropagation();
|
|
492
|
+
const isOpen = ({ element, options }) => options.mode === "modal" ? element.open : element.matches(":popover-open");
|
|
493
|
+
/** Open on the record, but no longer in the top layer, after leaving the document */
|
|
494
|
+
const left = (entry) => entry.options.mode === "modal" ? !entry.element.matches(":modal") : !isOpen(entry);
|
|
495
|
+
const close = (entry) => {
|
|
496
|
+
if (!isOpen(entry)) return;
|
|
497
|
+
if (entry.options.mode === "modal") entry.element.close();
|
|
498
|
+
else entry.element.hidePopover();
|
|
499
|
+
};
|
|
500
|
+
const track = (registry) => {
|
|
501
|
+
if (registry.stack.length) registry.moves.observe(registry.document, {
|
|
502
|
+
childList: true,
|
|
503
|
+
subtree: true
|
|
504
|
+
});
|
|
505
|
+
else registry.moves.disconnect();
|
|
506
|
+
};
|
|
507
|
+
const opened = (entry) => {
|
|
508
|
+
const { stack } = entry.registry;
|
|
509
|
+
if (stack.includes(entry)) return;
|
|
510
|
+
stack.push(entry);
|
|
511
|
+
track(entry.registry);
|
|
512
|
+
for (const other of stack.filter((open) => open !== entry && open.options.transient)) {
|
|
513
|
+
other.reason = "replaced";
|
|
514
|
+
close(other);
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
const closed = (entry, asked) => {
|
|
518
|
+
const { stack } = entry.registry;
|
|
519
|
+
const index = stack.indexOf(entry);
|
|
520
|
+
if (index < 0) return;
|
|
521
|
+
stack.splice(index, 1);
|
|
522
|
+
track(entry.registry);
|
|
523
|
+
const reason = entry.reason ?? "dismiss";
|
|
524
|
+
entry.reason = void 0;
|
|
525
|
+
if (!asked) entry.options.onClose(reason);
|
|
526
|
+
const inside = (node) => !!node && entry.element.contains(node);
|
|
527
|
+
for (const other of stack.filter((open) => inside(open.element) || inside(open.source))) {
|
|
528
|
+
other.reason = "parent";
|
|
529
|
+
close(other);
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
const registryOf = (document) => {
|
|
533
|
+
let registry = registries.get(document);
|
|
534
|
+
if (!registry) {
|
|
535
|
+
const stack = [];
|
|
536
|
+
const onKeydown = (event) => {
|
|
537
|
+
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return;
|
|
538
|
+
const top = stack.at(-1);
|
|
539
|
+
if (!top) return;
|
|
540
|
+
const target = event.target;
|
|
541
|
+
const host = typeof target?.closest === "function" ? target.closest("dialog:modal, :popover-open:not([popover=\"manual\"])") : null;
|
|
542
|
+
if (host && !stack.some((open) => open.element === host)) return;
|
|
543
|
+
event.preventDefault();
|
|
544
|
+
top.options.onClose("escape");
|
|
545
|
+
};
|
|
546
|
+
registry = {
|
|
547
|
+
stack,
|
|
548
|
+
attached: 0,
|
|
549
|
+
onKeydown,
|
|
550
|
+
moves: new MutationObserver(() => {
|
|
551
|
+
for (const entry of stack.filter(left)) {
|
|
552
|
+
if (entry.options.mode === "modal") entry.element.close();
|
|
553
|
+
closed(entry, false);
|
|
554
|
+
}
|
|
555
|
+
}),
|
|
556
|
+
document
|
|
557
|
+
};
|
|
558
|
+
registries.set(document, registry);
|
|
559
|
+
}
|
|
560
|
+
return registry;
|
|
561
|
+
};
|
|
562
|
+
function attachOverlay(element, options) {
|
|
563
|
+
const modal = options.mode === "modal";
|
|
564
|
+
if (modal && element.localName !== "dialog") throw new TypeError("A modal overlay must be a <dialog> element.");
|
|
565
|
+
const document = element.ownerDocument;
|
|
566
|
+
const registry = registryOf(document);
|
|
567
|
+
const entry = {
|
|
568
|
+
element,
|
|
569
|
+
options,
|
|
570
|
+
registry
|
|
571
|
+
};
|
|
572
|
+
const onToggle = () => isOpen(entry) ? opened(entry) : closed(entry, false);
|
|
573
|
+
const onDialogClose = () => {
|
|
574
|
+
if (!isOpen(entry)) closed(entry, false);
|
|
575
|
+
};
|
|
576
|
+
const onCancel = (event) => {
|
|
577
|
+
event.preventDefault();
|
|
578
|
+
options.onClose("escape");
|
|
579
|
+
};
|
|
580
|
+
if (modal) {
|
|
581
|
+
element.addEventListener("close", onDialogClose);
|
|
582
|
+
element.addEventListener("cancel", onCancel);
|
|
583
|
+
} else {
|
|
584
|
+
element.popover = options.mode;
|
|
585
|
+
element.addEventListener("toggle", onToggle);
|
|
586
|
+
}
|
|
587
|
+
if (options.contain) for (const type of PRESSES) element.addEventListener(type, stopPropagation);
|
|
588
|
+
if (registry.attached++ === 0) document.addEventListener("keydown", registry.onKeydown);
|
|
589
|
+
let detached = false;
|
|
590
|
+
return {
|
|
591
|
+
show(source) {
|
|
592
|
+
if (detached || isOpen(entry)) return;
|
|
593
|
+
const index = registry.stack.indexOf(entry);
|
|
594
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
595
|
+
track(registry);
|
|
596
|
+
entry.reason = void 0;
|
|
597
|
+
entry.source = source;
|
|
598
|
+
if (modal) element.showModal();
|
|
599
|
+
else element.showPopover({ source });
|
|
600
|
+
opened(entry);
|
|
601
|
+
},
|
|
602
|
+
hide() {
|
|
603
|
+
if (!isOpen(entry)) return;
|
|
604
|
+
close(entry);
|
|
605
|
+
closed(entry, true);
|
|
606
|
+
},
|
|
607
|
+
covered() {
|
|
608
|
+
const index = registry.stack.indexOf(entry);
|
|
609
|
+
return index >= 0 && registry.stack.slice(index + 1).some((open) => !open.options.transient);
|
|
610
|
+
},
|
|
611
|
+
detach() {
|
|
612
|
+
if (detached) return;
|
|
613
|
+
detached = true;
|
|
614
|
+
element.removeEventListener("close", onDialogClose);
|
|
615
|
+
element.removeEventListener("cancel", onCancel);
|
|
616
|
+
element.removeEventListener("toggle", onToggle);
|
|
617
|
+
for (const type of PRESSES) element.removeEventListener(type, stopPropagation);
|
|
618
|
+
const index = registry.stack.indexOf(entry);
|
|
619
|
+
if (index >= 0) registry.stack.splice(index, 1);
|
|
620
|
+
track(registry);
|
|
621
|
+
close(entry);
|
|
622
|
+
if (!modal) element.removeAttribute("popover");
|
|
623
|
+
if (--registry.attached === 0) document.removeEventListener("keydown", registry.onKeydown);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
// src/dialog/interaction.ts
|
|
628
|
+
const TABBABLE = [
|
|
629
|
+
"a[href]",
|
|
630
|
+
"area[href]",
|
|
631
|
+
"button",
|
|
632
|
+
"input:not([type=\"hidden\"])",
|
|
633
|
+
"select",
|
|
634
|
+
"textarea",
|
|
635
|
+
"iframe",
|
|
636
|
+
"audio[controls]",
|
|
637
|
+
"video[controls]",
|
|
638
|
+
"summary",
|
|
639
|
+
"[contenteditable]:not([contenteditable=\"false\"])",
|
|
640
|
+
"[tabindex]"
|
|
641
|
+
].join(",");
|
|
642
|
+
const isRadio = (element) => element.localName === "input" && element.type === "radio";
|
|
643
|
+
const visible = (element) => typeof element.checkVisibility === "function" ? element.checkVisibility({ visibilityProperty: true }) : element.getClientRects().length > 0;
|
|
644
|
+
/** The elements Tab stops at in `root`, in order. Elements inside shadow roots aren't found. */
|
|
645
|
+
const tabbablesIn = (root) => {
|
|
646
|
+
const all = [...root.querySelectorAll(TABBABLE)].filter((element) => element.tabIndex >= 0 && !element.matches(":disabled") && !element.closest("[inert]") && visible(element));
|
|
647
|
+
return all.filter((element) => {
|
|
648
|
+
if (!isRadio(element) || !element.name) return true;
|
|
649
|
+
const group = all.filter((other) => isRadio(other) && other.name === element.name && other.form === element.form);
|
|
650
|
+
return element === (group.find((radio) => radio.checked) ?? group[0]);
|
|
651
|
+
});
|
|
652
|
+
};
|
|
653
|
+
/** How far, in px, the content may scroll and still count as at its end */
|
|
654
|
+
const SCROLL_SLOP = 1;
|
|
655
|
+
const scrollLocks = /* @__PURE__ */ new WeakMap();
|
|
656
|
+
const lockScroll = (document) => {
|
|
657
|
+
const lock = scrollLocks.get(document);
|
|
658
|
+
if (lock) {
|
|
659
|
+
lock.count++;
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const html = document.documentElement;
|
|
663
|
+
const view = document.defaultView;
|
|
664
|
+
const { overflow, scrollbarGutter } = html.style;
|
|
665
|
+
const scrollbar = view.innerWidth - html.clientWidth;
|
|
666
|
+
html.style.overflow = "hidden";
|
|
667
|
+
if (scrollbar > 0 && view.getComputedStyle(html).scrollbarGutter === "auto") html.style.scrollbarGutter = "stable";
|
|
668
|
+
scrollLocks.set(document, {
|
|
669
|
+
count: 1,
|
|
670
|
+
restore() {
|
|
671
|
+
html.style.overflow = overflow;
|
|
672
|
+
html.style.scrollbarGutter = scrollbarGutter;
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
};
|
|
676
|
+
const unlockScroll = (document) => {
|
|
677
|
+
const lock = scrollLocks.get(document);
|
|
678
|
+
if (!lock || --lock.count > 0) return;
|
|
679
|
+
lock.restore();
|
|
680
|
+
scrollLocks.delete(document);
|
|
681
|
+
};
|
|
682
|
+
function attachDialog(root, options) {
|
|
683
|
+
const document = root.ownerDocument;
|
|
684
|
+
const cancel = (reason) => {
|
|
685
|
+
let prevented = false;
|
|
686
|
+
options.onCancel?.({
|
|
687
|
+
reason,
|
|
688
|
+
get defaultPrevented() {
|
|
689
|
+
return prevented;
|
|
690
|
+
},
|
|
691
|
+
preventDefault() {
|
|
692
|
+
prevented = true;
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
if (!prevented) options.onOpenChange(false);
|
|
696
|
+
};
|
|
697
|
+
const overlay = attachOverlay(root, {
|
|
698
|
+
mode: "modal",
|
|
699
|
+
contain: true,
|
|
700
|
+
onClose: (reason) => reason === "escape" ? cancel("escape") : options.onOpenChange(false)
|
|
701
|
+
});
|
|
702
|
+
const onKeydown = (event) => {
|
|
703
|
+
if (event.key !== "Tab" || event.defaultPrevented) return;
|
|
704
|
+
if (event.altKey || event.ctrlKey || event.metaKey) return;
|
|
705
|
+
const tabbables = tabbablesIn(root);
|
|
706
|
+
const first = tabbables[0];
|
|
707
|
+
const last = tabbables.at(-1);
|
|
708
|
+
if (!first || !last) {
|
|
709
|
+
event.preventDefault();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
const focused = document.activeElement;
|
|
713
|
+
if (!(event.shiftKey ? focused === first || focused === root : focused === last)) return;
|
|
714
|
+
event.preventDefault();
|
|
715
|
+
(event.shiftKey ? last : first).focus();
|
|
716
|
+
};
|
|
717
|
+
const onScrim = (event) => {
|
|
718
|
+
if (event.target !== root) return false;
|
|
719
|
+
const box = root.getBoundingClientRect();
|
|
720
|
+
return event.clientX < box.left || event.clientX > box.right || event.clientY < box.top || event.clientY > box.bottom;
|
|
721
|
+
};
|
|
722
|
+
let pressedScrim = false;
|
|
723
|
+
const onPointerDown = (event) => {
|
|
724
|
+
pressedScrim = onScrim(event) && !overlay.covered();
|
|
725
|
+
};
|
|
726
|
+
const onClick = (event) => {
|
|
727
|
+
const pressed = pressedScrim;
|
|
728
|
+
pressedScrim = false;
|
|
729
|
+
if (pressed && onScrim(event)) cancel("scrim");
|
|
730
|
+
};
|
|
731
|
+
const content = () => root.querySelector(":scope > .rio-dialog__content");
|
|
732
|
+
const updateDividers = () => {
|
|
733
|
+
const element = content();
|
|
734
|
+
if (!element) return;
|
|
735
|
+
const hidden = element.scrollHeight - element.clientHeight;
|
|
736
|
+
element.toggleAttribute("data-overflow-top", element.scrollTop >= SCROLL_SLOP);
|
|
737
|
+
element.toggleAttribute("data-overflow-bottom", hidden - element.scrollTop >= SCROLL_SLOP);
|
|
738
|
+
};
|
|
739
|
+
/** The content, while it takes the focus so the keyboard can scroll it */
|
|
740
|
+
let reachable = null;
|
|
741
|
+
const setReachable = (element) => {
|
|
742
|
+
if (element === reachable) return;
|
|
743
|
+
reachable?.removeAttribute("tabindex");
|
|
744
|
+
reachable = element;
|
|
745
|
+
reachable?.setAttribute("tabindex", "0");
|
|
746
|
+
};
|
|
747
|
+
const updateOverflow = () => {
|
|
748
|
+
updateDividers();
|
|
749
|
+
const element = content();
|
|
750
|
+
const scrolls = !!element && element.scrollHeight - element.clientHeight >= SCROLL_SLOP;
|
|
751
|
+
setReachable(scrolls && tabbablesIn(element).length === 0 ? element : null);
|
|
752
|
+
};
|
|
753
|
+
const resizes = new ResizeObserver(updateOverflow);
|
|
754
|
+
let watched = null;
|
|
755
|
+
const watch = () => {
|
|
756
|
+
resizes.disconnect();
|
|
757
|
+
watched?.removeEventListener("scroll", updateDividers);
|
|
758
|
+
watched = content();
|
|
759
|
+
if (!watched) return;
|
|
760
|
+
watched.addEventListener("scroll", updateDividers, { passive: true });
|
|
761
|
+
resizes.observe(watched);
|
|
762
|
+
for (const child of watched.children) resizes.observe(child);
|
|
763
|
+
};
|
|
764
|
+
const unwatch = () => {
|
|
765
|
+
resizes.disconnect();
|
|
766
|
+
watched?.removeEventListener("scroll", updateDividers);
|
|
767
|
+
watched = null;
|
|
768
|
+
setReachable(null);
|
|
769
|
+
};
|
|
770
|
+
const focusFirst = () => {
|
|
771
|
+
if (root.querySelector("[autofocus]")) return;
|
|
772
|
+
const tabbables = tabbablesIn(root);
|
|
773
|
+
(tabbables.find((element) => element !== reachable) ?? tabbables[0])?.focus();
|
|
774
|
+
};
|
|
775
|
+
/** Whether this dialog holds a lock on the page's scroll: from opening until it closes */
|
|
776
|
+
let locked = false;
|
|
777
|
+
/** Counts closings, so only the last one reports its end, and only if the dialog stayed closed */
|
|
778
|
+
let closings = 0;
|
|
779
|
+
const closed = () => {
|
|
780
|
+
if (!locked) return;
|
|
781
|
+
locked = false;
|
|
782
|
+
unlockScroll(document);
|
|
783
|
+
const closing = ++closings;
|
|
784
|
+
const motion = root.getAnimations({ subtree: true }).map((animation) => animation.finished.catch(() => void 0));
|
|
785
|
+
Promise.all(motion).then(() => {
|
|
786
|
+
if (closing === closings && !root.open) options.onClosed?.();
|
|
787
|
+
});
|
|
788
|
+
};
|
|
789
|
+
root.addEventListener("keydown", onKeydown);
|
|
790
|
+
root.addEventListener("pointerdown", onPointerDown);
|
|
791
|
+
root.addEventListener("click", onClick);
|
|
792
|
+
return {
|
|
793
|
+
sync(open) {
|
|
794
|
+
if (open && !root.open) {
|
|
795
|
+
overlay.show();
|
|
796
|
+
if (!locked) {
|
|
797
|
+
locked = true;
|
|
798
|
+
lockScroll(document);
|
|
799
|
+
}
|
|
800
|
+
const element = content();
|
|
801
|
+
if (element) element.scrollTop = 0;
|
|
802
|
+
watch();
|
|
803
|
+
updateOverflow();
|
|
804
|
+
focusFirst();
|
|
805
|
+
} else if (!open) {
|
|
806
|
+
if (root.open) overlay.hide();
|
|
807
|
+
unwatch();
|
|
808
|
+
closed();
|
|
809
|
+
}
|
|
810
|
+
},
|
|
811
|
+
update() {
|
|
812
|
+
if (!root.open) return;
|
|
813
|
+
watch();
|
|
814
|
+
updateOverflow();
|
|
815
|
+
},
|
|
816
|
+
detach() {
|
|
817
|
+
root.removeEventListener("keydown", onKeydown);
|
|
818
|
+
root.removeEventListener("pointerdown", onPointerDown);
|
|
819
|
+
root.removeEventListener("click", onClick);
|
|
820
|
+
unwatch();
|
|
821
|
+
overlay.detach();
|
|
822
|
+
closings++;
|
|
823
|
+
if (locked) {
|
|
824
|
+
locked = false;
|
|
825
|
+
unlockScroll(document);
|
|
826
|
+
}
|
|
57
827
|
}
|
|
58
828
|
};
|
|
59
829
|
}
|
|
@@ -81,25 +851,989 @@ const iconButtonDefaults = {
|
|
|
81
851
|
softDisabled: false,
|
|
82
852
|
type: "button"
|
|
83
853
|
};
|
|
854
|
+
/**
|
|
855
|
+
* How the adapter renders the icon button's parts. The icon holds the selected icon when
|
|
856
|
+
* `showsSelectedIcon()`, otherwise the default one.
|
|
857
|
+
*/
|
|
858
|
+
const iconButtonSkeleton = {
|
|
859
|
+
part: "root",
|
|
860
|
+
tag: "button",
|
|
861
|
+
children: [{
|
|
862
|
+
part: "touch",
|
|
863
|
+
tag: "span"
|
|
864
|
+
}, {
|
|
865
|
+
part: "icon",
|
|
866
|
+
tag: "span",
|
|
867
|
+
content: true
|
|
868
|
+
}]
|
|
869
|
+
};
|
|
84
870
|
function createIconButton(options) {
|
|
85
871
|
const button = createButton({
|
|
86
|
-
...options,
|
|
87
872
|
props: () => ({
|
|
88
873
|
...options.props(),
|
|
89
874
|
variant: options.props().variant
|
|
90
|
-
})
|
|
875
|
+
}),
|
|
876
|
+
onClick: options.onClick,
|
|
877
|
+
onChange: options.onChange
|
|
91
878
|
});
|
|
92
879
|
return {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
880
|
+
parts: {
|
|
881
|
+
root() {
|
|
882
|
+
const p = options.props();
|
|
883
|
+
const root = button.parts.root();
|
|
884
|
+
return {
|
|
885
|
+
...root,
|
|
886
|
+
class: `${root.class} rio-icon-button`,
|
|
887
|
+
"aria-label": p.label,
|
|
888
|
+
"data-width": p.width ?? iconButtonDefaults.width,
|
|
889
|
+
"data-variant": p.variant ?? iconButtonDefaults.variant
|
|
890
|
+
};
|
|
891
|
+
},
|
|
892
|
+
touch: button.parts.touch,
|
|
893
|
+
icon: button.parts.icon
|
|
894
|
+
},
|
|
895
|
+
showsSelectedIcon() {
|
|
896
|
+
const p = options.props();
|
|
897
|
+
return !!(p.toggle && p.selected && options.content?.().selectedIcon);
|
|
898
|
+
},
|
|
899
|
+
handleClick: button.handleClick
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
// src/primitives/anchored-position/anchored-position.ts
|
|
903
|
+
const OPPOSITE = {
|
|
904
|
+
top: "bottom",
|
|
905
|
+
bottom: "top",
|
|
906
|
+
start: "end",
|
|
907
|
+
end: "start"
|
|
908
|
+
};
|
|
909
|
+
const VARS = [
|
|
910
|
+
"--rio-anchored-available-width",
|
|
911
|
+
"--rio-anchored-available-height",
|
|
912
|
+
"--rio-anchored-anchor-width"
|
|
913
|
+
];
|
|
914
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
|
915
|
+
/** An anchor point, rather than an element. Checked by shape, so elements from iframes count. */
|
|
916
|
+
const isPoint = (target) => typeof target.getBoundingClientRect !== "function";
|
|
917
|
+
function attachAnchoredPosition(floating, options) {
|
|
918
|
+
const document = floating.ownerDocument;
|
|
919
|
+
const view = document.defaultView;
|
|
920
|
+
floating.dataset.anchored = "";
|
|
921
|
+
const offset = options.offset ?? 0;
|
|
922
|
+
const alignOffset = options.alignOffset ?? 0;
|
|
923
|
+
const padding = options.padding ?? 8;
|
|
924
|
+
const preferred = options.side ?? "bottom";
|
|
925
|
+
const align = options.align ?? "start";
|
|
926
|
+
const candidates = [.../* @__PURE__ */ new Set([preferred, ...options.fallbacks ?? [OPPOSITE[preferred]]])];
|
|
927
|
+
/** Rounds to the device's pixel grid, so text in the element stays sharp. */
|
|
928
|
+
const snap = (value) => Math.round(value * view.devicePixelRatio) / view.devicePixelRatio;
|
|
929
|
+
let frame;
|
|
930
|
+
const schedule = () => {
|
|
931
|
+
frame ??= view.requestAnimationFrame(() => {
|
|
932
|
+
frame = void 0;
|
|
933
|
+
place();
|
|
934
|
+
});
|
|
935
|
+
};
|
|
936
|
+
const onScroll = (event) => {
|
|
937
|
+
if (event.target !== document && floating.contains(event.target)) return;
|
|
938
|
+
schedule();
|
|
939
|
+
};
|
|
940
|
+
const resize = new view.ResizeObserver(schedule);
|
|
941
|
+
resize.observe(floating);
|
|
942
|
+
let observed;
|
|
943
|
+
const observeAnchor = (element) => {
|
|
944
|
+
if (element === observed) return;
|
|
945
|
+
if (observed) resize.unobserve(observed);
|
|
946
|
+
observed = element;
|
|
947
|
+
if (element) resize.observe(element);
|
|
948
|
+
};
|
|
949
|
+
const place = () => {
|
|
950
|
+
const target = options.anchor();
|
|
951
|
+
if (!target || floating.getClientRects().length === 0) return;
|
|
952
|
+
if (!isPoint(target) && !target.isConnected) return;
|
|
953
|
+
observeAnchor(isPoint(target) ? void 0 : target);
|
|
954
|
+
const anchor = isPoint(target) ? {
|
|
955
|
+
left: target.x,
|
|
956
|
+
right: target.x,
|
|
957
|
+
top: target.y,
|
|
958
|
+
bottom: target.y,
|
|
959
|
+
width: 0
|
|
960
|
+
} : target.getBoundingClientRect();
|
|
961
|
+
const rtl = view.getComputedStyle(isPoint(target) ? floating : target).direction === "rtl";
|
|
962
|
+
const viewport = {
|
|
963
|
+
width: document.documentElement.clientWidth,
|
|
964
|
+
height: document.documentElement.clientHeight
|
|
965
|
+
};
|
|
966
|
+
const physical = (side) => side === "start" ? rtl ? "right" : "left" : side === "end" ? rtl ? "left" : "right" : side;
|
|
967
|
+
const room = {
|
|
968
|
+
top: anchor.top - offset - padding,
|
|
969
|
+
bottom: viewport.height - anchor.bottom - offset - padding,
|
|
970
|
+
left: anchor.left - offset - padding,
|
|
971
|
+
right: viewport.width - anchor.right - offset - padding
|
|
972
|
+
};
|
|
973
|
+
floating.style.left = "0px";
|
|
974
|
+
floating.style.top = "0px";
|
|
975
|
+
const borders = {
|
|
976
|
+
width: floating.offsetWidth - floating.clientWidth,
|
|
977
|
+
height: floating.offsetHeight - floating.clientHeight
|
|
978
|
+
};
|
|
979
|
+
const natural = {
|
|
980
|
+
width: floating.scrollWidth + borders.width,
|
|
981
|
+
height: floating.scrollHeight + borders.height
|
|
982
|
+
};
|
|
983
|
+
const fits = (candidate) => {
|
|
984
|
+
const at = physical(candidate);
|
|
985
|
+
return at === "top" || at === "bottom" ? room[at] >= natural.height && natural.width <= viewport.width - 2 * padding : room[at] >= natural.width && natural.height <= viewport.height - 2 * padding;
|
|
986
|
+
};
|
|
987
|
+
const roomier = (a, b) => room[physical(b)] > room[physical(a)] ? b : a;
|
|
988
|
+
const side = candidates.find(fits) ?? roomier(preferred, OPPOSITE[preferred]);
|
|
989
|
+
const at = physical(side);
|
|
990
|
+
const vertical = at === "top" || at === "bottom";
|
|
991
|
+
const available = {
|
|
992
|
+
width: vertical ? viewport.width - 2 * padding : room[at],
|
|
993
|
+
height: vertical ? room[at] : viewport.height - 2 * padding
|
|
994
|
+
};
|
|
995
|
+
floating.style.setProperty(VARS[0], `${Math.max(0, available.width)}px`);
|
|
996
|
+
floating.style.setProperty(VARS[1], `${Math.max(0, available.height)}px`);
|
|
997
|
+
floating.style.setProperty(VARS[2], `${anchor.width}px`);
|
|
998
|
+
const size = {
|
|
999
|
+
width: floating.offsetWidth,
|
|
1000
|
+
height: floating.offsetHeight
|
|
1001
|
+
};
|
|
1002
|
+
let x;
|
|
1003
|
+
let y;
|
|
1004
|
+
if (vertical) {
|
|
1005
|
+
y = at === "bottom" ? anchor.bottom + offset : anchor.top - offset - size.height;
|
|
1006
|
+
const startEdge = rtl ? anchor.right - size.width : anchor.left;
|
|
1007
|
+
const endEdge = rtl ? anchor.left : anchor.right - size.width;
|
|
1008
|
+
x = (align === "center" ? anchor.left + (anchor.width - size.width) / 2 : align === "start" ? startEdge : endEdge) + (rtl ? -alignOffset : alignOffset);
|
|
1009
|
+
} else {
|
|
1010
|
+
x = at === "right" ? anchor.right + offset : anchor.left - offset - size.width;
|
|
1011
|
+
const height = anchor.bottom - anchor.top;
|
|
1012
|
+
y = (align === "center" ? anchor.top + (height - size.height) / 2 : align === "start" ? anchor.top : anchor.bottom - size.height) + alignOffset;
|
|
1013
|
+
}
|
|
1014
|
+
const fit = (value, length, extent, startAtEnd) => length > extent - 2 * padding ? startAtEnd ? extent - padding - length : padding : clamp(value, padding, extent - padding - length);
|
|
1015
|
+
x = fit(x, size.width, viewport.width, rtl);
|
|
1016
|
+
y = fit(y, size.height, viewport.height, false);
|
|
1017
|
+
floating.style.left = `${snap(x)}px`;
|
|
1018
|
+
floating.style.top = `${snap(y)}px`;
|
|
1019
|
+
floating.dataset.side = side;
|
|
1020
|
+
floating.dataset.align = align;
|
|
1021
|
+
if (anchor.bottom < 0 || anchor.top > viewport.height || anchor.right < 0 || anchor.left > viewport.width) floating.dataset.anchorHidden = "";
|
|
1022
|
+
else delete floating.dataset.anchorHidden;
|
|
1023
|
+
};
|
|
1024
|
+
document.addEventListener("scroll", onScroll, {
|
|
1025
|
+
capture: true,
|
|
1026
|
+
passive: true
|
|
1027
|
+
});
|
|
1028
|
+
view.addEventListener("resize", schedule, { passive: true });
|
|
1029
|
+
return {
|
|
1030
|
+
update: place,
|
|
1031
|
+
detach() {
|
|
1032
|
+
document.removeEventListener("scroll", onScroll, { capture: true });
|
|
1033
|
+
view.removeEventListener("resize", schedule);
|
|
1034
|
+
resize.disconnect();
|
|
1035
|
+
if (frame !== void 0) view.cancelAnimationFrame(frame);
|
|
1036
|
+
for (const name of [
|
|
1037
|
+
"left",
|
|
1038
|
+
"top",
|
|
1039
|
+
...VARS
|
|
1040
|
+
]) floating.style.removeProperty(name);
|
|
1041
|
+
for (const name of [
|
|
1042
|
+
"side",
|
|
1043
|
+
"align",
|
|
1044
|
+
"anchorHidden",
|
|
1045
|
+
"anchored"
|
|
1046
|
+
]) delete floating.dataset[name];
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
// src/menu/menu.ts
|
|
1051
|
+
/** M3E vertical menu color styles: surface-based, or tertiary-based for more emphasis. */
|
|
1052
|
+
const MENU_VARIANTS = ["standard", "vibrant"];
|
|
1053
|
+
/** Whether the menu's items can be selected: none, one at a time, or any number. */
|
|
1054
|
+
const MENU_SELECTIONS = [
|
|
1055
|
+
"none",
|
|
1056
|
+
"single",
|
|
1057
|
+
"multiple"
|
|
1058
|
+
];
|
|
1059
|
+
const menuDefaults = {
|
|
1060
|
+
open: false,
|
|
1061
|
+
variant: "standard",
|
|
1062
|
+
selection: "none",
|
|
1063
|
+
side: "bottom",
|
|
1064
|
+
align: "start",
|
|
1065
|
+
offset: 0,
|
|
1066
|
+
instant: false
|
|
1067
|
+
};
|
|
1068
|
+
const menuSkeleton = {
|
|
1069
|
+
part: "root",
|
|
1070
|
+
tag: "div",
|
|
1071
|
+
content: true
|
|
1072
|
+
};
|
|
1073
|
+
const valuesOf = (selected) => selected === void 0 ? [] : Array.isArray(selected) ? selected : [selected];
|
|
1074
|
+
function createMenu(options) {
|
|
1075
|
+
const props = () => ({
|
|
1076
|
+
...menuDefaults,
|
|
1077
|
+
...omitUndefined(options.props())
|
|
1078
|
+
});
|
|
1079
|
+
const isSelected = (value) => {
|
|
1080
|
+
const p = props();
|
|
1081
|
+
return p.selection !== "none" && valuesOf(p.selected).includes(value);
|
|
1082
|
+
};
|
|
1083
|
+
return {
|
|
1084
|
+
parts: { root() {
|
|
1085
|
+
const p = props();
|
|
1086
|
+
return {
|
|
1087
|
+
class: "rio-menu",
|
|
1088
|
+
role: "menu",
|
|
1089
|
+
"aria-label": p.label,
|
|
1090
|
+
"data-variant": p.variant,
|
|
1091
|
+
"data-instant": p.instant ? "" : void 0
|
|
1092
|
+
};
|
|
1093
|
+
} },
|
|
1094
|
+
selection: () => props().selection,
|
|
1095
|
+
isSelected,
|
|
1096
|
+
choose(value) {
|
|
1097
|
+
const p = props();
|
|
1098
|
+
if (p.selection !== "none" && value !== void 0) {
|
|
1099
|
+
if (p.selection === "single") {
|
|
1100
|
+
if (!isSelected(value)) options.onChange(value);
|
|
1101
|
+
} else {
|
|
1102
|
+
const current = valuesOf(p.selected);
|
|
1103
|
+
options.onChange(isSelected(value) ? current.filter((other) => other !== value) : [...current, value]);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
options.onOpenChange(false);
|
|
1108
|
+
},
|
|
1109
|
+
close: () => options.onOpenChange(false),
|
|
1110
|
+
variant: () => props().variant,
|
|
1111
|
+
instant: () => props().instant
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
const menuItemDefaults = { disabled: false };
|
|
1115
|
+
const menuItemSkeleton = {
|
|
1116
|
+
part: "root",
|
|
1117
|
+
tag: "button",
|
|
1118
|
+
children: [
|
|
1119
|
+
{
|
|
1120
|
+
part: "touch",
|
|
1121
|
+
tag: "span"
|
|
1122
|
+
},
|
|
1123
|
+
{
|
|
1124
|
+
part: "icon",
|
|
1125
|
+
tag: "span",
|
|
1126
|
+
when: "showsIcon",
|
|
1127
|
+
content: true
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
part: "text",
|
|
1131
|
+
tag: "span",
|
|
1132
|
+
children: [{
|
|
1133
|
+
part: "label",
|
|
1134
|
+
tag: "span",
|
|
1135
|
+
content: true
|
|
1136
|
+
}, {
|
|
1137
|
+
part: "supportingText",
|
|
1138
|
+
tag: "span",
|
|
1139
|
+
when: "showsSupportingText",
|
|
1140
|
+
content: true
|
|
1141
|
+
}]
|
|
1142
|
+
},
|
|
1143
|
+
{
|
|
1144
|
+
part: "trailingText",
|
|
1145
|
+
tag: "span",
|
|
1146
|
+
when: "showsTrailingText",
|
|
1147
|
+
content: true
|
|
1148
|
+
},
|
|
1149
|
+
{
|
|
1150
|
+
part: "trailingIcon",
|
|
1151
|
+
tag: "span",
|
|
1152
|
+
when: "showsTrailingIcon",
|
|
1153
|
+
content: true
|
|
1154
|
+
}
|
|
1155
|
+
]
|
|
1156
|
+
};
|
|
1157
|
+
const NO_CONTENT$1 = {
|
|
1158
|
+
icon: false,
|
|
1159
|
+
supportingText: false,
|
|
1160
|
+
trailingText: false,
|
|
1161
|
+
trailingIcon: false
|
|
1162
|
+
};
|
|
1163
|
+
function createMenuItem(options) {
|
|
1164
|
+
const props = () => ({
|
|
1165
|
+
...menuItemDefaults,
|
|
1166
|
+
...omitUndefined(options.props())
|
|
1167
|
+
});
|
|
1168
|
+
const content = () => options.content?.() ?? NO_CONTENT$1;
|
|
1169
|
+
const kind = () => {
|
|
1170
|
+
if (options.submenu) return void 0;
|
|
1171
|
+
const selection = options.menu?.()?.selection() ?? "none";
|
|
1172
|
+
if (selection === "none" || props().value === void 0) return void 0;
|
|
1173
|
+
return selection === "single" ? "radio" : "checkbox";
|
|
1174
|
+
};
|
|
1175
|
+
const selected = () => {
|
|
1176
|
+
const value = props().value;
|
|
1177
|
+
return kind() !== void 0 && !!options.menu?.()?.isSelected(value);
|
|
1178
|
+
};
|
|
1179
|
+
return {
|
|
1180
|
+
parts: {
|
|
1181
|
+
root() {
|
|
1182
|
+
const p = props();
|
|
1183
|
+
const k = kind();
|
|
1184
|
+
return {
|
|
1185
|
+
class: "rio-menu-item rio-state-layer rio-focus-ring",
|
|
1186
|
+
type: "button",
|
|
1187
|
+
role: k ? `menuitem${k}` : "menuitem",
|
|
1188
|
+
tabindex: "-1",
|
|
1189
|
+
"aria-checked": k ? String(selected()) : void 0,
|
|
1190
|
+
"aria-haspopup": options.submenu ? "menu" : void 0,
|
|
1191
|
+
"aria-expanded": options.submenu ? String(options.submenu.open()) : void 0,
|
|
1192
|
+
"aria-disabled": p.disabled ? "true" : void 0,
|
|
1193
|
+
"data-selected": k ? String(selected()) : void 0,
|
|
1194
|
+
"data-focus-ring": "inner"
|
|
1195
|
+
};
|
|
1196
|
+
},
|
|
1197
|
+
touch: () => ({
|
|
1198
|
+
class: "rio-menu-item__touch",
|
|
1199
|
+
"aria-hidden": "true"
|
|
1200
|
+
}),
|
|
1201
|
+
icon: () => ({
|
|
1202
|
+
class: "rio-menu-item__icon",
|
|
1203
|
+
"aria-hidden": "true"
|
|
1204
|
+
}),
|
|
1205
|
+
text: () => ({ class: "rio-menu-item__text" }),
|
|
1206
|
+
label: () => ({ class: "rio-menu-item__label" }),
|
|
1207
|
+
supportingText: () => ({ class: "rio-menu-item__supporting-text" }),
|
|
1208
|
+
trailingText: () => ({ class: "rio-menu-item__trailing-text" }),
|
|
1209
|
+
trailingIcon: () => ({
|
|
1210
|
+
class: "rio-menu-item__trailing-icon",
|
|
1211
|
+
"aria-hidden": "true"
|
|
1212
|
+
})
|
|
1213
|
+
},
|
|
1214
|
+
showsIcon: () => content().icon,
|
|
1215
|
+
showsSupportingText: () => content().supportingText,
|
|
1216
|
+
showsTrailingText: () => content().trailingText,
|
|
1217
|
+
showsTrailingIcon: () => content().trailingIcon,
|
|
1218
|
+
selected,
|
|
1219
|
+
handleClick(event) {
|
|
1220
|
+
if (props().disabled) {
|
|
1221
|
+
event.preventDefault();
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
options.onClick(event);
|
|
1225
|
+
if (options.submenu) options.submenu.onOpen();
|
|
1226
|
+
else options.menu?.()?.choose(props().value);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* A submenu and the item that opens it. The two render side by side in the menu, the item where it
|
|
1232
|
+
* sits and the submenu after it; the submenu shows in the top layer next to the item.
|
|
1233
|
+
*/
|
|
1234
|
+
function createSubMenu(options) {
|
|
1235
|
+
const menu = createMenu({
|
|
1236
|
+
props: () => {
|
|
1237
|
+
const p = options.props();
|
|
1238
|
+
const parent = options.menu?.();
|
|
1239
|
+
return {
|
|
1240
|
+
open: options.open(),
|
|
1241
|
+
variant: parent?.variant(),
|
|
1242
|
+
instant: parent?.instant(),
|
|
1243
|
+
selection: p.selection,
|
|
1244
|
+
selected: p.selected,
|
|
1245
|
+
label: p.label
|
|
1246
|
+
};
|
|
1247
|
+
},
|
|
1248
|
+
onChange: options.onChange,
|
|
1249
|
+
onOpenChange: (open) => {
|
|
1250
|
+
const parent = options.menu?.();
|
|
1251
|
+
if (open) options.onOpenChange(true);
|
|
1252
|
+
else if (parent) parent.close();
|
|
1253
|
+
else options.onOpenChange(false);
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1256
|
+
return {
|
|
1257
|
+
item: createMenuItem({
|
|
1258
|
+
props: () => ({ disabled: options.props().disabled }),
|
|
1259
|
+
content: options.content,
|
|
1260
|
+
onClick: () => {},
|
|
1261
|
+
submenu: {
|
|
1262
|
+
open: options.open,
|
|
1263
|
+
onOpen: () => options.onOpenChange(true)
|
|
1264
|
+
}
|
|
1265
|
+
}),
|
|
1266
|
+
menu
|
|
1267
|
+
};
|
|
1268
|
+
}
|
|
1269
|
+
const menuGroupSkeleton = {
|
|
1270
|
+
part: "root",
|
|
1271
|
+
tag: "div",
|
|
1272
|
+
children: [{
|
|
1273
|
+
part: "label",
|
|
1274
|
+
tag: "div",
|
|
1275
|
+
when: "showsLabel",
|
|
1276
|
+
content: true
|
|
1277
|
+
}, {
|
|
1278
|
+
part: "items",
|
|
1279
|
+
tag: "div",
|
|
1280
|
+
content: true
|
|
1281
|
+
}]
|
|
1282
|
+
};
|
|
1283
|
+
function createMenuGroup(options) {
|
|
1284
|
+
return {
|
|
1285
|
+
parts: {
|
|
1286
|
+
root: () => ({
|
|
1287
|
+
class: "rio-menu-group",
|
|
1288
|
+
role: "group",
|
|
1289
|
+
"aria-label": options.props().label
|
|
1290
|
+
}),
|
|
1291
|
+
label: () => ({
|
|
1292
|
+
class: "rio-menu-group__label",
|
|
1293
|
+
"aria-hidden": "true"
|
|
1294
|
+
}),
|
|
1295
|
+
items: () => ({ class: "rio-menu-group__items" })
|
|
1296
|
+
},
|
|
1297
|
+
showsLabel: () => !!options.props().label
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
const menuDividerSkeleton = {
|
|
1301
|
+
part: "root",
|
|
1302
|
+
tag: "div"
|
|
1303
|
+
};
|
|
1304
|
+
function createMenuDivider() {
|
|
1305
|
+
return { parts: { root: () => ({
|
|
1306
|
+
class: "rio-menu-divider",
|
|
1307
|
+
role: "separator"
|
|
1308
|
+
}) } };
|
|
1309
|
+
}
|
|
1310
|
+
// src/menu/interaction.ts
|
|
1311
|
+
/** Where to try next when the preferred side has no room: across, then to the sides. */
|
|
1312
|
+
const FALLBACKS = {
|
|
1313
|
+
bottom: [
|
|
1314
|
+
"top",
|
|
1315
|
+
"end",
|
|
1316
|
+
"start"
|
|
1317
|
+
],
|
|
1318
|
+
top: [
|
|
1319
|
+
"bottom",
|
|
1320
|
+
"end",
|
|
1321
|
+
"start"
|
|
1322
|
+
],
|
|
1323
|
+
end: [
|
|
1324
|
+
"start",
|
|
1325
|
+
"bottom",
|
|
1326
|
+
"top"
|
|
1327
|
+
],
|
|
1328
|
+
start: [
|
|
1329
|
+
"end",
|
|
1330
|
+
"bottom",
|
|
1331
|
+
"top"
|
|
1332
|
+
]
|
|
1333
|
+
};
|
|
1334
|
+
/** How long typed letters add up to one search. */
|
|
1335
|
+
const TYPEAHEAD_MS = 500;
|
|
1336
|
+
/** How long the pointer rests on an item before its submenu opens */
|
|
1337
|
+
const HOVER_OPEN_MS = 100;
|
|
1338
|
+
/** How long the pointer may take to reach an open submenu across other items */
|
|
1339
|
+
const GRACE_MS = 300;
|
|
1340
|
+
/**
|
|
1341
|
+
* A submenu goes beside its item, 4px off the menu around it (the item is inset 4px), with its
|
|
1342
|
+
* first item lined up with the item (its own are inset 4px from its top).
|
|
1343
|
+
*/
|
|
1344
|
+
const SUBMENU_PLACEMENT = {
|
|
1345
|
+
side: "end",
|
|
1346
|
+
align: "start",
|
|
1347
|
+
offset: 8,
|
|
1348
|
+
alignOffset: -4
|
|
1349
|
+
};
|
|
1350
|
+
const isElement = (anchor) => !!anchor && typeof anchor.focus === "function";
|
|
1351
|
+
/** Points are compared by value: an inline `{ x, y }` is a new object on every render. */
|
|
1352
|
+
const samePoint = (a, b) => a.x === b.x && a.y === b.y && a.element === b.element;
|
|
1353
|
+
/** Whether a point is inside a polygon, by casting a ray to the right and counting crossings */
|
|
1354
|
+
const isInside = ({ x, y }, polygon) => {
|
|
1355
|
+
let inside = false;
|
|
1356
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
1357
|
+
const a = polygon[i];
|
|
1358
|
+
const b = polygon[j];
|
|
1359
|
+
if (a.y > y !== b.y > y && x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x) inside = !inside;
|
|
1360
|
+
}
|
|
1361
|
+
return inside;
|
|
1362
|
+
};
|
|
1363
|
+
const menuStates = /* @__PURE__ */ new WeakMap();
|
|
1364
|
+
const stateOf = (menu) => {
|
|
1365
|
+
let state = menuStates.get(menu);
|
|
1366
|
+
if (!state) {
|
|
1367
|
+
state = {
|
|
1368
|
+
openers: /* @__PURE__ */ new Map(),
|
|
1369
|
+
typing: () => false
|
|
1370
|
+
};
|
|
1371
|
+
menuStates.set(menu, state);
|
|
1372
|
+
}
|
|
1373
|
+
return state;
|
|
1374
|
+
};
|
|
1375
|
+
const endGrace = (state) => {
|
|
1376
|
+
if (state.grace) clearTimeout(state.grace.timer);
|
|
1377
|
+
state.grace = void 0;
|
|
1378
|
+
};
|
|
1379
|
+
const menuAround = (element) => element.parentElement?.closest(".rio-menu");
|
|
1380
|
+
const topOf = (menu) => {
|
|
1381
|
+
let top = menu;
|
|
1382
|
+
for (let up = menuAround(menu); up; up = menuAround(up)) top = up;
|
|
1383
|
+
return top;
|
|
1384
|
+
};
|
|
1385
|
+
const activeMenus = /* @__PURE__ */ new WeakMap();
|
|
1386
|
+
const shapeTree = (menu, active) => {
|
|
1387
|
+
const top = topOf(menu);
|
|
1388
|
+
if (active) activeMenus.set(top, active);
|
|
1389
|
+
let current = activeMenus.get(top) ?? top;
|
|
1390
|
+
while (current && !current.matches(":popover-open")) current = menuAround(current);
|
|
1391
|
+
const open = [top, ...top.querySelectorAll(".rio-menu")].filter((each) => each.matches(":popover-open"));
|
|
1392
|
+
for (const each of open) each.toggleAttribute("data-inactive", open.length > 1 && each !== current);
|
|
1393
|
+
};
|
|
1394
|
+
function attachMenu(root, options) {
|
|
1395
|
+
return setup(root, options);
|
|
1396
|
+
}
|
|
1397
|
+
function setup(root, options, nested) {
|
|
1398
|
+
const document = root.ownerDocument;
|
|
1399
|
+
const view = document.defaultView;
|
|
1400
|
+
let reanchoring = false;
|
|
1401
|
+
const overlay = attachOverlay(root, {
|
|
1402
|
+
mode: "auto",
|
|
1403
|
+
onClose: (reason) => {
|
|
1404
|
+
if (!(reanchoring && reason === "dismiss")) options.onClose();
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
const items = () => [...root.querySelectorAll("[role^=\"menuitem\"]")].filter((item) => item.closest("[role=\"menu\"]") === root);
|
|
1408
|
+
const focused = () => items().indexOf(document.activeElement);
|
|
1409
|
+
const focusAt = (index) => {
|
|
1410
|
+
const all = items();
|
|
1411
|
+
if (all.length) all[(index + all.length) % all.length].focus();
|
|
1412
|
+
};
|
|
1413
|
+
let anchored;
|
|
1414
|
+
let placement = "";
|
|
1415
|
+
/** What had the focus when the menu opened */
|
|
1416
|
+
let opener = null;
|
|
1417
|
+
let pinned;
|
|
1418
|
+
const pin = (fresh) => {
|
|
1419
|
+
const anchor = options.anchor();
|
|
1420
|
+
if (!anchor || isElement(anchor) || !anchor.element) {
|
|
1421
|
+
pinned = void 0;
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
if (!fresh && pinned && samePoint(pinned.point, anchor)) return;
|
|
1425
|
+
const rect = anchor.element.getBoundingClientRect();
|
|
1426
|
+
pinned = {
|
|
1427
|
+
point: { ...anchor },
|
|
1428
|
+
x: anchor.x - rect.left,
|
|
1429
|
+
y: anchor.y - rect.top
|
|
1430
|
+
};
|
|
1431
|
+
};
|
|
1432
|
+
/** The anchor for placing: for a point on an element, where that spot is now */
|
|
1433
|
+
const anchorNow = () => {
|
|
1434
|
+
const anchor = options.anchor();
|
|
1435
|
+
if (!anchor || isElement(anchor) || !anchor.element) return anchor;
|
|
1436
|
+
if (!anchor.element.isConnected) return void 0;
|
|
1437
|
+
if (!pinned || !samePoint(pinned.point, anchor)) pin(false);
|
|
1438
|
+
const rect = anchor.element.getBoundingClientRect();
|
|
1439
|
+
return {
|
|
1440
|
+
x: rect.left + pinned.x,
|
|
1441
|
+
y: rect.top + pinned.y
|
|
1442
|
+
};
|
|
1443
|
+
};
|
|
1444
|
+
const place = () => {
|
|
1445
|
+
const p = {
|
|
1446
|
+
...menuDefaults,
|
|
1447
|
+
alignOffset: 0,
|
|
1448
|
+
...omitUndefined(options.props())
|
|
1449
|
+
};
|
|
1450
|
+
const key = `${p.side} ${p.align} ${p.offset} ${p.alignOffset}`;
|
|
1451
|
+
if (anchored && key === placement) return anchored;
|
|
1452
|
+
anchored?.detach();
|
|
1453
|
+
placement = key;
|
|
1454
|
+
anchored = attachAnchoredPosition(root, {
|
|
1455
|
+
anchor: anchorNow,
|
|
1456
|
+
side: p.side,
|
|
1457
|
+
align: p.align,
|
|
1458
|
+
offset: p.offset,
|
|
1459
|
+
alignOffset: p.alignOffset,
|
|
1460
|
+
fallbacks: FALLBACKS[p.side]
|
|
1461
|
+
});
|
|
1462
|
+
return anchored;
|
|
1463
|
+
};
|
|
1464
|
+
const returnTarget = () => {
|
|
1465
|
+
const anchor = options.anchor();
|
|
1466
|
+
if (isElement(anchor) && anchor.isConnected) return anchor;
|
|
1467
|
+
return opener?.isConnected && !root.contains(opener) ? opener : null;
|
|
1468
|
+
};
|
|
1469
|
+
let typed = "";
|
|
1470
|
+
let typedAt = 0;
|
|
1471
|
+
const typeahead = (key) => {
|
|
1472
|
+
const now = performance.now();
|
|
1473
|
+
typed = now - typedAt > TYPEAHEAD_MS ? key : typed + key;
|
|
1474
|
+
typedAt = now;
|
|
1475
|
+
const all = items();
|
|
1476
|
+
const labelOf = (item) => (item.querySelector(".rio-menu-item__label")?.textContent ?? item.textContent ?? "").trim().toLowerCase();
|
|
1477
|
+
const search = [...typed].every((letter) => letter === typed[0]) ? typed[0] : typed;
|
|
1478
|
+
const start = focused() + (search.length === 1 ? 1 : 0);
|
|
1479
|
+
for (let step = 0; step < all.length; step++) {
|
|
1480
|
+
const item = all[(Math.max(start, 0) + step) % all.length];
|
|
1481
|
+
if (labelOf(item).startsWith(search)) {
|
|
1482
|
+
item.focus();
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
};
|
|
1487
|
+
const state = stateOf(root);
|
|
1488
|
+
state.typing = () => typed !== "" && performance.now() - typedAt <= TYPEAHEAD_MS;
|
|
1489
|
+
const onKeydown = (event) => {
|
|
1490
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
1491
|
+
if (event.key === "Tab") {
|
|
1492
|
+
if (nested) return;
|
|
1493
|
+
options.onClose();
|
|
1494
|
+
returnTarget()?.focus();
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
if (event.target.closest("[role=\"menu\"]") !== root) return;
|
|
1498
|
+
if (activeMenus.get(topOf(root)) !== root) shapeTree(root, root);
|
|
1499
|
+
const handled = () => event.preventDefault();
|
|
1500
|
+
if (nested && event.key === (isRtl() ? "ArrowRight" : "ArrowLeft")) {
|
|
1501
|
+
handled();
|
|
1502
|
+
options.onClose();
|
|
1503
|
+
return;
|
|
1504
|
+
}
|
|
1505
|
+
switch (event.key) {
|
|
1506
|
+
case "ArrowDown":
|
|
1507
|
+
handled();
|
|
1508
|
+
focusAt(focused() + 1);
|
|
1509
|
+
return;
|
|
1510
|
+
case "ArrowUp":
|
|
1511
|
+
handled();
|
|
1512
|
+
focusAt(focused() < 0 ? -1 : focused() - 1);
|
|
1513
|
+
return;
|
|
1514
|
+
case "Home":
|
|
1515
|
+
handled();
|
|
1516
|
+
focusAt(0);
|
|
1517
|
+
return;
|
|
1518
|
+
case "End":
|
|
1519
|
+
handled();
|
|
1520
|
+
focusAt(-1);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (event.key.length === 1 && !(event.key === " " && !typed)) {
|
|
1524
|
+
handled();
|
|
1525
|
+
typeahead(event.key.toLowerCase());
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1528
|
+
root.addEventListener("keydown", onKeydown);
|
|
1529
|
+
const isRtl = () => view.getComputedStyle(root).direction === "rtl";
|
|
1530
|
+
let last;
|
|
1531
|
+
/** Which way the pointer last moved, across and down */
|
|
1532
|
+
const heading = {};
|
|
1533
|
+
const onPointerMove = (event) => {
|
|
1534
|
+
if (event.pointerType !== "mouse") return;
|
|
1535
|
+
const target = event.target;
|
|
1536
|
+
if (target.closest("[role=\"menu\"]") !== root) return;
|
|
1537
|
+
const point = {
|
|
1538
|
+
x: event.clientX,
|
|
1539
|
+
y: event.clientY
|
|
1540
|
+
};
|
|
1541
|
+
if (last && point.x !== last.x) heading.x = point.x > last.x ? "right" : "left";
|
|
1542
|
+
if (last && point.y !== last.y) heading.y = point.y > last.y ? "down" : "up";
|
|
1543
|
+
last = point;
|
|
1544
|
+
if (activeMenus.get(topOf(root)) !== root) shapeTree(root, root);
|
|
1545
|
+
const { grace } = state;
|
|
1546
|
+
if (grace) {
|
|
1547
|
+
if ((grace.toward === "left" || grace.toward === "right" ? heading.x : heading.y) === grace.toward && isInside(point, grace.area)) return;
|
|
1548
|
+
}
|
|
1549
|
+
endGrace(state);
|
|
1550
|
+
const item = target.closest("[role^=\"menuitem\"]");
|
|
1551
|
+
if (!item) return;
|
|
1552
|
+
const focusedMenu = document.activeElement?.closest("[role=\"menu\"]");
|
|
1553
|
+
if (focusedMenu && focusedMenu !== root && topOf(focusedMenu) === topOf(root)) item.focus({ preventScroll: true });
|
|
1554
|
+
for (const [opener, control] of state.openers) if (opener !== item) control.cancel();
|
|
1555
|
+
state.openers.get(item)?.hover();
|
|
1556
|
+
};
|
|
1557
|
+
root.addEventListener("pointermove", onPointerMove);
|
|
1558
|
+
const onFocusIn = (event) => {
|
|
1559
|
+
if (event.target.closest("[role=\"menu\"]") === root) shapeTree(root, root);
|
|
1560
|
+
};
|
|
1561
|
+
root.addEventListener("focusin", onFocusIn);
|
|
1562
|
+
const focusFirst = () => {
|
|
1563
|
+
const all = items();
|
|
1564
|
+
(all.find((item) => item.getAttribute("aria-checked") === "true") ?? all[0])?.focus();
|
|
1565
|
+
};
|
|
1566
|
+
/** What the menu last opened at: its anchor element or point, or null for none */
|
|
1567
|
+
let shownAt = null;
|
|
1568
|
+
const isNewAnchor = () => {
|
|
1569
|
+
const anchor = options.anchor();
|
|
1570
|
+
if (!anchor || !shownAt) return false;
|
|
1571
|
+
if (isElement(anchor) || isElement(shownAt)) return anchor !== shownAt;
|
|
1572
|
+
return !samePoint(anchor, shownAt);
|
|
1573
|
+
};
|
|
1574
|
+
const remember = () => {
|
|
1575
|
+
const anchor = options.anchor();
|
|
1576
|
+
shownAt = !anchor ? null : isElement(anchor) ? anchor : { ...anchor };
|
|
1577
|
+
};
|
|
1578
|
+
/**
|
|
1579
|
+
* Opens the menu. `afresh` opens it as if it had been closed: a menu still open, or still fading
|
|
1580
|
+
* out after a click outside, would only turn its closing back where it is, so that is cut short
|
|
1581
|
+
* and the opening motion plays from the start. By default, at a new anchor.
|
|
1582
|
+
*/
|
|
1583
|
+
const show = (afresh = isNewAnchor()) => {
|
|
1584
|
+
const active = document.activeElement;
|
|
1585
|
+
if (!root.contains(active)) opener = active instanceof document.defaultView.HTMLElement ? active : null;
|
|
1586
|
+
if (afresh) {
|
|
1587
|
+
root.style.setProperty("transition", "none");
|
|
1588
|
+
if (root.matches(":popover-open")) overlay.hide();
|
|
1589
|
+
root.offsetWidth;
|
|
1590
|
+
root.style.removeProperty("transition");
|
|
1591
|
+
}
|
|
1592
|
+
const anchored = place();
|
|
1593
|
+
const anchor = options.anchor();
|
|
1594
|
+
remember();
|
|
1595
|
+
const on = anchor && !isElement(anchor) ? anchor.element : void 0;
|
|
1596
|
+
overlay.show(isElement(anchor) ? anchor : on instanceof document.defaultView.HTMLElement ? on : void 0);
|
|
1597
|
+
anchored.update();
|
|
1598
|
+
const focusing = !nested || nested.focusOnShow();
|
|
1599
|
+
shapeTree(root, focusing ? root : void 0);
|
|
1600
|
+
if (focusing) focusFirst();
|
|
1601
|
+
};
|
|
1602
|
+
let pressing = false;
|
|
1603
|
+
let afterPress;
|
|
1604
|
+
let pressTimer;
|
|
1605
|
+
const release = () => {
|
|
1606
|
+
if (!pressing) return;
|
|
1607
|
+
pressing = false;
|
|
1608
|
+
clearTimeout(pressTimer);
|
|
1609
|
+
const run = afterPress;
|
|
1610
|
+
afterPress = void 0;
|
|
1611
|
+
if (run) setTimeout(run);
|
|
1612
|
+
};
|
|
1613
|
+
const onContextMenu = (event) => {
|
|
1614
|
+
if (event.buttons === 0) return;
|
|
1615
|
+
pressing = true;
|
|
1616
|
+
clearTimeout(pressTimer);
|
|
1617
|
+
pressTimer = setTimeout(release, 1e3);
|
|
1618
|
+
};
|
|
1619
|
+
const onContextMenuDone = (event) => {
|
|
1620
|
+
setTimeout(() => {
|
|
1621
|
+
if (!event.defaultPrevented) release();
|
|
1622
|
+
});
|
|
1623
|
+
};
|
|
1624
|
+
if (!nested) {
|
|
1625
|
+
document.addEventListener("contextmenu", onContextMenu, { capture: true });
|
|
1626
|
+
view.addEventListener("contextmenu", onContextMenuDone);
|
|
1627
|
+
for (const type of ["pointerup", "pointercancel"]) document.addEventListener(type, release, { capture: true });
|
|
1628
|
+
view.addEventListener("blur", release);
|
|
1629
|
+
}
|
|
1630
|
+
let wanted = false;
|
|
1631
|
+
return {
|
|
1632
|
+
sync(open) {
|
|
1633
|
+
wanted = open;
|
|
1634
|
+
const shown = root.matches(":popover-open");
|
|
1635
|
+
if (open && !shown) {
|
|
1636
|
+
pin(true);
|
|
1637
|
+
if (pressing) afterPress = () => wanted && !root.matches(":popover-open") && show();
|
|
1638
|
+
else show();
|
|
1639
|
+
} else if (!open) {
|
|
1640
|
+
const hadFocus = root.contains(document.activeElement);
|
|
1641
|
+
afterPress = void 0;
|
|
1642
|
+
reanchoring = false;
|
|
1643
|
+
overlay.hide();
|
|
1644
|
+
typed = "";
|
|
1645
|
+
shapeTree(root);
|
|
1646
|
+
if (hadFocus) returnTarget()?.focus();
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
update() {
|
|
1650
|
+
pin(false);
|
|
1651
|
+
if (!root.matches(":popover-open")) {
|
|
1652
|
+
if (wanted && !pressing && isNewAnchor()) show(true);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1655
|
+
if (pressing) {
|
|
1656
|
+
reanchoring = true;
|
|
1657
|
+
afterPress = () => {
|
|
1658
|
+
reanchoring = false;
|
|
1659
|
+
if (wanted) show(true);
|
|
1660
|
+
};
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
if (isNewAnchor()) show(true);
|
|
1664
|
+
else {
|
|
1665
|
+
remember();
|
|
1666
|
+
place().update();
|
|
1667
|
+
}
|
|
1668
|
+
},
|
|
1669
|
+
focusFirst,
|
|
1670
|
+
detach() {
|
|
1671
|
+
root.removeEventListener("keydown", onKeydown);
|
|
1672
|
+
root.removeEventListener("pointermove", onPointerMove);
|
|
1673
|
+
root.removeEventListener("focusin", onFocusIn);
|
|
1674
|
+
endGrace(state);
|
|
1675
|
+
document.removeEventListener("contextmenu", onContextMenu, { capture: true });
|
|
1676
|
+
view.removeEventListener("contextmenu", onContextMenuDone);
|
|
1677
|
+
for (const type of ["pointerup", "pointercancel"]) document.removeEventListener(type, release, { capture: true });
|
|
1678
|
+
view.removeEventListener("blur", release);
|
|
1679
|
+
clearTimeout(pressTimer);
|
|
1680
|
+
pressing = false;
|
|
1681
|
+
afterPress = void 0;
|
|
1682
|
+
overlay.detach();
|
|
1683
|
+
anchored?.detach();
|
|
1684
|
+
}
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
let submenuIds = 0;
|
|
1688
|
+
/**
|
|
1689
|
+
* A submenu's behavior in the DOM: a menu (see `attachMenu`) that `item` opens, shown beside it.
|
|
1690
|
+
* `item` sits in a menu that `attachMenu` or this function runs, and `root` follows it.
|
|
1691
|
+
*/
|
|
1692
|
+
function attachSubMenu(item, root, options) {
|
|
1693
|
+
const view = item.ownerDocument.defaultView;
|
|
1694
|
+
const parent = item.closest("[role=\"menu\"]");
|
|
1695
|
+
let open = false;
|
|
1696
|
+
/** Whether the opening asked for moves the focus in: from the keyboard, not the pointer */
|
|
1697
|
+
let focusIn = false;
|
|
1698
|
+
const request = (next, focus = false) => {
|
|
1699
|
+
focusIn = focus;
|
|
1700
|
+
options.onOpenChange(next);
|
|
1701
|
+
};
|
|
1702
|
+
const dom = setup(root, {
|
|
1703
|
+
anchor: () => item,
|
|
1704
|
+
props: () => SUBMENU_PLACEMENT,
|
|
1705
|
+
onClose: () => request(false)
|
|
1706
|
+
}, { focusOnShow: () => focusIn });
|
|
1707
|
+
const disabled = () => item.getAttribute("aria-disabled") === "true";
|
|
1708
|
+
root.id ||= `rio-submenu-${++submenuIds}`;
|
|
1709
|
+
item.setAttribute("popovertarget", root.id);
|
|
1710
|
+
item.setAttribute("popovertargetaction", "show");
|
|
1711
|
+
const onClickFirst = (event) => {
|
|
1712
|
+
focusIn = event.detail === 0;
|
|
1713
|
+
};
|
|
1714
|
+
const onClick = (event) => event.preventDefault();
|
|
1715
|
+
item.addEventListener("click", onClickFirst, { capture: true });
|
|
1716
|
+
item.addEventListener("click", onClick);
|
|
1717
|
+
let timer;
|
|
1718
|
+
const stopTimer = () => {
|
|
1719
|
+
clearTimeout(timer);
|
|
1720
|
+
timer = void 0;
|
|
1721
|
+
};
|
|
1722
|
+
const opener = {
|
|
1723
|
+
hover() {
|
|
1724
|
+
if (open || disabled() || timer !== void 0) return;
|
|
1725
|
+
timer = setTimeout(() => {
|
|
1726
|
+
timer = void 0;
|
|
1727
|
+
if (!parent || parent.matches(":popover-open")) request(true);
|
|
1728
|
+
}, HOVER_OPEN_MS);
|
|
1729
|
+
},
|
|
1730
|
+
cancel() {
|
|
1731
|
+
stopTimer();
|
|
1732
|
+
if (open) request(false);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
if (parent) stateOf(parent).openers.set(item, opener);
|
|
1736
|
+
const onPointerLeave = (event) => {
|
|
1737
|
+
if (event.pointerType !== "mouse") return;
|
|
1738
|
+
stopTimer();
|
|
1739
|
+
if (!open || !parent) return;
|
|
1740
|
+
const state = stateOf(parent);
|
|
1741
|
+
const box = root.getBoundingClientRect();
|
|
1742
|
+
const from = item.getBoundingClientRect();
|
|
1743
|
+
const toward = box.top >= from.bottom - 1 ? "down" : box.bottom <= from.top + 1 ? "up" : box.left >= from.left ? "right" : "left";
|
|
1744
|
+
const across = toward === "left" || toward === "right";
|
|
1745
|
+
const bleed = toward === "right" || toward === "down" ? -5 : 5;
|
|
1746
|
+
const exit = across ? {
|
|
1747
|
+
x: event.clientX + bleed,
|
|
1748
|
+
y: event.clientY
|
|
1749
|
+
} : {
|
|
1750
|
+
x: event.clientX,
|
|
1751
|
+
y: event.clientY + bleed
|
|
1752
|
+
};
|
|
1753
|
+
const near = {
|
|
1754
|
+
right: box.left,
|
|
1755
|
+
left: box.right,
|
|
1756
|
+
down: box.top,
|
|
1757
|
+
up: box.bottom
|
|
1758
|
+
}[toward];
|
|
1759
|
+
const far = {
|
|
1760
|
+
right: box.right,
|
|
1761
|
+
left: box.left,
|
|
1762
|
+
down: box.bottom,
|
|
1763
|
+
up: box.top
|
|
1764
|
+
}[toward];
|
|
1765
|
+
endGrace(state);
|
|
1766
|
+
state.grace = {
|
|
1767
|
+
area: across ? [
|
|
1768
|
+
exit,
|
|
1769
|
+
{
|
|
1770
|
+
x: near,
|
|
1771
|
+
y: box.top
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
x: far,
|
|
1775
|
+
y: box.top
|
|
1776
|
+
},
|
|
1777
|
+
{
|
|
1778
|
+
x: far,
|
|
1779
|
+
y: box.bottom
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
x: near,
|
|
1783
|
+
y: box.bottom
|
|
1784
|
+
}
|
|
1785
|
+
] : [
|
|
1786
|
+
exit,
|
|
1787
|
+
{
|
|
1788
|
+
x: box.left,
|
|
1789
|
+
y: near
|
|
1790
|
+
},
|
|
1791
|
+
{
|
|
1792
|
+
x: box.left,
|
|
1793
|
+
y: far
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
x: box.right,
|
|
1797
|
+
y: far
|
|
1798
|
+
},
|
|
1799
|
+
{
|
|
1800
|
+
x: box.right,
|
|
1801
|
+
y: near
|
|
1802
|
+
}
|
|
1803
|
+
],
|
|
1804
|
+
toward,
|
|
1805
|
+
timer: setTimeout(() => endGrace(state), GRACE_MS)
|
|
1806
|
+
};
|
|
1807
|
+
};
|
|
1808
|
+
item.addEventListener("pointerleave", onPointerLeave);
|
|
1809
|
+
const onKeydown = (event) => {
|
|
1810
|
+
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
1811
|
+
const forward = view.getComputedStyle(item).direction === "rtl" ? "ArrowLeft" : "ArrowRight";
|
|
1812
|
+
const space = event.key === " " && !(parent && stateOf(parent).typing());
|
|
1813
|
+
if (event.key !== forward && event.key !== "Enter" && !space) return;
|
|
1814
|
+
event.preventDefault();
|
|
1815
|
+
if (disabled()) return;
|
|
1816
|
+
if (open) dom.focusFirst();
|
|
1817
|
+
else request(true, true);
|
|
1818
|
+
};
|
|
1819
|
+
item.addEventListener("keydown", onKeydown);
|
|
1820
|
+
return {
|
|
1821
|
+
sync(next) {
|
|
1822
|
+
open = next;
|
|
1823
|
+
if (!next) stopTimer();
|
|
1824
|
+
dom.sync(next);
|
|
101
1825
|
},
|
|
102
|
-
|
|
1826
|
+
detach() {
|
|
1827
|
+
stopTimer();
|
|
1828
|
+
item.removeEventListener("click", onClickFirst, { capture: true });
|
|
1829
|
+
item.removeEventListener("click", onClick);
|
|
1830
|
+
item.removeEventListener("pointerleave", onPointerLeave);
|
|
1831
|
+
item.removeEventListener("keydown", onKeydown);
|
|
1832
|
+
item.removeAttribute("popovertarget");
|
|
1833
|
+
item.removeAttribute("popovertargetaction");
|
|
1834
|
+
if (parent && stateOf(parent).openers.get(item) === opener) stateOf(parent).openers.delete(item);
|
|
1835
|
+
dom.detach();
|
|
1836
|
+
}
|
|
103
1837
|
};
|
|
104
1838
|
}
|
|
105
1839
|
// src/primitives/drag/drag.ts
|
|
@@ -219,7 +1953,7 @@ const PADDING = 10;
|
|
|
219
1953
|
const SOFT_EDGE_RATIO = .35;
|
|
220
1954
|
const SOFT_EDGE_MINIMUM = 75;
|
|
221
1955
|
const isDisabled = (el) => el.matches(":disabled, [aria-disabled=\"true\"]");
|
|
222
|
-
const reducedMotion
|
|
1956
|
+
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
223
1957
|
const setPressed = (els, pressed) => {
|
|
224
1958
|
for (const el of els) if (pressed) el.dataset.pressed = "";
|
|
225
1959
|
else delete el.dataset.pressed;
|
|
@@ -264,7 +1998,7 @@ function attachRipple(element, options = {}) {
|
|
|
264
1998
|
animation?.cancel();
|
|
265
1999
|
animation = surface.animate([frame(from, radius.from), frame(to, radius.to)], {
|
|
266
2000
|
pseudoElement: "::after",
|
|
267
|
-
duration: reducedMotion
|
|
2001
|
+
duration: reducedMotion() ? 0 : GROW_MS,
|
|
268
2002
|
easing: GROW_EASING,
|
|
269
2003
|
fill: "forwards"
|
|
270
2004
|
});
|
|
@@ -337,138 +2071,13 @@ function attachRipple(element, options = {}) {
|
|
|
337
2071
|
element.removeEventListener("click", onClick);
|
|
338
2072
|
} };
|
|
339
2073
|
}
|
|
340
|
-
// src/primitives/spring/spring.ts
|
|
341
|
-
/**
|
|
342
|
-
* Where a spring is `t` seconds after it starts at `from` with `velocity`, heading to `to`.
|
|
343
|
-
*/
|
|
344
|
-
function solveSpring({ dampingRatio: zeta, stiffness }, from, to, velocity, t) {
|
|
345
|
-
const w0 = Math.sqrt(stiffness);
|
|
346
|
-
const x0 = from - to;
|
|
347
|
-
const v0 = velocity;
|
|
348
|
-
if (Math.abs(zeta - 1) < 1e-6) {
|
|
349
|
-
const b = v0 + w0 * x0;
|
|
350
|
-
const decay = Math.exp(-w0 * t);
|
|
351
|
-
return {
|
|
352
|
-
value: to + (x0 + b * t) * decay,
|
|
353
|
-
velocity: (b - w0 * (x0 + b * t)) * decay
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
if (zeta < 1) {
|
|
357
|
-
const a = zeta * w0;
|
|
358
|
-
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
359
|
-
const b = (v0 + a * x0) / wd;
|
|
360
|
-
const decay = Math.exp(-a * t);
|
|
361
|
-
const cos = Math.cos(wd * t);
|
|
362
|
-
const sin = Math.sin(wd * t);
|
|
363
|
-
return {
|
|
364
|
-
value: to + decay * (x0 * cos + b * sin),
|
|
365
|
-
velocity: decay * ((wd * b - a * x0) * cos - (a * b + wd * x0) * sin)
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
const root = w0 * Math.sqrt(zeta * zeta - 1);
|
|
369
|
-
const r1 = -zeta * w0 + root;
|
|
370
|
-
const r2 = -zeta * w0 - root;
|
|
371
|
-
const c2 = (v0 - r1 * x0) / (r2 - r1);
|
|
372
|
-
const c1 = x0 - c2;
|
|
373
|
-
const e1 = Math.exp(r1 * t);
|
|
374
|
-
const e2 = Math.exp(r2 * t);
|
|
375
|
-
return {
|
|
376
|
-
value: to + c1 * e1 + c2 * e2,
|
|
377
|
-
velocity: r1 * c1 * e1 + r2 * c2 * e2
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
const now = () => performance.now() / 1e3;
|
|
381
|
-
/** A spring that animates a single number with `requestAnimationFrame`. */
|
|
382
|
-
function createSpring(options) {
|
|
383
|
-
const precision = options.precision ?? .01;
|
|
384
|
-
let from = options.value;
|
|
385
|
-
let target = options.value;
|
|
386
|
-
let startVelocity = 0;
|
|
387
|
-
let params;
|
|
388
|
-
let startedAt = 0;
|
|
389
|
-
let frame;
|
|
390
|
-
const state = () => params && frame !== void 0 ? solveSpring(params, from, target, startVelocity, now() - startedAt) : {
|
|
391
|
-
value: target,
|
|
392
|
-
velocity: 0
|
|
393
|
-
};
|
|
394
|
-
const settle = () => {
|
|
395
|
-
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
396
|
-
frame = void 0;
|
|
397
|
-
from = target;
|
|
398
|
-
startVelocity = 0;
|
|
399
|
-
options.onUpdate(target);
|
|
400
|
-
options.onRest?.();
|
|
401
|
-
};
|
|
402
|
-
const tick = () => {
|
|
403
|
-
const { value, velocity } = state();
|
|
404
|
-
if (Math.abs(value - target) < precision && Math.abs(velocity) < precision * 10) {
|
|
405
|
-
settle();
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
options.onUpdate(value);
|
|
409
|
-
frame = requestAnimationFrame(tick);
|
|
410
|
-
};
|
|
411
|
-
return {
|
|
412
|
-
get value() {
|
|
413
|
-
return state().value;
|
|
414
|
-
},
|
|
415
|
-
get velocity() {
|
|
416
|
-
return state().velocity;
|
|
417
|
-
},
|
|
418
|
-
get target() {
|
|
419
|
-
return target;
|
|
420
|
-
},
|
|
421
|
-
get moving() {
|
|
422
|
-
return frame !== void 0;
|
|
423
|
-
},
|
|
424
|
-
to(next, nextParams, velocity) {
|
|
425
|
-
const current = state();
|
|
426
|
-
from = current.value;
|
|
427
|
-
startVelocity = velocity ?? current.velocity;
|
|
428
|
-
target = next;
|
|
429
|
-
params = nextParams;
|
|
430
|
-
startedAt = now();
|
|
431
|
-
if (frame === void 0) frame = requestAnimationFrame(tick);
|
|
432
|
-
},
|
|
433
|
-
jump(value) {
|
|
434
|
-
target = value;
|
|
435
|
-
settle();
|
|
436
|
-
},
|
|
437
|
-
stop() {
|
|
438
|
-
const { value } = state();
|
|
439
|
-
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
440
|
-
frame = void 0;
|
|
441
|
-
from = target = value;
|
|
442
|
-
startVelocity = 0;
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
/**
|
|
447
|
-
* Reads a motion token's spring from CSS variables on an element, for example
|
|
448
|
-
* `--rio-motion-fast-spatial-damping` and `-stiffness`. Reading them from the element, rather than
|
|
449
|
-
* from the tokens package, lets a subtree that swaps motion schemes change its springs too.
|
|
450
|
-
* Returns `undefined` when the theme doesn't define them.
|
|
451
|
-
*/
|
|
452
|
-
function readSpringParams(element, token) {
|
|
453
|
-
const style = getComputedStyle(element);
|
|
454
|
-
const dampingRatio = parseFloat(style.getPropertyValue(`--rio-motion-${token}-damping`));
|
|
455
|
-
const stiffness = parseFloat(style.getPropertyValue(`--rio-motion-${token}-stiffness`));
|
|
456
|
-
return Number.isFinite(dampingRatio) && Number.isFinite(stiffness) ? {
|
|
457
|
-
dampingRatio,
|
|
458
|
-
stiffness
|
|
459
|
-
} : void 0;
|
|
460
|
-
}
|
|
461
2074
|
// src/primitives/spring-property/spring-property.ts
|
|
462
|
-
/** The
|
|
463
|
-
const
|
|
464
|
-
"
|
|
465
|
-
"
|
|
466
|
-
"
|
|
467
|
-
"data-shape",
|
|
468
|
-
"data-size",
|
|
469
|
-
"data-icon"
|
|
2075
|
+
/** The transition events that report a change of the target. */
|
|
2076
|
+
const EVENTS = [
|
|
2077
|
+
"transitionrun",
|
|
2078
|
+
"transitionend",
|
|
2079
|
+
"transitioncancel"
|
|
470
2080
|
];
|
|
471
|
-
const reducedMotion = () => typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
472
2081
|
function attachSpringProperty(element, options) {
|
|
473
2082
|
const token = options.token ?? "fast-spatial";
|
|
474
2083
|
const read = () => parseFloat(getComputedStyle(element).getPropertyValue(options.target));
|
|
@@ -485,16 +2094,20 @@ function attachSpringProperty(element, options) {
|
|
|
485
2094
|
const target = read();
|
|
486
2095
|
if (!Number.isFinite(target)) return;
|
|
487
2096
|
const params = readSpringParams(element, token);
|
|
488
|
-
if (!params ||
|
|
2097
|
+
if (!params || prefersReducedMotion()) spring.jump(target);
|
|
489
2098
|
else spring.to(target, params, velocity);
|
|
490
2099
|
};
|
|
491
|
-
const
|
|
2100
|
+
const onTransition = (event) => {
|
|
2101
|
+
if (event.target !== element || event.propertyName !== options.target) return;
|
|
492
2102
|
if (!held && read() !== spring.target) go();
|
|
2103
|
+
};
|
|
2104
|
+
for (const type of EVENTS) element.addEventListener(type, onTransition);
|
|
2105
|
+
const resync = new ResizeObserver(() => {
|
|
2106
|
+
if (held || spring.moving) return;
|
|
2107
|
+
const now = read();
|
|
2108
|
+
if (Number.isFinite(now) && now !== spring.target) spring.jump(now);
|
|
493
2109
|
});
|
|
494
|
-
|
|
495
|
-
attributes: true,
|
|
496
|
-
attributeFilter: options.watch ?? STATE_ATTRIBUTES
|
|
497
|
-
});
|
|
2110
|
+
resync.observe(element);
|
|
498
2111
|
return {
|
|
499
2112
|
get value() {
|
|
500
2113
|
return spring.value;
|
|
@@ -508,25 +2121,182 @@ function attachSpringProperty(element, options) {
|
|
|
508
2121
|
go(velocity);
|
|
509
2122
|
},
|
|
510
2123
|
detach() {
|
|
511
|
-
|
|
2124
|
+
for (const type of EVENTS) element.removeEventListener(type, onTransition);
|
|
2125
|
+
resync.disconnect();
|
|
512
2126
|
spring.stop();
|
|
513
2127
|
options.apply(void 0);
|
|
514
2128
|
}
|
|
515
2129
|
};
|
|
516
2130
|
}
|
|
517
2131
|
// src/primitives/shape-morph/shape-morph.ts
|
|
2132
|
+
const CORNERS = {
|
|
2133
|
+
start: ["border-start-start-radius", "border-end-start-radius"],
|
|
2134
|
+
end: ["border-start-end-radius", "border-end-end-radius"]
|
|
2135
|
+
};
|
|
518
2136
|
function attachShapeMorph(element, options = {}) {
|
|
519
|
-
const radius = attachSpringProperty(element, {
|
|
520
|
-
...options,
|
|
521
|
-
target: "--rio-shape-morph-target",
|
|
522
|
-
apply: (value) => value === void 0 ? element.style.removeProperty("border-radius") : element.style.setProperty("border-radius", `${value}px`)
|
|
523
|
-
});
|
|
524
2137
|
element.dataset.shapeMorph = "";
|
|
2138
|
+
const springs = ["start", "end"].map((side) => attachSpringProperty(element, {
|
|
2139
|
+
...options,
|
|
2140
|
+
target: `--rio-shape-morph-${side}-target`,
|
|
2141
|
+
apply: (value) => {
|
|
2142
|
+
for (const corner of CORNERS[side]) if (value === void 0) element.style.removeProperty(corner);
|
|
2143
|
+
else element.style.setProperty(corner, `${value}px`);
|
|
2144
|
+
}
|
|
2145
|
+
}));
|
|
525
2146
|
return { detach() {
|
|
526
|
-
|
|
2147
|
+
for (const spring of springs) spring.detach();
|
|
527
2148
|
delete element.dataset.shapeMorph;
|
|
528
2149
|
} };
|
|
529
2150
|
}
|
|
2151
|
+
// src/split-button/menu-icon.ts
|
|
2152
|
+
const inlineVar$1 = (el, name, unit) => (value) => value === void 0 ? el.style.removeProperty(name) : el.style.setProperty(name, `${value}${unit}`);
|
|
2153
|
+
/** Attach to the trailing button. */
|
|
2154
|
+
function attachSplitButtonMenuIcon(trailing) {
|
|
2155
|
+
const token = "standard-fast-spatial";
|
|
2156
|
+
const turn = attachSpringProperty(trailing, {
|
|
2157
|
+
target: "--rio-split-button-menu-turn-target",
|
|
2158
|
+
token,
|
|
2159
|
+
apply: inlineVar$1(trailing, "--_menu-turn", "deg")
|
|
2160
|
+
});
|
|
2161
|
+
const shift = attachSpringProperty(trailing, {
|
|
2162
|
+
target: "--rio-split-button-menu-shift-target",
|
|
2163
|
+
token,
|
|
2164
|
+
apply: inlineVar$1(trailing, "--_menu-shift", "px")
|
|
2165
|
+
});
|
|
2166
|
+
return { detach() {
|
|
2167
|
+
turn.detach();
|
|
2168
|
+
shift.detach();
|
|
2169
|
+
} };
|
|
2170
|
+
}
|
|
2171
|
+
// src/split-button/split-button.ts
|
|
2172
|
+
/** Split button colors: the button's, without text. */
|
|
2173
|
+
const SPLIT_BUTTON_VARIANTS = [
|
|
2174
|
+
"filled",
|
|
2175
|
+
"tonal",
|
|
2176
|
+
"outlined",
|
|
2177
|
+
"elevated"
|
|
2178
|
+
];
|
|
2179
|
+
const splitButtonDefaults = {
|
|
2180
|
+
variant: "filled",
|
|
2181
|
+
size: "small",
|
|
2182
|
+
open: false,
|
|
2183
|
+
popup: "menu",
|
|
2184
|
+
disabled: false,
|
|
2185
|
+
softDisabled: false,
|
|
2186
|
+
type: "button"
|
|
2187
|
+
};
|
|
2188
|
+
/**
|
|
2189
|
+
* How the adapter renders the split button's parts. The icon and the label hold the leading
|
|
2190
|
+
* button's content; the menu icon holds the expand icon the app provides.
|
|
2191
|
+
*/
|
|
2192
|
+
const splitButtonSkeleton = {
|
|
2193
|
+
part: "root",
|
|
2194
|
+
tag: "div",
|
|
2195
|
+
children: [{
|
|
2196
|
+
part: "leading",
|
|
2197
|
+
tag: "button",
|
|
2198
|
+
children: [
|
|
2199
|
+
{
|
|
2200
|
+
part: "touch",
|
|
2201
|
+
tag: "span"
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
part: "icon",
|
|
2205
|
+
tag: "span",
|
|
2206
|
+
when: "showsIcon",
|
|
2207
|
+
content: true
|
|
2208
|
+
},
|
|
2209
|
+
{
|
|
2210
|
+
part: "label",
|
|
2211
|
+
tag: "span",
|
|
2212
|
+
when: "showsLabel",
|
|
2213
|
+
content: true
|
|
2214
|
+
}
|
|
2215
|
+
]
|
|
2216
|
+
}, {
|
|
2217
|
+
part: "trailing",
|
|
2218
|
+
tag: "button",
|
|
2219
|
+
children: [{
|
|
2220
|
+
part: "touch",
|
|
2221
|
+
tag: "span"
|
|
2222
|
+
}, {
|
|
2223
|
+
part: "menuIcon",
|
|
2224
|
+
tag: "span",
|
|
2225
|
+
content: true
|
|
2226
|
+
}]
|
|
2227
|
+
}]
|
|
2228
|
+
};
|
|
2229
|
+
function createSplitButton(options) {
|
|
2230
|
+
const props = () => ({
|
|
2231
|
+
...splitButtonDefaults,
|
|
2232
|
+
...omitUndefined(options.props())
|
|
2233
|
+
});
|
|
2234
|
+
const content = () => options.content?.() ?? {
|
|
2235
|
+
icon: false,
|
|
2236
|
+
label: true
|
|
2237
|
+
};
|
|
2238
|
+
const shared = () => {
|
|
2239
|
+
const p = props();
|
|
2240
|
+
return {
|
|
2241
|
+
variant: p.variant,
|
|
2242
|
+
size: p.size,
|
|
2243
|
+
disabled: p.disabled,
|
|
2244
|
+
softDisabled: p.softDisabled
|
|
2245
|
+
};
|
|
2246
|
+
};
|
|
2247
|
+
const leading = createButton({
|
|
2248
|
+
props: () => ({
|
|
2249
|
+
...shared(),
|
|
2250
|
+
type: props().type
|
|
2251
|
+
}),
|
|
2252
|
+
onClick: options.onClick,
|
|
2253
|
+
onChange: () => {}
|
|
2254
|
+
});
|
|
2255
|
+
const trailing = createButton({
|
|
2256
|
+
props: () => ({
|
|
2257
|
+
...shared(),
|
|
2258
|
+
type: "button"
|
|
2259
|
+
}),
|
|
2260
|
+
onClick: () => options.onChange(!props().open),
|
|
2261
|
+
onChange: () => {}
|
|
2262
|
+
});
|
|
2263
|
+
return {
|
|
2264
|
+
parts: {
|
|
2265
|
+
root: () => ({ class: "rio-split-button" }),
|
|
2266
|
+
leading() {
|
|
2267
|
+
const root = leading.parts.root();
|
|
2268
|
+
return {
|
|
2269
|
+
...root,
|
|
2270
|
+
class: `${root.class} rio-split-button__leading`,
|
|
2271
|
+
"aria-label": content().label ? void 0 : props().label
|
|
2272
|
+
};
|
|
2273
|
+
},
|
|
2274
|
+
trailing() {
|
|
2275
|
+
const p = props();
|
|
2276
|
+
const root = trailing.parts.root();
|
|
2277
|
+
return {
|
|
2278
|
+
...root,
|
|
2279
|
+
class: `${root.class} rio-split-button__trailing`,
|
|
2280
|
+
"aria-label": p.menuLabel,
|
|
2281
|
+
"aria-haspopup": p.popup,
|
|
2282
|
+
"aria-expanded": String(p.open),
|
|
2283
|
+
"data-open": p.open ? "" : void 0
|
|
2284
|
+
};
|
|
2285
|
+
},
|
|
2286
|
+
touch: leading.parts.touch,
|
|
2287
|
+
icon: leading.parts.icon,
|
|
2288
|
+
label: leading.parts.label,
|
|
2289
|
+
menuIcon: () => ({
|
|
2290
|
+
class: "rio-split-button__menu-icon",
|
|
2291
|
+
"aria-hidden": "true"
|
|
2292
|
+
})
|
|
2293
|
+
},
|
|
2294
|
+
showsIcon: () => content().icon,
|
|
2295
|
+
showsLabel: () => content().label,
|
|
2296
|
+
handleLeadingClick: leading.handleClick,
|
|
2297
|
+
handleTrailingClick: trailing.handleClick
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
530
2300
|
// src/switch/handle.ts
|
|
531
2301
|
/** A release faster than this (px/s) picks the side it's heading to, wherever the handle is. */
|
|
532
2302
|
const FLING_VELOCITY = 300;
|
|
@@ -559,7 +2329,7 @@ function attachSwitchHandle(root, options) {
|
|
|
559
2329
|
onEnd({ velocityX }) {
|
|
560
2330
|
const velocity = direction * velocityX;
|
|
561
2331
|
const next = Math.abs(velocity) > FLING_VELOCITY ? velocity > 0 : x.value > (off + on) / 2;
|
|
562
|
-
if (next !== options.selected()) options.
|
|
2332
|
+
if (next !== options.selected()) options.onChange(next);
|
|
563
2333
|
x.release(velocity);
|
|
564
2334
|
},
|
|
565
2335
|
onCancel() {
|
|
@@ -578,28 +2348,451 @@ const switchDefaults = {
|
|
|
578
2348
|
disabled: false,
|
|
579
2349
|
softDisabled: false
|
|
580
2350
|
};
|
|
2351
|
+
/**
|
|
2352
|
+
* How the adapter renders the switch's parts. The state part is the circle where the state layer
|
|
2353
|
+
* and the ripple draw. The icon holds the selected icon when `showsSelectedIcon()`, otherwise the
|
|
2354
|
+
* unselected one.
|
|
2355
|
+
*/
|
|
2356
|
+
const switchSkeleton = {
|
|
2357
|
+
part: "root",
|
|
2358
|
+
tag: "button",
|
|
2359
|
+
children: [
|
|
2360
|
+
{
|
|
2361
|
+
part: "touch",
|
|
2362
|
+
tag: "span"
|
|
2363
|
+
},
|
|
2364
|
+
{
|
|
2365
|
+
part: "state",
|
|
2366
|
+
tag: "span"
|
|
2367
|
+
},
|
|
2368
|
+
{
|
|
2369
|
+
part: "handle",
|
|
2370
|
+
tag: "span",
|
|
2371
|
+
children: [{
|
|
2372
|
+
part: "icon",
|
|
2373
|
+
tag: "span",
|
|
2374
|
+
when: "showsIcon",
|
|
2375
|
+
content: true
|
|
2376
|
+
}]
|
|
2377
|
+
}
|
|
2378
|
+
]
|
|
2379
|
+
};
|
|
581
2380
|
function createSwitch(options) {
|
|
2381
|
+
const showsIcon = () => {
|
|
2382
|
+
const content = options.content?.();
|
|
2383
|
+
return !!(options.props().selected ? content?.selectedIcon : content?.unselectedIcon);
|
|
2384
|
+
};
|
|
582
2385
|
return {
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
2386
|
+
parts: {
|
|
2387
|
+
root() {
|
|
2388
|
+
const p = options.props();
|
|
2389
|
+
const selected = String(!!p.selected);
|
|
2390
|
+
return {
|
|
2391
|
+
class: "rio-switch rio-focus-ring",
|
|
2392
|
+
type: "button",
|
|
2393
|
+
role: "switch",
|
|
2394
|
+
"aria-checked": selected,
|
|
2395
|
+
disabled: p.disabled ? "" : void 0,
|
|
2396
|
+
"aria-disabled": p.softDisabled && !p.disabled ? "true" : void 0,
|
|
2397
|
+
"data-selected": selected,
|
|
2398
|
+
"data-icon": showsIcon() ? "" : void 0
|
|
2399
|
+
};
|
|
2400
|
+
},
|
|
2401
|
+
touch: () => ({
|
|
2402
|
+
class: "rio-switch__touch",
|
|
2403
|
+
"aria-hidden": "true"
|
|
2404
|
+
}),
|
|
2405
|
+
state: () => ({
|
|
2406
|
+
class: "rio-switch__state",
|
|
2407
|
+
"aria-hidden": "true"
|
|
2408
|
+
}),
|
|
2409
|
+
handle: () => ({
|
|
2410
|
+
class: "rio-switch__handle",
|
|
2411
|
+
"aria-hidden": "true"
|
|
2412
|
+
}),
|
|
2413
|
+
icon: () => ({ class: "rio-switch__icon" })
|
|
594
2414
|
},
|
|
2415
|
+
showsIcon,
|
|
2416
|
+
showsSelectedIcon: () => !!(options.props().selected && options.content?.().selectedIcon),
|
|
595
2417
|
handleClick(event) {
|
|
596
2418
|
const p = options.props();
|
|
597
2419
|
if (p.disabled || p.softDisabled) {
|
|
598
2420
|
event.preventDefault();
|
|
599
2421
|
return;
|
|
600
2422
|
}
|
|
601
|
-
options.
|
|
2423
|
+
options.onChange(!p.selected);
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
// src/tooltip/interaction.ts
|
|
2428
|
+
/** How long the pointer rests on the element before its tooltip shows */
|
|
2429
|
+
const SHOW_DELAY_MS = 500;
|
|
2430
|
+
/** How long a tooltip stays after the pointer and the focus leave */
|
|
2431
|
+
const HIDE_DELAY_MS = 1500;
|
|
2432
|
+
/** How soon after a tooltip closes the next one still shows at once */
|
|
2433
|
+
const WARM_MS = 300;
|
|
2434
|
+
/** How long a touch is held to show the tooltip */
|
|
2435
|
+
const LONG_PRESS_MS = 500;
|
|
2436
|
+
/** How long after a touch that showed the tooltip ends its click is swallowed */
|
|
2437
|
+
const SWALLOW_CLICK_MS = 600;
|
|
2438
|
+
/** How far a held touch may move, in px, and still count as held */
|
|
2439
|
+
const MOVE_SLOP = 10;
|
|
2440
|
+
const shown = /* @__PURE__ */ new Set();
|
|
2441
|
+
let lastClosedAt = -Infinity;
|
|
2442
|
+
const warm = () => shown.size > 0 || performance.now() - lastClosedAt < WARM_MS;
|
|
2443
|
+
const textOf = (element) => (element?.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
2444
|
+
/** The element's accessible name, near enough to tell whether a tooltip repeats it */
|
|
2445
|
+
const nameOf = (element) => {
|
|
2446
|
+
const labelledBy = element.getAttribute("aria-labelledby");
|
|
2447
|
+
if (labelledBy) {
|
|
2448
|
+
const document = element.ownerDocument;
|
|
2449
|
+
return labelledBy.split(/\s+/).map((id) => textOf(document.getElementById(id))).join(" ").trim();
|
|
2450
|
+
}
|
|
2451
|
+
return element.getAttribute("aria-label")?.trim() || textOf(element);
|
|
2452
|
+
};
|
|
2453
|
+
/** Adds or removes one id in a space-separated attribute, keeping the others. */
|
|
2454
|
+
const setToken = (element, name, id, on) => {
|
|
2455
|
+
const ids = (element.getAttribute(name) ?? "").split(/\s+/).filter((each) => each && each !== id);
|
|
2456
|
+
if (on) ids.push(id);
|
|
2457
|
+
if (ids.length) element.setAttribute(name, ids.join(" "));
|
|
2458
|
+
else element.removeAttribute(name);
|
|
2459
|
+
};
|
|
2460
|
+
function attachTooltip(trigger, root, options) {
|
|
2461
|
+
const document = trigger.ownerDocument;
|
|
2462
|
+
const view = document.defaultView;
|
|
2463
|
+
/** The element the tooltip is for: the trigger's first child, ahead of the tooltip */
|
|
2464
|
+
const element = () => {
|
|
2465
|
+
const first = trigger.firstElementChild;
|
|
2466
|
+
return first instanceof view.HTMLElement && first !== root ? first : null;
|
|
2467
|
+
};
|
|
2468
|
+
const isOpen = () => root.matches(":popover-open");
|
|
2469
|
+
const request = (open) => options.onOpenChange(open);
|
|
2470
|
+
let overlay;
|
|
2471
|
+
let overlayPersistent;
|
|
2472
|
+
/** When the pointer last went down on the element, whether the tooltip was open */
|
|
2473
|
+
let openAtPress = false;
|
|
2474
|
+
const overlayNow = () => {
|
|
2475
|
+
const persistent = options.persistent();
|
|
2476
|
+
if (overlay && persistent === overlayPersistent) return overlay;
|
|
2477
|
+
overlay?.detach();
|
|
2478
|
+
overlayPersistent = persistent;
|
|
2479
|
+
const onClose = () => request(false);
|
|
2480
|
+
overlay = attachOverlay(root, persistent ? {
|
|
2481
|
+
mode: "auto",
|
|
2482
|
+
contain: true,
|
|
2483
|
+
onClose
|
|
2484
|
+
} : {
|
|
2485
|
+
mode: "manual",
|
|
2486
|
+
transient: true,
|
|
2487
|
+
contain: true,
|
|
2488
|
+
onClose
|
|
2489
|
+
});
|
|
2490
|
+
return overlay;
|
|
2491
|
+
};
|
|
2492
|
+
let anchored;
|
|
2493
|
+
let placement = "";
|
|
2494
|
+
const place = () => {
|
|
2495
|
+
const p = options.placement();
|
|
2496
|
+
const key = `${p.side} ${p.align} ${p.offset}`;
|
|
2497
|
+
if (anchored && key === placement) return anchored;
|
|
2498
|
+
anchored?.detach();
|
|
2499
|
+
placement = key;
|
|
2500
|
+
anchored = attachAnchoredPosition(root, {
|
|
2501
|
+
anchor: element,
|
|
2502
|
+
side: p.side,
|
|
2503
|
+
align: p.align,
|
|
2504
|
+
offset: p.offset
|
|
2505
|
+
});
|
|
2506
|
+
return anchored;
|
|
2507
|
+
};
|
|
2508
|
+
let described;
|
|
2509
|
+
let expanded;
|
|
2510
|
+
const updateAria = () => {
|
|
2511
|
+
const target = element();
|
|
2512
|
+
const text = options.interactive() ? root.querySelector(".rio-tooltip__supporting-text") : root;
|
|
2513
|
+
const describe = !!target && !!text?.id && nameOf(target) !== textOf(text);
|
|
2514
|
+
if (described && (!describe || described.element !== target || described.id !== text.id)) {
|
|
2515
|
+
setToken(described.element, "aria-describedby", described.id, false);
|
|
2516
|
+
described = void 0;
|
|
2517
|
+
}
|
|
2518
|
+
if (describe && !described) {
|
|
2519
|
+
setToken(target, "aria-describedby", text.id, true);
|
|
2520
|
+
described = {
|
|
2521
|
+
element: target,
|
|
2522
|
+
id: text.id
|
|
2523
|
+
};
|
|
2524
|
+
}
|
|
2525
|
+
const persistent = options.persistent();
|
|
2526
|
+
if (expanded && (!persistent || expanded !== target)) {
|
|
2527
|
+
expanded.removeAttribute("aria-expanded");
|
|
2528
|
+
expanded = void 0;
|
|
2529
|
+
}
|
|
2530
|
+
if (persistent && target) {
|
|
2531
|
+
target.setAttribute("aria-expanded", String(isOpen()));
|
|
2532
|
+
expanded = target;
|
|
2533
|
+
}
|
|
2534
|
+
};
|
|
2535
|
+
let showTimer;
|
|
2536
|
+
let hideTimer;
|
|
2537
|
+
const stopShow = () => {
|
|
2538
|
+
clearTimeout(showTimer);
|
|
2539
|
+
showTimer = void 0;
|
|
2540
|
+
};
|
|
2541
|
+
const stopHide = () => {
|
|
2542
|
+
clearTimeout(hideTimer);
|
|
2543
|
+
hideTimer = void 0;
|
|
2544
|
+
};
|
|
2545
|
+
const hideLater = () => {
|
|
2546
|
+
stopHide();
|
|
2547
|
+
hideTimer = setTimeout(() => {
|
|
2548
|
+
hideTimer = void 0;
|
|
2549
|
+
const focused = document.activeElement;
|
|
2550
|
+
const keyboard = !!focused && trigger.contains(focused) && focused.matches(":focus-visible");
|
|
2551
|
+
if (!trigger.matches(":hover") && !keyboard) request(false);
|
|
2552
|
+
}, HIDE_DELAY_MS);
|
|
2553
|
+
};
|
|
2554
|
+
const onPointerEnter = (event) => {
|
|
2555
|
+
if (event.pointerType !== "mouse" || options.persistent()) return;
|
|
2556
|
+
stopHide();
|
|
2557
|
+
if (isOpen() || showTimer !== void 0) return;
|
|
2558
|
+
if (warm()) {
|
|
2559
|
+
request(true);
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
showTimer = setTimeout(() => {
|
|
2563
|
+
showTimer = void 0;
|
|
2564
|
+
request(true);
|
|
2565
|
+
}, SHOW_DELAY_MS);
|
|
2566
|
+
};
|
|
2567
|
+
const onPointerLeave = (event) => {
|
|
2568
|
+
if (event.pointerType !== "mouse" || options.persistent()) return;
|
|
2569
|
+
stopShow();
|
|
2570
|
+
if (isOpen()) hideLater();
|
|
2571
|
+
};
|
|
2572
|
+
const onFocusIn = (event) => {
|
|
2573
|
+
if (options.persistent()) return;
|
|
2574
|
+
const focused = event.target;
|
|
2575
|
+
if (!root.contains(focused) && !focused.matches(":focus-visible")) return;
|
|
2576
|
+
stopShow();
|
|
2577
|
+
stopHide();
|
|
2578
|
+
if (!isOpen()) request(true);
|
|
2579
|
+
};
|
|
2580
|
+
const onFocusOut = (event) => {
|
|
2581
|
+
const next = event.relatedTarget;
|
|
2582
|
+
if (next && trigger.contains(next)) return;
|
|
2583
|
+
if (options.persistent()) {
|
|
2584
|
+
if (next && isOpen()) request(false);
|
|
2585
|
+
return;
|
|
2586
|
+
}
|
|
2587
|
+
if (isOpen()) hideLater();
|
|
2588
|
+
};
|
|
2589
|
+
let pressTimer;
|
|
2590
|
+
let pressedAt;
|
|
2591
|
+
let swallowUntil = -Infinity;
|
|
2592
|
+
const stopPress = () => {
|
|
2593
|
+
clearTimeout(pressTimer);
|
|
2594
|
+
pressTimer = void 0;
|
|
2595
|
+
pressedAt = void 0;
|
|
2596
|
+
};
|
|
2597
|
+
const onPointerDown = (event) => {
|
|
2598
|
+
if (!element()?.contains(event.target)) return;
|
|
2599
|
+
openAtPress = isOpen();
|
|
2600
|
+
if (options.persistent()) return;
|
|
2601
|
+
stopShow();
|
|
2602
|
+
if (event.pointerType === "touch") {
|
|
2603
|
+
stopPress();
|
|
2604
|
+
pressedAt = {
|
|
2605
|
+
x: event.clientX,
|
|
2606
|
+
y: event.clientY
|
|
2607
|
+
};
|
|
2608
|
+
pressTimer = setTimeout(() => {
|
|
2609
|
+
stopPress();
|
|
2610
|
+
swallowUntil = Infinity;
|
|
2611
|
+
stopHide();
|
|
2612
|
+
request(true);
|
|
2613
|
+
}, LONG_PRESS_MS);
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
stopHide();
|
|
2617
|
+
if (isOpen()) request(false);
|
|
2618
|
+
};
|
|
2619
|
+
const onPointerMove = (event) => {
|
|
2620
|
+
if (!pressedAt) return;
|
|
2621
|
+
if (Math.hypot(event.clientX - pressedAt.x, event.clientY - pressedAt.y) > MOVE_SLOP) stopPress();
|
|
2622
|
+
};
|
|
2623
|
+
const onPointerUp = (event) => {
|
|
2624
|
+
if (event.pointerType !== "touch" || options.persistent()) return;
|
|
2625
|
+
stopPress();
|
|
2626
|
+
if (swallowUntil === Infinity) {
|
|
2627
|
+
swallowUntil = performance.now() + SWALLOW_CLICK_MS;
|
|
2628
|
+
if (isOpen()) hideLater();
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
const onPointerCancel = () => {
|
|
2632
|
+
stopPress();
|
|
2633
|
+
swallowUntil = -Infinity;
|
|
2634
|
+
};
|
|
2635
|
+
const onContextMenu = (event) => {
|
|
2636
|
+
if (pressTimer !== void 0 || performance.now() < swallowUntil) event.preventDefault();
|
|
2637
|
+
};
|
|
2638
|
+
const onClickFirst = (event) => {
|
|
2639
|
+
if (performance.now() >= swallowUntil || !element()?.contains(event.target)) return;
|
|
2640
|
+
swallowUntil = -Infinity;
|
|
2641
|
+
event.preventDefault();
|
|
2642
|
+
event.stopImmediatePropagation();
|
|
2643
|
+
};
|
|
2644
|
+
const onClick = (event) => {
|
|
2645
|
+
if (!options.persistent()) return;
|
|
2646
|
+
if (!element()?.contains(event.target)) return;
|
|
2647
|
+
request(!(event.detail === 0 ? isOpen() : openAtPress));
|
|
2648
|
+
};
|
|
2649
|
+
trigger.addEventListener("pointerenter", onPointerEnter);
|
|
2650
|
+
trigger.addEventListener("pointerleave", onPointerLeave);
|
|
2651
|
+
trigger.addEventListener("focusin", onFocusIn);
|
|
2652
|
+
trigger.addEventListener("focusout", onFocusOut);
|
|
2653
|
+
trigger.addEventListener("pointerdown", onPointerDown);
|
|
2654
|
+
trigger.addEventListener("pointermove", onPointerMove);
|
|
2655
|
+
trigger.addEventListener("pointerup", onPointerUp);
|
|
2656
|
+
trigger.addEventListener("pointercancel", onPointerCancel);
|
|
2657
|
+
trigger.addEventListener("contextmenu", onContextMenu);
|
|
2658
|
+
trigger.addEventListener("click", onClickFirst, { capture: true });
|
|
2659
|
+
trigger.addEventListener("click", onClick);
|
|
2660
|
+
overlayNow();
|
|
2661
|
+
updateAria();
|
|
2662
|
+
return {
|
|
2663
|
+
sync(open) {
|
|
2664
|
+
const showing = isOpen();
|
|
2665
|
+
if (open && !showing) {
|
|
2666
|
+
const target = element();
|
|
2667
|
+
if (!target) {
|
|
2668
|
+
request(false);
|
|
2669
|
+
return;
|
|
2670
|
+
}
|
|
2671
|
+
overlayNow().show(target);
|
|
2672
|
+
place().update();
|
|
2673
|
+
shown.add(root);
|
|
2674
|
+
} else if (!open) {
|
|
2675
|
+
stopShow();
|
|
2676
|
+
stopHide();
|
|
2677
|
+
if (showing) overlay?.hide();
|
|
2678
|
+
if (shown.delete(root)) lastClosedAt = performance.now();
|
|
2679
|
+
}
|
|
2680
|
+
updateAria();
|
|
2681
|
+
},
|
|
2682
|
+
update: updateAria,
|
|
2683
|
+
detach() {
|
|
2684
|
+
trigger.removeEventListener("pointerenter", onPointerEnter);
|
|
2685
|
+
trigger.removeEventListener("pointerleave", onPointerLeave);
|
|
2686
|
+
trigger.removeEventListener("focusin", onFocusIn);
|
|
2687
|
+
trigger.removeEventListener("focusout", onFocusOut);
|
|
2688
|
+
trigger.removeEventListener("pointerdown", onPointerDown);
|
|
2689
|
+
trigger.removeEventListener("pointermove", onPointerMove);
|
|
2690
|
+
trigger.removeEventListener("pointerup", onPointerUp);
|
|
2691
|
+
trigger.removeEventListener("pointercancel", onPointerCancel);
|
|
2692
|
+
trigger.removeEventListener("contextmenu", onContextMenu);
|
|
2693
|
+
trigger.removeEventListener("click", onClickFirst, { capture: true });
|
|
2694
|
+
trigger.removeEventListener("click", onClick);
|
|
2695
|
+
stopShow();
|
|
2696
|
+
stopHide();
|
|
2697
|
+
stopPress();
|
|
2698
|
+
if (described) setToken(described.element, "aria-describedby", described.id, false);
|
|
2699
|
+
expanded?.removeAttribute("aria-expanded");
|
|
2700
|
+
if (shown.delete(root)) lastClosedAt = performance.now();
|
|
2701
|
+
overlay?.detach();
|
|
2702
|
+
anchored?.detach();
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
}
|
|
2706
|
+
// src/tooltip/tooltip.ts
|
|
2707
|
+
/** M3 tooltip variants: a brief label, or a card with a subhead, text, and actions. */
|
|
2708
|
+
const TOOLTIP_VARIANTS = ["plain", "rich"];
|
|
2709
|
+
const tooltipDefaults = {
|
|
2710
|
+
variant: "plain",
|
|
2711
|
+
persistent: false,
|
|
2712
|
+
open: false,
|
|
2713
|
+
offset: 4
|
|
2714
|
+
};
|
|
2715
|
+
/** The trigger holds the app's element first, then the tooltip (`tooltipSkeleton`). */
|
|
2716
|
+
const tooltipTriggerSkeleton = {
|
|
2717
|
+
part: "trigger",
|
|
2718
|
+
tag: "span",
|
|
2719
|
+
content: true
|
|
2720
|
+
};
|
|
2721
|
+
/** Spans throughout, so a tooltip may sit inside a paragraph. */
|
|
2722
|
+
const tooltipSkeleton = {
|
|
2723
|
+
part: "root",
|
|
2724
|
+
tag: "span",
|
|
2725
|
+
children: [
|
|
2726
|
+
{
|
|
2727
|
+
part: "subhead",
|
|
2728
|
+
tag: "span",
|
|
2729
|
+
when: "showsSubhead",
|
|
2730
|
+
content: true
|
|
2731
|
+
},
|
|
2732
|
+
{
|
|
2733
|
+
part: "supportingText",
|
|
2734
|
+
tag: "span",
|
|
2735
|
+
content: true
|
|
2736
|
+
},
|
|
2737
|
+
{
|
|
2738
|
+
part: "actions",
|
|
2739
|
+
tag: "span",
|
|
2740
|
+
when: "showsActions",
|
|
2741
|
+
content: true
|
|
2742
|
+
}
|
|
2743
|
+
]
|
|
2744
|
+
};
|
|
2745
|
+
const NO_CONTENT = {
|
|
2746
|
+
subhead: false,
|
|
2747
|
+
actions: false
|
|
2748
|
+
};
|
|
2749
|
+
function createTooltip(options) {
|
|
2750
|
+
const props = () => ({
|
|
2751
|
+
...tooltipDefaults,
|
|
2752
|
+
...omitUndefined(options.props())
|
|
2753
|
+
});
|
|
2754
|
+
const rich = () => props().variant === "rich";
|
|
2755
|
+
const content = () => options.content?.() ?? NO_CONTENT;
|
|
2756
|
+
const showsSubhead = () => rich() && content().subhead;
|
|
2757
|
+
const showsActions = () => rich() && content().actions;
|
|
2758
|
+
const interactive = showsActions;
|
|
2759
|
+
const { ids } = options;
|
|
2760
|
+
return {
|
|
2761
|
+
parts: {
|
|
2762
|
+
trigger: () => ({ class: "rio-tooltip-trigger" }),
|
|
2763
|
+
root() {
|
|
2764
|
+
const p = props();
|
|
2765
|
+
const dialog = interactive();
|
|
2766
|
+
return {
|
|
2767
|
+
class: "rio-tooltip",
|
|
2768
|
+
id: ids.root,
|
|
2769
|
+
role: dialog ? "dialog" : "tooltip",
|
|
2770
|
+
"aria-labelledby": dialog ? showsSubhead() ? ids.subhead : ids.supportingText : void 0,
|
|
2771
|
+
"data-variant": p.variant
|
|
2772
|
+
};
|
|
2773
|
+
},
|
|
2774
|
+
subhead: () => ({
|
|
2775
|
+
class: "rio-tooltip__subhead",
|
|
2776
|
+
id: ids.subhead
|
|
2777
|
+
}),
|
|
2778
|
+
supportingText: () => ({
|
|
2779
|
+
class: "rio-tooltip__supporting-text",
|
|
2780
|
+
id: ids.supportingText
|
|
2781
|
+
}),
|
|
2782
|
+
actions: () => ({ class: "rio-tooltip__actions" })
|
|
2783
|
+
},
|
|
2784
|
+
showsSubhead,
|
|
2785
|
+
showsActions,
|
|
2786
|
+
interactive,
|
|
2787
|
+
persistent: () => rich() && props().persistent,
|
|
2788
|
+
placement() {
|
|
2789
|
+
const p = props();
|
|
2790
|
+
return {
|
|
2791
|
+
side: p.side ?? (rich() ? "bottom" : "top"),
|
|
2792
|
+
align: p.align ?? (rich() ? "start" : "center"),
|
|
2793
|
+
offset: p.offset
|
|
2794
|
+
};
|
|
602
2795
|
}
|
|
603
2796
|
};
|
|
604
2797
|
}
|
|
605
|
-
export { BUTTON_SIZES, BUTTON_VARIANTS, ICON_BUTTON_VARIANTS, ICON_BUTTON_WIDTHS,
|
|
2798
|
+
export { BUTTON_GROUP_SELECTIONS, BUTTON_GROUP_VARIANTS, BUTTON_SIZES, BUTTON_VARIANTS, DIALOG_ROLES, ICON_BUTTON_VARIANTS, ICON_BUTTON_WIDTHS, MENU_SELECTIONS, MENU_VARIANTS, SPLIT_BUTTON_VARIANTS, TOOLTIP_VARIANTS, attachAnchoredPosition, attachButtonGroup, attachDialog, attachDrag, attachMenu, attachOverlay, attachRipple, attachShapeMorph, attachSplitButtonMenuIcon, attachSpringProperty, attachSubMenu, attachSwitchHandle, attachTooltip, buttonDefaults, buttonGroupDefaults, buttonGroupSkeleton, buttonSkeleton, createButton, createButtonGroup, createDialog, createIconButton, createMenu, createMenuDivider, createMenuGroup, createMenuItem, createSplitButton, createSpring, createSubMenu, createSwitch, createTooltip, dialogDefaults, dialogSkeleton, iconButtonDefaults, iconButtonSkeleton, menuDefaults, menuDividerSkeleton, menuGroupSkeleton, menuItemDefaults, menuItemSkeleton, menuSkeleton, prefersReducedMotion, readSpringParams, solveSpring, splitButtonDefaults, splitButtonSkeleton, switchDefaults, switchSkeleton, tooltipDefaults, tooltipSkeleton, tooltipTriggerSkeleton };
|