@dorsk/tsumikit 0.67.1 → 0.68.1

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.
@@ -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
- href: string;
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
- /** Caps and centres the row of links. */
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
- function corner(dx: number, dy: number): number {
133
- if (Math.hypot(dx, dy) < SLIDE_THRESHOLD) return -1;
134
- const i = (dy < 0 ? 0 : 2) + (dx < 0 ? 0 : 1);
135
- return i < tiles.length ? i : -1;
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
- origin = { x: e.clientX, y: e.clientY };
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
- if (origin) armed = corner(e.clientX - origin.x, e.clientY - origin.y);
162
+ slide.move(e);
166
163
  }
167
164
 
168
165
  function onPointerUp() {
169
- if (!origin) return;
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
- origin = null;
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>