svelte-p5-components 0.4.1 → 0.6.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 +20 -0
- package/dist/ActivityBar.svelte +227 -0
- package/dist/ActivityBar.svelte.d.ts +66 -0
- package/dist/ContextMenu.svelte +439 -0
- package/dist/ContextMenu.svelte.d.ts +80 -0
- package/dist/EntityToggleList.svelte +175 -21
- package/dist/EntityToggleList.svelte.d.ts +7 -0
- package/dist/SidePanel.svelte +272 -0
- package/dist/SidePanel.svelte.d.ts +59 -0
- package/dist/Sketch.svelte +20 -18
- package/dist/Sketch.svelte.d.ts +23 -12
- package/dist/Sketch.test.svelte.d.ts +1 -0
- package/dist/Sketch.test.svelte.js +102 -0
- package/dist/TimelineScrubber.svelte +223 -70
- package/dist/TimelineScrubber.svelte.d.ts +32 -8
- package/dist/TimelineScrubber.test.svelte.js +4 -3
- package/dist/TimelineTrack.svelte +483 -150
- package/dist/TimelineTrack.svelte.d.ts +33 -18
- package/dist/TimelineTrack.test.svelte.js +70 -9
- package/dist/createMediaPlayback.svelte.d.ts +60 -0
- package/dist/createMediaPlayback.svelte.js +169 -0
- package/dist/createMediaPlayback.test.d.ts +1 -0
- package/dist/createMediaPlayback.test.js +176 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/sketch-types.d.ts +28 -0
- package/dist/sketch-types.js +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One entry rendered as a row in the context menu.
|
|
6
|
+
*/
|
|
7
|
+
export interface ContextMenuItem {
|
|
8
|
+
/** Stable id passed back via `onSelect`. */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Human-readable label. */
|
|
11
|
+
label: string;
|
|
12
|
+
/** Optional leading icon. */
|
|
13
|
+
icon?: Snippet;
|
|
14
|
+
/** When true the item is rendered but not activatable. */
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
/** When true the item is rendered with a destructive accent. */
|
|
17
|
+
danger?: boolean;
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<script lang="ts">
|
|
22
|
+
/**
|
|
23
|
+
* Lightweight floating action menu, anchored to a viewport point.
|
|
24
|
+
*
|
|
25
|
+
* Use for Figma-style selection-contextual chrome: click a thing on a
|
|
26
|
+
* canvas, get actions near the cursor. Positioning is viewport-fixed
|
|
27
|
+
* (`position: fixed`) so the menu does not scroll with the page. The
|
|
28
|
+
* menu auto-flips horizontally and/or vertically when it would overflow
|
|
29
|
+
* the viewport — mirroring `HoverTooltip`'s edge-aware layout.
|
|
30
|
+
*
|
|
31
|
+
* Close affordances:
|
|
32
|
+
* - Pointerdown outside the menu
|
|
33
|
+
* - Escape keypress
|
|
34
|
+
* - Window blur, scroll, or resize
|
|
35
|
+
* - Selecting an enabled item
|
|
36
|
+
* - Caller sets `open = false`
|
|
37
|
+
*
|
|
38
|
+
* Keyboard:
|
|
39
|
+
* - Up / Down arrow moves focus between enabled items (wraps)
|
|
40
|
+
* - Home / End jump to first / last enabled item
|
|
41
|
+
* - Enter or Space activates the focused item
|
|
42
|
+
* - Escape closes
|
|
43
|
+
*
|
|
44
|
+
* Public class-name contract (stable across versions):
|
|
45
|
+
* .context-menu
|
|
46
|
+
* .context-menu__list
|
|
47
|
+
* .context-menu__item
|
|
48
|
+
* .context-menu__item--disabled
|
|
49
|
+
* .context-menu__item--danger
|
|
50
|
+
* .context-menu__item-icon
|
|
51
|
+
* .context-menu__item-label
|
|
52
|
+
*
|
|
53
|
+
* @example
|
|
54
|
+
* ```svelte
|
|
55
|
+
* <ContextMenu
|
|
56
|
+
* bind:open
|
|
57
|
+
* anchor={{ x: evt.clientX, y: evt.clientY }}
|
|
58
|
+
* items={[
|
|
59
|
+
* { id: 'seek', label: 'Seek video here' },
|
|
60
|
+
* { id: 'filter', label: 'Filter to speaker' }
|
|
61
|
+
* ]}
|
|
62
|
+
* onSelect={(id) => doAction(id)}
|
|
63
|
+
* onClose={() => (open = false)}
|
|
64
|
+
* />
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
interface Props {
|
|
68
|
+
/** Whether the menu is visible. Bindable. */
|
|
69
|
+
open: boolean;
|
|
70
|
+
/** Anchor point in client (page) coordinates. Menu top-left aligns here until flipped. */
|
|
71
|
+
anchor: { x: number; y: number } | null;
|
|
72
|
+
/** Menu entries. */
|
|
73
|
+
items: ContextMenuItem[];
|
|
74
|
+
/** Fired with the activated item id. */
|
|
75
|
+
onSelect?: (id: string) => void;
|
|
76
|
+
/** Fired whenever the menu should close (Escape, outside click, blur, etc.). */
|
|
77
|
+
onClose?: () => void;
|
|
78
|
+
class?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let {
|
|
82
|
+
open = $bindable(false),
|
|
83
|
+
anchor,
|
|
84
|
+
items,
|
|
85
|
+
onSelect,
|
|
86
|
+
onClose,
|
|
87
|
+
class: className = ''
|
|
88
|
+
}: Props = $props();
|
|
89
|
+
|
|
90
|
+
let menuEl: HTMLDivElement | null = $state(null);
|
|
91
|
+
let measuredWidth = $state(0);
|
|
92
|
+
let measuredHeight = $state(0);
|
|
93
|
+
let focusedIndex = $state(-1);
|
|
94
|
+
let previouslyFocused: HTMLElement | null = null;
|
|
95
|
+
|
|
96
|
+
// Respect prefers-reduced-motion. Detected once per mount; re-reading is
|
|
97
|
+
// cheap but unnecessary — animation duration is the only thing gated on
|
|
98
|
+
// this so the CSS handles the reduced case via a @media rule anyway.
|
|
99
|
+
const prefersReducedMotion =
|
|
100
|
+
typeof window !== 'undefined' &&
|
|
101
|
+
typeof window.matchMedia === 'function' &&
|
|
102
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
103
|
+
|
|
104
|
+
// Measure after mount so auto-flip has real dimensions to work with. The
|
|
105
|
+
// ResizeObserver also catches the case where items / labels change while
|
|
106
|
+
// the menu is open.
|
|
107
|
+
$effect(() => {
|
|
108
|
+
if (!menuEl) return;
|
|
109
|
+
const el = menuEl;
|
|
110
|
+
const ro = new ResizeObserver(() => {
|
|
111
|
+
const r = el.getBoundingClientRect();
|
|
112
|
+
measuredWidth = r.width;
|
|
113
|
+
measuredHeight = r.height;
|
|
114
|
+
});
|
|
115
|
+
ro.observe(el);
|
|
116
|
+
const initial = el.getBoundingClientRect();
|
|
117
|
+
measuredWidth = initial.width;
|
|
118
|
+
measuredHeight = initial.height;
|
|
119
|
+
return () => ro.disconnect();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Focus + dismiss wiring. Only attaches while open, and captures the
|
|
123
|
+
// previously-focused element so we can restore focus on close.
|
|
124
|
+
$effect(() => {
|
|
125
|
+
if (!open) return;
|
|
126
|
+
previouslyFocused = (document.activeElement as HTMLElement | null) ?? null;
|
|
127
|
+
const firstEnabled = items.findIndex((it) => !it.disabled);
|
|
128
|
+
focusedIndex = firstEnabled;
|
|
129
|
+
|
|
130
|
+
// Attach after a microtask so our own opening pointerdown doesn't
|
|
131
|
+
// immediately get interpreted as an outside click.
|
|
132
|
+
let attached = false;
|
|
133
|
+
const attach = () => {
|
|
134
|
+
if (attached) return;
|
|
135
|
+
attached = true;
|
|
136
|
+
document.addEventListener('pointerdown', handleDocumentPointerDown, true);
|
|
137
|
+
window.addEventListener('blur', handleWindowBlur);
|
|
138
|
+
window.addEventListener('resize', handleWindowResize);
|
|
139
|
+
window.addEventListener('scroll', handleWindowScroll, true);
|
|
140
|
+
};
|
|
141
|
+
const handle = requestAnimationFrame(attach);
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
cancelAnimationFrame(handle);
|
|
145
|
+
if (attached) {
|
|
146
|
+
document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
|
|
147
|
+
window.removeEventListener('blur', handleWindowBlur);
|
|
148
|
+
window.removeEventListener('resize', handleWindowResize);
|
|
149
|
+
window.removeEventListener('scroll', handleWindowScroll, true);
|
|
150
|
+
}
|
|
151
|
+
// Restore focus to whatever owned it before the menu opened.
|
|
152
|
+
if (previouslyFocused && typeof previouslyFocused.focus === 'function') {
|
|
153
|
+
previouslyFocused.focus({ preventScroll: true });
|
|
154
|
+
}
|
|
155
|
+
previouslyFocused = null;
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// When the focused index changes, actually move DOM focus to that item so
|
|
160
|
+
// keyboard users see a visible focus ring and activate on Enter.
|
|
161
|
+
$effect(() => {
|
|
162
|
+
if (!open) return;
|
|
163
|
+
if (focusedIndex < 0 || !menuEl) return;
|
|
164
|
+
const buttons = menuEl.querySelectorAll<HTMLButtonElement>('[data-context-menu-item]');
|
|
165
|
+
const target = buttons[focusedIndex];
|
|
166
|
+
if (target && document.activeElement !== target) {
|
|
167
|
+
target.focus({ preventScroll: true });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const placement = $derived.by<{ left: number; top: number } | null>(() => {
|
|
172
|
+
if (!anchor) return null;
|
|
173
|
+
if (typeof window === 'undefined') return null;
|
|
174
|
+
|
|
175
|
+
const vpW = window.innerWidth;
|
|
176
|
+
const vpH = window.innerHeight;
|
|
177
|
+
const pad = 4;
|
|
178
|
+
const { x, y } = anchor;
|
|
179
|
+
|
|
180
|
+
// Horizontal: prefer the right of the anchor; flip to the left if it
|
|
181
|
+
// would overflow. Clamp inside the viewport either way.
|
|
182
|
+
let left = x;
|
|
183
|
+
if (measuredWidth > 0 && left + measuredWidth + pad > vpW) {
|
|
184
|
+
left = x - measuredWidth;
|
|
185
|
+
}
|
|
186
|
+
left = Math.max(pad, Math.min(vpW - measuredWidth - pad, left));
|
|
187
|
+
|
|
188
|
+
// Vertical: prefer below the anchor; flip above if it would overflow.
|
|
189
|
+
let top = y;
|
|
190
|
+
if (measuredHeight > 0 && top + measuredHeight + pad > vpH) {
|
|
191
|
+
top = y - measuredHeight;
|
|
192
|
+
}
|
|
193
|
+
top = Math.max(pad, Math.min(vpH - measuredHeight - pad, top));
|
|
194
|
+
|
|
195
|
+
return { left, top };
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
function closeMenu() {
|
|
199
|
+
onClose?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function handleDocumentPointerDown(event: PointerEvent) {
|
|
203
|
+
if (!menuEl) return;
|
|
204
|
+
const target = event.target as Node | null;
|
|
205
|
+
if (target && menuEl.contains(target)) return;
|
|
206
|
+
closeMenu();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function handleWindowBlur() {
|
|
210
|
+
closeMenu();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function handleWindowResize() {
|
|
214
|
+
closeMenu();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function handleWindowScroll() {
|
|
218
|
+
closeMenu();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function nextEnabled(from: number, dir: 1 | -1): number {
|
|
222
|
+
const n = items.length;
|
|
223
|
+
if (n === 0) return -1;
|
|
224
|
+
let idx = from;
|
|
225
|
+
for (let i = 0; i < n; i++) {
|
|
226
|
+
idx = (idx + dir + n) % n;
|
|
227
|
+
const item = items[idx];
|
|
228
|
+
if (item && !item.disabled) return idx;
|
|
229
|
+
}
|
|
230
|
+
return -1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function firstEnabled(): number {
|
|
234
|
+
const idx = items.findIndex((it) => !it.disabled);
|
|
235
|
+
return idx;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function lastEnabled(): number {
|
|
239
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
240
|
+
const item = items[i];
|
|
241
|
+
if (item && !item.disabled) return i;
|
|
242
|
+
}
|
|
243
|
+
return -1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function handleMenuKeydown(event: KeyboardEvent) {
|
|
247
|
+
switch (event.key) {
|
|
248
|
+
case 'Escape': {
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
event.stopPropagation();
|
|
251
|
+
closeMenu();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
case 'ArrowDown': {
|
|
255
|
+
event.preventDefault();
|
|
256
|
+
focusedIndex = nextEnabled(focusedIndex, 1);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
case 'ArrowUp': {
|
|
260
|
+
event.preventDefault();
|
|
261
|
+
focusedIndex = nextEnabled(focusedIndex, -1);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case 'Home': {
|
|
265
|
+
event.preventDefault();
|
|
266
|
+
focusedIndex = firstEnabled();
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case 'End': {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
focusedIndex = lastEnabled();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
case 'Enter':
|
|
275
|
+
case ' ': {
|
|
276
|
+
if (focusedIndex < 0) return;
|
|
277
|
+
const it = items[focusedIndex];
|
|
278
|
+
if (!it || it.disabled) return;
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
activate(it.id);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function activate(id: string) {
|
|
287
|
+
onSelect?.(id);
|
|
288
|
+
closeMenu();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function handleItemClick(item: ContextMenuItem) {
|
|
292
|
+
if (item.disabled) return;
|
|
293
|
+
activate(item.id);
|
|
294
|
+
}
|
|
295
|
+
</script>
|
|
296
|
+
|
|
297
|
+
{#if open && anchor}
|
|
298
|
+
<div
|
|
299
|
+
bind:this={menuEl}
|
|
300
|
+
class="context-menu {className}"
|
|
301
|
+
class:context-menu--reduced-motion={prefersReducedMotion}
|
|
302
|
+
role="menu"
|
|
303
|
+
tabindex="-1"
|
|
304
|
+
style:left="{placement?.left ?? anchor.x}px"
|
|
305
|
+
style:top="{placement?.top ?? anchor.y}px"
|
|
306
|
+
style:visibility={placement ? 'visible' : 'hidden'}
|
|
307
|
+
onkeydown={handleMenuKeydown}
|
|
308
|
+
>
|
|
309
|
+
<ul class="context-menu__list">
|
|
310
|
+
{#each items as item, i (item.id)}
|
|
311
|
+
<li>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
data-context-menu-item
|
|
315
|
+
class="context-menu__item"
|
|
316
|
+
class:context-menu__item--disabled={item.disabled}
|
|
317
|
+
class:context-menu__item--danger={item.danger}
|
|
318
|
+
role="menuitem"
|
|
319
|
+
aria-disabled={item.disabled ? 'true' : undefined}
|
|
320
|
+
tabindex={focusedIndex === i ? 0 : -1}
|
|
321
|
+
onclick={() => handleItemClick(item)}
|
|
322
|
+
onmouseenter={() => {
|
|
323
|
+
if (!item.disabled) focusedIndex = i;
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{#if item.icon}
|
|
327
|
+
<span class="context-menu__item-icon" aria-hidden="true">
|
|
328
|
+
{@render item.icon()}
|
|
329
|
+
</span>
|
|
330
|
+
{/if}
|
|
331
|
+
<span class="context-menu__item-label">{item.label}</span>
|
|
332
|
+
</button>
|
|
333
|
+
</li>
|
|
334
|
+
{/each}
|
|
335
|
+
</ul>
|
|
336
|
+
</div>
|
|
337
|
+
{/if}
|
|
338
|
+
|
|
339
|
+
<style>
|
|
340
|
+
.context-menu {
|
|
341
|
+
position: fixed;
|
|
342
|
+
z-index: 1100;
|
|
343
|
+
min-width: 180px;
|
|
344
|
+
max-width: 320px;
|
|
345
|
+
padding: 4px 0;
|
|
346
|
+
background: var(--context-menu-bg, #ffffff);
|
|
347
|
+
color: var(--context-menu-fg, #111);
|
|
348
|
+
border: 1px solid var(--context-menu-border, rgba(0, 0, 0, 0.1));
|
|
349
|
+
border-radius: 8px;
|
|
350
|
+
box-shadow:
|
|
351
|
+
0 8px 20px rgba(0, 0, 0, 0.14),
|
|
352
|
+
0 2px 4px rgba(0, 0, 0, 0.06);
|
|
353
|
+
font:
|
|
354
|
+
13px/1.3 system-ui,
|
|
355
|
+
-apple-system,
|
|
356
|
+
Segoe UI,
|
|
357
|
+
sans-serif;
|
|
358
|
+
user-select: none;
|
|
359
|
+
animation: context-menu-in 100ms ease-out;
|
|
360
|
+
transform-origin: top left;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.context-menu--reduced-motion {
|
|
364
|
+
animation: none;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@keyframes context-menu-in {
|
|
368
|
+
from {
|
|
369
|
+
transform: scale(0.96);
|
|
370
|
+
opacity: 0;
|
|
371
|
+
}
|
|
372
|
+
to {
|
|
373
|
+
transform: scale(1);
|
|
374
|
+
opacity: 1;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
@media (prefers-reduced-motion: reduce) {
|
|
379
|
+
.context-menu {
|
|
380
|
+
animation: none;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.context-menu__list {
|
|
385
|
+
list-style: none;
|
|
386
|
+
margin: 0;
|
|
387
|
+
padding: 0;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.context-menu__item {
|
|
391
|
+
display: flex;
|
|
392
|
+
align-items: center;
|
|
393
|
+
gap: 8px;
|
|
394
|
+
width: 100%;
|
|
395
|
+
padding: 6px 12px;
|
|
396
|
+
border: none;
|
|
397
|
+
background: transparent;
|
|
398
|
+
color: inherit;
|
|
399
|
+
text-align: left;
|
|
400
|
+
font: inherit;
|
|
401
|
+
cursor: pointer;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.context-menu__item:hover:not(.context-menu__item--disabled),
|
|
405
|
+
.context-menu__item:focus-visible:not(.context-menu__item--disabled) {
|
|
406
|
+
background: var(--context-menu-item-hover-bg, rgba(59, 130, 246, 0.12));
|
|
407
|
+
color: var(--context-menu-item-hover-fg, inherit);
|
|
408
|
+
outline: none;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.context-menu__item--disabled {
|
|
412
|
+
color: var(--context-menu-item-disabled-fg, rgba(0, 0, 0, 0.38));
|
|
413
|
+
cursor: default;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.context-menu__item--danger {
|
|
417
|
+
color: var(--context-menu-item-danger-fg, #dc2626);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.context-menu__item--danger:hover:not(.context-menu__item--disabled),
|
|
421
|
+
.context-menu__item--danger:focus-visible:not(.context-menu__item--disabled) {
|
|
422
|
+
background: var(--context-menu-item-danger-hover-bg, rgba(220, 38, 38, 0.1));
|
|
423
|
+
color: var(--context-menu-item-danger-fg, #dc2626);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.context-menu__item-icon {
|
|
427
|
+
display: inline-flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
justify-content: center;
|
|
430
|
+
flex: 0 0 auto;
|
|
431
|
+
width: 16px;
|
|
432
|
+
height: 16px;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.context-menu__item-label {
|
|
436
|
+
flex: 1 1 auto;
|
|
437
|
+
min-width: 0;
|
|
438
|
+
}
|
|
439
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* One entry rendered as a row in the context menu.
|
|
4
|
+
*/
|
|
5
|
+
export interface ContextMenuItem {
|
|
6
|
+
/** Stable id passed back via `onSelect`. */
|
|
7
|
+
id: string;
|
|
8
|
+
/** Human-readable label. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Optional leading icon. */
|
|
11
|
+
icon?: Snippet;
|
|
12
|
+
/** When true the item is rendered but not activatable. */
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
/** When true the item is rendered with a destructive accent. */
|
|
15
|
+
danger?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Lightweight floating action menu, anchored to a viewport point.
|
|
19
|
+
*
|
|
20
|
+
* Use for Figma-style selection-contextual chrome: click a thing on a
|
|
21
|
+
* canvas, get actions near the cursor. Positioning is viewport-fixed
|
|
22
|
+
* (`position: fixed`) so the menu does not scroll with the page. The
|
|
23
|
+
* menu auto-flips horizontally and/or vertically when it would overflow
|
|
24
|
+
* the viewport — mirroring `HoverTooltip`'s edge-aware layout.
|
|
25
|
+
*
|
|
26
|
+
* Close affordances:
|
|
27
|
+
* - Pointerdown outside the menu
|
|
28
|
+
* - Escape keypress
|
|
29
|
+
* - Window blur, scroll, or resize
|
|
30
|
+
* - Selecting an enabled item
|
|
31
|
+
* - Caller sets `open = false`
|
|
32
|
+
*
|
|
33
|
+
* Keyboard:
|
|
34
|
+
* - Up / Down arrow moves focus between enabled items (wraps)
|
|
35
|
+
* - Home / End jump to first / last enabled item
|
|
36
|
+
* - Enter or Space activates the focused item
|
|
37
|
+
* - Escape closes
|
|
38
|
+
*
|
|
39
|
+
* Public class-name contract (stable across versions):
|
|
40
|
+
* .context-menu
|
|
41
|
+
* .context-menu__list
|
|
42
|
+
* .context-menu__item
|
|
43
|
+
* .context-menu__item--disabled
|
|
44
|
+
* .context-menu__item--danger
|
|
45
|
+
* .context-menu__item-icon
|
|
46
|
+
* .context-menu__item-label
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```svelte
|
|
50
|
+
* <ContextMenu
|
|
51
|
+
* bind:open
|
|
52
|
+
* anchor={{ x: evt.clientX, y: evt.clientY }}
|
|
53
|
+
* items={[
|
|
54
|
+
* { id: 'seek', label: 'Seek video here' },
|
|
55
|
+
* { id: 'filter', label: 'Filter to speaker' }
|
|
56
|
+
* ]}
|
|
57
|
+
* onSelect={(id) => doAction(id)}
|
|
58
|
+
* onClose={() => (open = false)}
|
|
59
|
+
* />
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
interface Props {
|
|
63
|
+
/** Whether the menu is visible. Bindable. */
|
|
64
|
+
open: boolean;
|
|
65
|
+
/** Anchor point in client (page) coordinates. Menu top-left aligns here until flipped. */
|
|
66
|
+
anchor: {
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
} | null;
|
|
70
|
+
/** Menu entries. */
|
|
71
|
+
items: ContextMenuItem[];
|
|
72
|
+
/** Fired with the activated item id. */
|
|
73
|
+
onSelect?: (id: string) => void;
|
|
74
|
+
/** Fired whenever the menu should close (Escape, outside click, blur, etc.). */
|
|
75
|
+
onClose?: () => void;
|
|
76
|
+
class?: string;
|
|
77
|
+
}
|
|
78
|
+
declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
|
|
79
|
+
type ContextMenu = ReturnType<typeof ContextMenu>;
|
|
80
|
+
export default ContextMenu;
|