@dorsk/tsumikit 0.67.0 → 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 +104 -7
- package/dist/components/atoms/Button.svelte +118 -10
- 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/FileButton.svelte +2 -2
- package/dist/components/molecules/FileButton.svelte.d.ts +1 -1
- package/dist/components/molecules/GlyphStack.svelte +12 -19
- package/dist/components/molecules/IconButton.svelte +1 -1
- package/dist/components/molecules/Popover.svelte +5 -6
- package/dist/components/molecules/Popover.svelte.d.ts +1 -1
- 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
|
@@ -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>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface RadialArc {
|
|
2
|
+
/** Degrees, 0 = right, counter-clockwise. */
|
|
3
|
+
from: number;
|
|
4
|
+
to: number;
|
|
5
|
+
}
|
|
6
|
+
import { type Snippet } from 'svelte';
|
|
7
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
8
|
+
import { type IconName } from '../atoms/Icon.svelte';
|
|
9
|
+
import type { MenuItem } from './Menu.svelte';
|
|
10
|
+
import { type Tone } from '../../tone';
|
|
11
|
+
type Own = {
|
|
12
|
+
items: MenuItem[];
|
|
13
|
+
/** Accessible name of the trigger and the menu. */
|
|
14
|
+
label: string;
|
|
15
|
+
open?: boolean;
|
|
16
|
+
onopen?: () => void;
|
|
17
|
+
onclose?: () => void;
|
|
18
|
+
/** Replaces the default FAB content. */
|
|
19
|
+
trigger?: Snippet<[{
|
|
20
|
+
open: boolean;
|
|
21
|
+
}]>;
|
|
22
|
+
icon?: IconName;
|
|
23
|
+
closeIcon?: IconName;
|
|
24
|
+
/** Small line under the FAB glyph ("● bot"). */
|
|
25
|
+
caption?: Snippet | string;
|
|
26
|
+
/** Colour of the FAB ring. */
|
|
27
|
+
tone?: Tone;
|
|
28
|
+
arc?: RadialArc;
|
|
29
|
+
/** Preferred arc radius; defaults to `--radial-radius`, else 8.5rem. */
|
|
30
|
+
radius?: string;
|
|
31
|
+
/** Petal diameter; defaults to `--radial-petal-size`, else 3.5rem. */
|
|
32
|
+
petalSize?: string;
|
|
33
|
+
scrim?: boolean;
|
|
34
|
+
/** A line centred above the arc while open. */
|
|
35
|
+
status?: Snippet;
|
|
36
|
+
class?: string;
|
|
37
|
+
style?: string;
|
|
38
|
+
};
|
|
39
|
+
type $$ComponentProps = Omit<HTMLButtonAttributes, keyof Own> & Own;
|
|
40
|
+
declare const RadialMenu: import("svelte").Component<$$ComponentProps, {}, "open">;
|
|
41
|
+
type RadialMenu = ReturnType<typeof RadialMenu>;
|
|
42
|
+
export default RadialMenu;
|