@dorsk/tsumikit 0.67.1 → 0.68.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 +96 -3
- package/dist/components/atoms/Button.svelte +111 -3
- package/dist/components/atoms/Button.svelte.d.ts +4 -0
- package/dist/components/layouts/NavBar.svelte +184 -30
- package/dist/components/layouts/NavBar.svelte.d.ts +19 -3
- package/dist/components/molecules/GlyphStack.svelte +12 -19
- package/dist/components/molecules/RadialMenu.svelte +579 -0
- package/dist/components/molecules/RadialMenu.svelte.d.ts +42 -0
- package/dist/components/molecules/SortableList.svelte +720 -0
- package/dist/components/molecules/SortableList.svelte.d.ts +116 -0
- package/dist/components/molecules/sortable-list-order.d.ts +68 -0
- package/dist/components/molecules/sortable-list-order.js +133 -0
- package/dist/floating.d.ts +38 -0
- package/dist/floating.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/slide-select.d.ts +26 -0
- package/dist/slide-select.js +53 -0
- package/package.json +1 -1
|
@@ -1,28 +1,44 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
1
2
|
import type { IconName } from '../atoms/Icon.svelte';
|
|
2
3
|
export interface NavBarItem {
|
|
3
|
-
|
|
4
|
+
/** Renders an `<a>`; without it the item is a `<button>`. */
|
|
5
|
+
href?: string;
|
|
6
|
+
/** Selects the item through the bar's `value`. */
|
|
7
|
+
value?: string;
|
|
4
8
|
label: string;
|
|
5
9
|
icon?: IconName;
|
|
6
10
|
/** Rendered instead of `icon` when set. */
|
|
7
11
|
emoji?: string;
|
|
8
12
|
/** Count or short text pinned to the glyph. 0 / '' render nothing. */
|
|
9
13
|
badge?: number | string;
|
|
14
|
+
/** Overrides the `value` match. */
|
|
10
15
|
current?: boolean;
|
|
16
|
+
/** `preventDefault()` skips the `value` change. */
|
|
17
|
+
onclick?: (e: MouseEvent) => void;
|
|
18
|
+
/** Id of the panel a tab shows (`aria-controls`). */
|
|
19
|
+
controls?: string;
|
|
11
20
|
}
|
|
12
21
|
type $$ComponentProps = {
|
|
13
22
|
items: NavBarItem[];
|
|
14
23
|
/** Accessible name of the `<nav>` landmark. */
|
|
15
24
|
label: string;
|
|
25
|
+
/** `value` of the current item, for in-page views without routes. */
|
|
26
|
+
value?: string;
|
|
27
|
+
onchange?: (value: string) => void;
|
|
28
|
+
/** Fills a notch in the middle of the row (`--navbar-center-w`), may rise above it (`--navbar-center-raise`). */
|
|
29
|
+
center?: Snippet;
|
|
16
30
|
/** 'bar' = fixed to the bottom edge with safe-area padding. */
|
|
17
31
|
placement?: 'bar' | 'inline';
|
|
18
32
|
/** Icon over label vs icon beside label. */
|
|
19
33
|
orientation?: 'stacked' | 'inline';
|
|
20
|
-
/**
|
|
34
|
+
/** 'floating' = rounded elevated card inset from the edges. */
|
|
35
|
+
variant?: 'flush' | 'floating';
|
|
36
|
+
/** Caps and centres the row of items. */
|
|
21
37
|
maxWidth?: string;
|
|
22
38
|
class?: string;
|
|
23
39
|
style?: string;
|
|
24
40
|
[key: string]: unknown;
|
|
25
41
|
};
|
|
26
|
-
declare const NavBar: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
42
|
+
declare const NavBar: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
27
43
|
type NavBar = ReturnType<typeof NavBar>;
|
|
28
44
|
export default NavBar;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { tick } from 'svelte';
|
|
21
21
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
22
22
|
import { browser } from '../../env';
|
|
23
|
+
import { slideSelect } from '../../slide-select';
|
|
23
24
|
|
|
24
25
|
type Own = {
|
|
25
26
|
/** Glyphs as `{ inline, stacked? }` pairs; alternative to `children`. */
|
|
@@ -78,7 +79,6 @@
|
|
|
78
79
|
return el.parentElement;
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
const SLIDE_THRESHOLD = 12;
|
|
82
82
|
const CONTROL = 'button, a[href], input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])';
|
|
83
83
|
|
|
84
84
|
const tiles = $derived(items?.slice(0, 4) ?? []);
|
|
@@ -86,7 +86,6 @@
|
|
|
86
86
|
let panel = $state<HTMLDivElement>();
|
|
87
87
|
let open = $state(false);
|
|
88
88
|
let armed = $state(-1);
|
|
89
|
-
let origin: { x: number; y: number } | null = null;
|
|
90
89
|
|
|
91
90
|
function show() {
|
|
92
91
|
try {
|
|
@@ -129,11 +128,13 @@
|
|
|
129
128
|
}
|
|
130
129
|
|
|
131
130
|
/** Tile under a drag from the press point: 0 top-left, 1 top-right, 2 bottom-left, 3 bottom-right. */
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
131
|
+
const slide = slideSelect({
|
|
132
|
+
pick: ({ dx, dy }) => {
|
|
133
|
+
const i = (dy < 0 ? 0 : 2) + (dx < 0 ? 0 : 1);
|
|
134
|
+
return i < tiles.length ? i : -1;
|
|
135
|
+
},
|
|
136
|
+
onarm: (i) => (armed = i),
|
|
137
|
+
});
|
|
137
138
|
|
|
138
139
|
async function activate(i: number) {
|
|
139
140
|
const { action } = tiles[i];
|
|
@@ -153,29 +154,21 @@
|
|
|
153
154
|
if (!expandable || expand !== 'slide' || e.button !== 0) return;
|
|
154
155
|
e.preventDefault();
|
|
155
156
|
e.stopPropagation();
|
|
156
|
-
|
|
157
|
-
armed = -1;
|
|
158
|
-
try {
|
|
159
|
-
root?.setPointerCapture(e.pointerId);
|
|
160
|
-
} catch {}
|
|
157
|
+
slide.down(e, root);
|
|
161
158
|
show();
|
|
162
159
|
}
|
|
163
160
|
|
|
164
161
|
function onPointerMove(e: PointerEvent) {
|
|
165
|
-
|
|
162
|
+
slide.move(e);
|
|
166
163
|
}
|
|
167
164
|
|
|
168
165
|
function onPointerUp() {
|
|
169
|
-
|
|
170
|
-
origin = null;
|
|
171
|
-
const i = armed;
|
|
172
|
-
armed = -1;
|
|
166
|
+
const i = slide.up();
|
|
173
167
|
if (i >= 0) activate(i);
|
|
174
168
|
}
|
|
175
169
|
|
|
176
170
|
function onPointerCancel() {
|
|
177
|
-
|
|
178
|
-
armed = -1;
|
|
171
|
+
slide.cancel();
|
|
179
172
|
}
|
|
180
173
|
|
|
181
174
|
// Pointer clicks on the folded cells open the panel instead of hitting the
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface RadialArc {
|
|
3
|
+
/** Degrees, 0 = right, counter-clockwise. */
|
|
4
|
+
from: number;
|
|
5
|
+
to: number;
|
|
6
|
+
}
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
// A round FAB that fans its MenuItems out as petals on an arc, in a top-layer
|
|
11
|
+
// panel over a scrim. Tap toggles; press-and-slide arms the petal under the
|
|
12
|
+
// pointer and fires it on release. The panel holds an inert copy of the FAB
|
|
13
|
+
// so the button still reads as the close control over the scrim.
|
|
14
|
+
import { tick, untrack, type Snippet } from 'svelte';
|
|
15
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
16
|
+
import Icon, { type IconName } from '../atoms/Icon.svelte';
|
|
17
|
+
import Scrim from '../atoms/Scrim.svelte';
|
|
18
|
+
import type { MenuItem } from './Menu.svelte';
|
|
19
|
+
import { menuItemCloses, menuItemGlyphs } from './menu-item';
|
|
20
|
+
import { type Bounds, fanLayout, type Point } from '../../floating';
|
|
21
|
+
import { slideSelect } from '../../slide-select';
|
|
22
|
+
import { canonicalTone, type Tone } from '../../tone';
|
|
23
|
+
|
|
24
|
+
type Own = {
|
|
25
|
+
items: MenuItem[];
|
|
26
|
+
/** Accessible name of the trigger and the menu. */
|
|
27
|
+
label: string;
|
|
28
|
+
open?: boolean;
|
|
29
|
+
onopen?: () => void;
|
|
30
|
+
onclose?: () => void;
|
|
31
|
+
/** Replaces the default FAB content. */
|
|
32
|
+
trigger?: Snippet<[{ open: boolean }]>;
|
|
33
|
+
icon?: IconName;
|
|
34
|
+
closeIcon?: IconName;
|
|
35
|
+
/** Small line under the FAB glyph ("● bot"). */
|
|
36
|
+
caption?: Snippet | string;
|
|
37
|
+
/** Colour of the FAB ring. */
|
|
38
|
+
tone?: Tone;
|
|
39
|
+
arc?: RadialArc;
|
|
40
|
+
/** Preferred arc radius; defaults to `--radial-radius`, else 8.5rem. */
|
|
41
|
+
radius?: string;
|
|
42
|
+
/** Petal diameter; defaults to `--radial-petal-size`, else 3.5rem. */
|
|
43
|
+
petalSize?: string;
|
|
44
|
+
scrim?: boolean;
|
|
45
|
+
/** A line centred above the arc while open. */
|
|
46
|
+
status?: Snippet;
|
|
47
|
+
class?: string;
|
|
48
|
+
style?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let {
|
|
52
|
+
items,
|
|
53
|
+
label,
|
|
54
|
+
open = $bindable(false),
|
|
55
|
+
onopen,
|
|
56
|
+
onclose,
|
|
57
|
+
trigger,
|
|
58
|
+
icon = 'plus',
|
|
59
|
+
closeIcon = 'x',
|
|
60
|
+
caption,
|
|
61
|
+
tone = 'accent',
|
|
62
|
+
arc = { from: 200, to: -20 },
|
|
63
|
+
radius,
|
|
64
|
+
petalSize,
|
|
65
|
+
scrim = true,
|
|
66
|
+
status,
|
|
67
|
+
class: klass = '',
|
|
68
|
+
style: styleProp = '',
|
|
69
|
+
...rest
|
|
70
|
+
}: Omit<HTMLButtonAttributes, keyof Own> & Own = $props();
|
|
71
|
+
|
|
72
|
+
const uid = $props.id();
|
|
73
|
+
const menuId = `${uid}-menu`;
|
|
74
|
+
const EDGE = 8;
|
|
75
|
+
const TAP_TAIL_MS = 400;
|
|
76
|
+
const SLACK = 8;
|
|
77
|
+
|
|
78
|
+
let triggerEl = $state<HTMLButtonElement>();
|
|
79
|
+
let panel = $state<HTMLDivElement>();
|
|
80
|
+
let statusEl = $state<HTMLDivElement>();
|
|
81
|
+
let points = $state<Point[]>([]);
|
|
82
|
+
let fab = $state<{ left: number; top: number; width: number; height: number } | null>(null);
|
|
83
|
+
let statusAt = $state<Point | null>(null);
|
|
84
|
+
let armed = $state(-1);
|
|
85
|
+
let active = $state(0);
|
|
86
|
+
let petalPx = 0;
|
|
87
|
+
let shown = false;
|
|
88
|
+
let focusOnOpen = false;
|
|
89
|
+
let pressedOpen = false;
|
|
90
|
+
let swallowClick = false;
|
|
91
|
+
let releasedAt = Number.NEGATIVE_INFINITY;
|
|
92
|
+
|
|
93
|
+
const petals = $derived(items.filter((item) => !item.control));
|
|
94
|
+
const ring = $derived(tone === 'neutral' ? 'var(--border-strong)' : `var(--${canonicalTone(tone)})`);
|
|
95
|
+
const forward = $derived(points.length < 2 || points[points.length - 1].x >= points[0].x);
|
|
96
|
+
|
|
97
|
+
function toPx(length: string, el: Element): number {
|
|
98
|
+
const n = Number.parseFloat(length);
|
|
99
|
+
if (Number.isNaN(n)) return 0;
|
|
100
|
+
const font = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize) || 16;
|
|
101
|
+
if (length.endsWith('rem')) return n * font(document.documentElement);
|
|
102
|
+
if (length.endsWith('em')) return n * font(el);
|
|
103
|
+
if (length.endsWith('vw')) return (n * document.documentElement.clientWidth) / 100;
|
|
104
|
+
if (length.endsWith('vh')) return (n * document.documentElement.clientHeight) / 100;
|
|
105
|
+
return n;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function length(el: Element, prop: string | undefined, name: string, fallback: string): number {
|
|
109
|
+
const value = prop ?? (getComputedStyle(el).getPropertyValue(name).trim() || fallback);
|
|
110
|
+
return toPx(value, el) || toPx(fallback, el);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function bounds(): Bounds {
|
|
114
|
+
const { clientWidth: vw, clientHeight: vh } = document.documentElement;
|
|
115
|
+
const pad = panel ? getComputedStyle(panel) : null;
|
|
116
|
+
const inset = (side: 'Top' | 'Right' | 'Bottom' | 'Left') =>
|
|
117
|
+
(Number.parseFloat(pad?.[`padding${side}`] ?? '') || 0) + EDGE;
|
|
118
|
+
return { left: inset('Left'), top: inset('Top'), right: vw - inset('Right'), bottom: vh - inset('Bottom') };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function position() {
|
|
122
|
+
if (!triggerEl) return;
|
|
123
|
+
const r = triggerEl.getBoundingClientRect();
|
|
124
|
+
const touch = length(triggerEl, undefined, '--touch-target', '44px');
|
|
125
|
+
petalPx = Math.max(length(triggerEl, petalSize, '--radial-petal-size', '3.5rem'), touch);
|
|
126
|
+
const center = { x: r.left + r.width / 2, y: r.top + r.height / 2 };
|
|
127
|
+
const layout = fanLayout({
|
|
128
|
+
center,
|
|
129
|
+
count: petals.length,
|
|
130
|
+
from: arc.from,
|
|
131
|
+
to: arc.to,
|
|
132
|
+
radius: length(triggerEl, radius, '--radial-reach', '8.5rem'),
|
|
133
|
+
petal: petalPx,
|
|
134
|
+
minRadius: Math.max(r.width, r.height) / 2 + petalPx / 2,
|
|
135
|
+
bounds: bounds(),
|
|
136
|
+
});
|
|
137
|
+
points = layout.points;
|
|
138
|
+
fab = { left: r.left, top: r.top, width: r.width, height: r.height };
|
|
139
|
+
if (!points.length) {
|
|
140
|
+
statusAt = null;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const xs = points.map((p) => p.x);
|
|
144
|
+
const top = Math.min(...points.map((p) => p.y), center.y - r.height / 2) - petalPx / 2 - EDGE;
|
|
145
|
+
statusAt = { x: (Math.min(...xs) + Math.max(...xs)) / 2, y: top };
|
|
146
|
+
tick().then(clampStatus);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function clampStatus() {
|
|
150
|
+
if (!statusEl || !statusAt) return;
|
|
151
|
+
const w = statusEl.getBoundingClientRect().width;
|
|
152
|
+
const b = bounds();
|
|
153
|
+
const x = Math.max(b.left + w / 2, Math.min(statusAt.x, b.right - w / 2));
|
|
154
|
+
if (x !== statusAt.x) statusAt = { x, y: statusAt.y };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function petalEls(): HTMLButtonElement[] {
|
|
158
|
+
return Array.from(panel?.querySelectorAll<HTMLButtonElement>('.radial-petal') ?? []);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function enabled(): number[] {
|
|
162
|
+
return petals.flatMap((item, i) => (item.disabled ? [] : [i]));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function focusPetal(i: number) {
|
|
166
|
+
if (i < 0) return;
|
|
167
|
+
active = i;
|
|
168
|
+
petalEls()[i]?.focus();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function step(by: number) {
|
|
172
|
+
const order = enabled();
|
|
173
|
+
if (!order.length) return;
|
|
174
|
+
const at = order.indexOf(active);
|
|
175
|
+
focusPetal(order[(at + by + order.length) % order.length] ?? order[0]);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function show() {
|
|
179
|
+
try {
|
|
180
|
+
panel?.showPopover();
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hide() {
|
|
185
|
+
try {
|
|
186
|
+
panel?.hidePopover();
|
|
187
|
+
} catch {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function openMenu(fromKeyboard: boolean) {
|
|
191
|
+
focusOnOpen = fromKeyboard;
|
|
192
|
+
open = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function close(refocus = false) {
|
|
196
|
+
if (!open) return;
|
|
197
|
+
const inside = !!panel?.contains(document.activeElement);
|
|
198
|
+
open = false;
|
|
199
|
+
if (refocus || inside) triggerEl?.focus();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function activate(item: MenuItem) {
|
|
203
|
+
if (item.disabled) return;
|
|
204
|
+
if (menuItemCloses(item, true)) close(true);
|
|
205
|
+
item.onselect?.();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
$effect(() => {
|
|
209
|
+
const want = open;
|
|
210
|
+
if (!panel || want === shown) return;
|
|
211
|
+
shown = want;
|
|
212
|
+
untrack(() => {
|
|
213
|
+
if (want) {
|
|
214
|
+
show();
|
|
215
|
+
position();
|
|
216
|
+
active = enabled()[0] ?? 0;
|
|
217
|
+
if (focusOnOpen) tick().then(() => focusPetal(enabled()[0] ?? -1));
|
|
218
|
+
focusOnOpen = false;
|
|
219
|
+
onopen?.();
|
|
220
|
+
} else {
|
|
221
|
+
hide();
|
|
222
|
+
armed = -1;
|
|
223
|
+
onclose?.();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
$effect(() => {
|
|
229
|
+
if (!open) return;
|
|
230
|
+
const onKey = (e: KeyboardEvent) => {
|
|
231
|
+
if (e.key !== 'Escape') return;
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
close(true);
|
|
234
|
+
};
|
|
235
|
+
const onDown = (e: PointerEvent) => {
|
|
236
|
+
const t = e.target as Node;
|
|
237
|
+
if (!panel?.querySelector('[role="menu"]')?.contains(t) && !triggerEl?.contains(t)) close();
|
|
238
|
+
};
|
|
239
|
+
const reflow = () => position();
|
|
240
|
+
document.addEventListener('keydown', onKey);
|
|
241
|
+
if (!scrim) document.addEventListener('pointerdown', onDown, true);
|
|
242
|
+
addEventListener('resize', reflow);
|
|
243
|
+
addEventListener('scroll', reflow, true);
|
|
244
|
+
return () => {
|
|
245
|
+
document.removeEventListener('keydown', onKey);
|
|
246
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
247
|
+
removeEventListener('resize', reflow);
|
|
248
|
+
removeEventListener('scroll', reflow, true);
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const slide = slideSelect({
|
|
253
|
+
pick: ({ x, y }) => {
|
|
254
|
+
const reach = petalPx / 2 + SLACK;
|
|
255
|
+
let best = -1;
|
|
256
|
+
let bestD = reach;
|
|
257
|
+
points.forEach((p, i) => {
|
|
258
|
+
const d = Math.hypot(x - p.x, y - p.y);
|
|
259
|
+
if (d <= bestD && !petals[i]?.disabled) {
|
|
260
|
+
best = i;
|
|
261
|
+
bestD = d;
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
return best;
|
|
265
|
+
},
|
|
266
|
+
onarm: (i) => (armed = i),
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
function onpointerdown(e: PointerEvent) {
|
|
270
|
+
if (e.button !== 0 || rest.disabled) return;
|
|
271
|
+
swallowClick = true;
|
|
272
|
+
pressedOpen = open;
|
|
273
|
+
slide.down(e, triggerEl);
|
|
274
|
+
if (!open) openMenu(false);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onpointerup() {
|
|
278
|
+
if (!slide.active) return;
|
|
279
|
+
releasedAt = performance.now();
|
|
280
|
+
const slid = slide.slid;
|
|
281
|
+
const i = slide.up();
|
|
282
|
+
if (i >= 0) activate(petals[i]);
|
|
283
|
+
else if (!slid && pressedOpen) close();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A touch tap's click is hit-tested after the scrim has appeared over the FAB.
|
|
287
|
+
function onscrim() {
|
|
288
|
+
if (performance.now() - releasedAt < TAP_TAIL_MS) return;
|
|
289
|
+
close();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function onclick(e: MouseEvent) {
|
|
293
|
+
if (swallowClick && e.detail !== 0) {
|
|
294
|
+
swallowClick = false;
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
swallowClick = false;
|
|
298
|
+
if (open) close(true);
|
|
299
|
+
else openMenu(true);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function ontriggerkey(e: KeyboardEvent) {
|
|
303
|
+
if (e.key === 'Tab' && open) {
|
|
304
|
+
close();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
|
308
|
+
e.preventDefault();
|
|
309
|
+
if (open) focusPetal(enabled()[0] ?? -1);
|
|
310
|
+
else openMenu(true);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function onpetalkey(e: KeyboardEvent) {
|
|
314
|
+
const order = enabled();
|
|
315
|
+
const keys: Record<string, () => void> = {
|
|
316
|
+
ArrowRight: () => step(forward ? 1 : -1),
|
|
317
|
+
ArrowLeft: () => step(forward ? -1 : 1),
|
|
318
|
+
ArrowDown: () => step(1),
|
|
319
|
+
ArrowUp: () => step(-1),
|
|
320
|
+
Home: () => focusPetal(order[0] ?? -1),
|
|
321
|
+
End: () => focusPetal(order[order.length - 1] ?? -1),
|
|
322
|
+
Tab: () => close(true),
|
|
323
|
+
};
|
|
324
|
+
const run = keys[e.key];
|
|
325
|
+
if (!run) return;
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
run();
|
|
328
|
+
}
|
|
329
|
+
</script>
|
|
330
|
+
|
|
331
|
+
{#snippet face()}
|
|
332
|
+
{#if trigger}
|
|
333
|
+
{@render trigger({ open })}
|
|
334
|
+
{:else}
|
|
335
|
+
<Icon name={open ? closeIcon : icon} size={20} />
|
|
336
|
+
{#if caption}
|
|
337
|
+
<span class="radial-caption">
|
|
338
|
+
{#if typeof caption === 'string'}{caption}{:else}{@render caption()}{/if}
|
|
339
|
+
</span>
|
|
340
|
+
{/if}
|
|
341
|
+
{/if}
|
|
342
|
+
{/snippet}
|
|
343
|
+
|
|
344
|
+
<button
|
|
345
|
+
type="button"
|
|
346
|
+
{...rest}
|
|
347
|
+
bind:this={triggerEl}
|
|
348
|
+
data-tsu="RadialMenu"
|
|
349
|
+
class="radial-fab {klass}"
|
|
350
|
+
style={styleProp}
|
|
351
|
+
style:--radial-ring={ring}
|
|
352
|
+
aria-label={label}
|
|
353
|
+
aria-haspopup="menu"
|
|
354
|
+
aria-expanded={open}
|
|
355
|
+
aria-controls={menuId}
|
|
356
|
+
data-open={open || undefined}
|
|
357
|
+
{onclick}
|
|
358
|
+
{onpointerdown}
|
|
359
|
+
onpointermove={(e) => slide.move(e)}
|
|
360
|
+
{onpointerup}
|
|
361
|
+
onpointercancel={() => slide.cancel()}
|
|
362
|
+
onkeydown={ontriggerkey}
|
|
363
|
+
>
|
|
364
|
+
{@render face()}
|
|
365
|
+
</button>
|
|
366
|
+
|
|
367
|
+
<div bind:this={panel} popover="manual" class="radial-panel" data-radial-panel>
|
|
368
|
+
{#if open}
|
|
369
|
+
{#if scrim}
|
|
370
|
+
<Scrim onclose={onscrim} z={0} label="Close {label}" />
|
|
371
|
+
{/if}
|
|
372
|
+
{#if fab}
|
|
373
|
+
<span
|
|
374
|
+
class="radial-fab radial-ghost"
|
|
375
|
+
aria-hidden="true"
|
|
376
|
+
style:--radial-ring={ring}
|
|
377
|
+
style:left="{fab.left}px"
|
|
378
|
+
style:top="{fab.top}px"
|
|
379
|
+
style:width="{fab.width}px"
|
|
380
|
+
style:height="{fab.height}px"
|
|
381
|
+
>
|
|
382
|
+
{@render face()}
|
|
383
|
+
</span>
|
|
384
|
+
{/if}
|
|
385
|
+
{#if status && statusAt}
|
|
386
|
+
<div bind:this={statusEl} class="radial-status" style:left="{statusAt.x}px" style:top="{statusAt.y}px">
|
|
387
|
+
{@render status()}
|
|
388
|
+
</div>
|
|
389
|
+
{/if}
|
|
390
|
+
{/if}
|
|
391
|
+
<div id={menuId} role="menu" aria-label={label} class="radial-menu">
|
|
392
|
+
{#if open}
|
|
393
|
+
{#each petals as item, i (item.label)}
|
|
394
|
+
{@const p = points[i]}
|
|
395
|
+
{#if p}
|
|
396
|
+
{@const glyphs = menuItemGlyphs(item)}
|
|
397
|
+
<button
|
|
398
|
+
type="button"
|
|
399
|
+
{...item.attrs}
|
|
400
|
+
role={item.pressed === undefined ? 'menuitem' : 'menuitemcheckbox'}
|
|
401
|
+
aria-checked={item.pressed === undefined ? undefined : item.pressed}
|
|
402
|
+
class="radial-petal"
|
|
403
|
+
class:danger={item.danger}
|
|
404
|
+
class:on={item.pressed}
|
|
405
|
+
class:armed={armed === i}
|
|
406
|
+
data-armed={armed === i || undefined}
|
|
407
|
+
disabled={item.disabled}
|
|
408
|
+
tabindex={i === active ? 0 : -1}
|
|
409
|
+
style:left="{p.x}px"
|
|
410
|
+
style:top="{p.y}px"
|
|
411
|
+
style:--radial-i={i}
|
|
412
|
+
onclick={() => activate(item)}
|
|
413
|
+
onkeydown={onpetalkey}
|
|
414
|
+
onfocus={() => (active = i)}
|
|
415
|
+
>
|
|
416
|
+
{#if item.content}
|
|
417
|
+
{@render item.content(item)}
|
|
418
|
+
{:else}
|
|
419
|
+
<span class="radial-glyph" aria-hidden="true">
|
|
420
|
+
{#if glyphs.leading === 'icon' && item.icon}
|
|
421
|
+
<Icon name={item.icon} size={16} />
|
|
422
|
+
{:else if glyphs.leading === 'check'}
|
|
423
|
+
<Icon name={item.pressed ? 'check' : 'minus'} size={16} />
|
|
424
|
+
{/if}
|
|
425
|
+
</span>
|
|
426
|
+
<span class="radial-label">{item.label}</span>
|
|
427
|
+
{/if}
|
|
428
|
+
</button>
|
|
429
|
+
{/if}
|
|
430
|
+
{/each}
|
|
431
|
+
{/if}
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<style>
|
|
436
|
+
.radial-fab {
|
|
437
|
+
--radial-reach: var(--radial-radius, 8.5rem);
|
|
438
|
+
position: relative;
|
|
439
|
+
display: inline-flex;
|
|
440
|
+
flex-direction: column;
|
|
441
|
+
align-items: center;
|
|
442
|
+
justify-content: center;
|
|
443
|
+
gap: 1px;
|
|
444
|
+
flex: none;
|
|
445
|
+
width: var(--radial-fab-size, 3.75rem);
|
|
446
|
+
height: var(--radial-fab-size, 3.75rem);
|
|
447
|
+
min-width: var(--touch-target);
|
|
448
|
+
min-height: var(--touch-target);
|
|
449
|
+
padding: 0;
|
|
450
|
+
border: 2px solid var(--radial-ring, var(--accent));
|
|
451
|
+
border-radius: 50%;
|
|
452
|
+
background: var(--bg-elevated-2);
|
|
453
|
+
color: var(--text);
|
|
454
|
+
box-shadow: var(--shadow-lg);
|
|
455
|
+
cursor: pointer;
|
|
456
|
+
touch-action: none;
|
|
457
|
+
user-select: none;
|
|
458
|
+
-webkit-touch-callout: none;
|
|
459
|
+
}
|
|
460
|
+
.radial-fab:focus-visible {
|
|
461
|
+
outline: var(--focus-ring);
|
|
462
|
+
outline-offset: var(--focus-ring-offset);
|
|
463
|
+
}
|
|
464
|
+
.radial-fab:disabled {
|
|
465
|
+
opacity: 0.45;
|
|
466
|
+
cursor: not-allowed;
|
|
467
|
+
}
|
|
468
|
+
.radial-caption {
|
|
469
|
+
font-size: var(--fs-xs);
|
|
470
|
+
line-height: 1;
|
|
471
|
+
color: var(--text-muted);
|
|
472
|
+
white-space: nowrap;
|
|
473
|
+
}
|
|
474
|
+
.radial-panel {
|
|
475
|
+
position: fixed;
|
|
476
|
+
inset: 0;
|
|
477
|
+
width: auto;
|
|
478
|
+
height: auto;
|
|
479
|
+
max-width: none;
|
|
480
|
+
max-height: none;
|
|
481
|
+
margin: 0;
|
|
482
|
+
padding: var(--safe-top) var(--safe-right) var(--safe-bottom) var(--safe-left);
|
|
483
|
+
border: 0;
|
|
484
|
+
background: transparent;
|
|
485
|
+
color: var(--text);
|
|
486
|
+
overflow: visible;
|
|
487
|
+
z-index: var(--z-modal);
|
|
488
|
+
}
|
|
489
|
+
.radial-ghost {
|
|
490
|
+
position: fixed;
|
|
491
|
+
pointer-events: none;
|
|
492
|
+
min-width: 0;
|
|
493
|
+
min-height: 0;
|
|
494
|
+
}
|
|
495
|
+
.radial-status {
|
|
496
|
+
position: fixed;
|
|
497
|
+
max-width: calc(100vw - 2 * var(--sp-2));
|
|
498
|
+
transform: translate(-50%, -100%);
|
|
499
|
+
font-size: var(--fs-sm);
|
|
500
|
+
color: var(--text-muted);
|
|
501
|
+
text-align: center;
|
|
502
|
+
white-space: nowrap;
|
|
503
|
+
overflow: hidden;
|
|
504
|
+
text-overflow: ellipsis;
|
|
505
|
+
pointer-events: none;
|
|
506
|
+
}
|
|
507
|
+
.radial-menu {
|
|
508
|
+
position: fixed;
|
|
509
|
+
inset: 0;
|
|
510
|
+
pointer-events: none;
|
|
511
|
+
}
|
|
512
|
+
.radial-petal {
|
|
513
|
+
position: fixed;
|
|
514
|
+
display: flex;
|
|
515
|
+
flex-direction: column;
|
|
516
|
+
align-items: center;
|
|
517
|
+
justify-content: center;
|
|
518
|
+
gap: 2px;
|
|
519
|
+
width: max(var(--radial-petal-size, 3.5rem), var(--touch-target));
|
|
520
|
+
height: max(var(--radial-petal-size, 3.5rem), var(--touch-target));
|
|
521
|
+
padding: 0 2px;
|
|
522
|
+
border: 1px solid var(--border-strong);
|
|
523
|
+
border-radius: 50%;
|
|
524
|
+
background: var(--bg-elevated);
|
|
525
|
+
color: var(--text);
|
|
526
|
+
box-shadow: var(--shadow-md);
|
|
527
|
+
font-size: var(--fs-xs);
|
|
528
|
+
line-height: 1.1;
|
|
529
|
+
translate: -50% -50%;
|
|
530
|
+
pointer-events: auto;
|
|
531
|
+
cursor: pointer;
|
|
532
|
+
animation: radial-in 0.16s var(--ease) both;
|
|
533
|
+
animation-delay: calc(var(--radial-i, 0) * 18ms);
|
|
534
|
+
}
|
|
535
|
+
@keyframes radial-in {
|
|
536
|
+
from {
|
|
537
|
+
opacity: 0;
|
|
538
|
+
scale: 0.4;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
.radial-glyph {
|
|
542
|
+
display: inline-flex;
|
|
543
|
+
color: var(--text-muted);
|
|
544
|
+
}
|
|
545
|
+
.radial-label {
|
|
546
|
+
max-width: 100%;
|
|
547
|
+
overflow: hidden;
|
|
548
|
+
text-overflow: ellipsis;
|
|
549
|
+
white-space: nowrap;
|
|
550
|
+
}
|
|
551
|
+
.radial-petal:hover:not(:disabled),
|
|
552
|
+
.radial-petal.armed {
|
|
553
|
+
background: var(--bg-elevated-2);
|
|
554
|
+
border-color: var(--accent);
|
|
555
|
+
}
|
|
556
|
+
.radial-petal:focus-visible {
|
|
557
|
+
outline: var(--focus-ring);
|
|
558
|
+
outline-offset: var(--focus-ring-offset);
|
|
559
|
+
}
|
|
560
|
+
.radial-petal.on {
|
|
561
|
+
border-color: var(--accent);
|
|
562
|
+
}
|
|
563
|
+
.radial-petal.danger,
|
|
564
|
+
.radial-petal.danger .radial-glyph {
|
|
565
|
+
color: var(--danger);
|
|
566
|
+
}
|
|
567
|
+
.radial-petal.danger {
|
|
568
|
+
border-color: var(--danger);
|
|
569
|
+
}
|
|
570
|
+
.radial-petal:disabled {
|
|
571
|
+
opacity: 0.45;
|
|
572
|
+
cursor: not-allowed;
|
|
573
|
+
}
|
|
574
|
+
@media (prefers-reduced-motion: reduce) {
|
|
575
|
+
.radial-petal {
|
|
576
|
+
animation: none;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
</style>
|