svelte-p5-components 0.4.0 → 0.5.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 +10 -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/DraggableWindow.svelte +63 -25
- 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/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 +5 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -72,36 +72,59 @@
|
|
|
72
72
|
let size = $state(untrack(() => ({ w: typeof width === 'number' ? width : 480, h: 40 })));
|
|
73
73
|
let rootEl: HTMLDivElement | null = $state(null);
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
75
|
+
// Clamp `pos` back inside whatever sandbox `constrained` points at.
|
|
76
|
+
// Runs on drag end (drag might overshoot during pointer-move) AND on
|
|
77
|
+
// any resize of the sandbox (window → viewport mode, parent element →
|
|
78
|
+
// parent mode) so the window snaps back into view when its container
|
|
79
|
+
// shrinks underneath it.
|
|
80
|
+
function clamp() {
|
|
81
|
+
if (typeof window === 'undefined' || !rootEl) return;
|
|
82
|
+
|
|
83
|
+
if (constrained === 'parent') {
|
|
84
|
+
// Position is relative to the parent's padding-box, so bounds
|
|
85
|
+
// are [0, parentSize - windowSize]. Falls through to no-op if
|
|
86
|
+
// the window is somehow larger than the parent (Math.max(0, ...)).
|
|
87
|
+
const parent = rootEl.parentElement;
|
|
88
|
+
if (!parent) return;
|
|
89
|
+
const r = parent.getBoundingClientRect();
|
|
90
|
+
const maxX = Math.max(0, r.width - size.w);
|
|
91
|
+
const maxY = Math.max(0, r.height - size.h);
|
|
92
|
+
pos = {
|
|
93
|
+
x: Math.max(0, Math.min(maxX, pos.x)),
|
|
94
|
+
y: Math.max(0, Math.min(maxY, pos.y))
|
|
95
|
+
};
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (constrained === 'viewport') {
|
|
100
|
+
// Never allow the titlebar above the viewport top - that's the
|
|
101
|
+
// "unreachable" case. Allow partial horizontal overflow so narrow
|
|
102
|
+
// screens don't trap wide windows, but always keep `minVisible`
|
|
103
|
+
// pixels of the window on-screen for a grab handle.
|
|
104
|
+
const vw = window.innerWidth;
|
|
105
|
+
const vh = window.innerHeight;
|
|
106
|
+
const minX = -(size.w - minVisible);
|
|
107
|
+
const maxX = vw - minVisible;
|
|
108
|
+
const minY = 0;
|
|
109
|
+
const maxY = vh - minVisible;
|
|
110
|
+
pos = {
|
|
111
|
+
x: Math.max(minX, Math.min(maxX, pos.x)),
|
|
112
|
+
y: Math.max(minY, Math.min(maxY, pos.y))
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// 'none': no clamping.
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
// Keep size fresh (used for clamp math) - ResizeObserver on our own
|
|
98
|
-
// root element catches CSS resize-from-corner too.
|
|
119
|
+
// root element catches CSS resize-from-corner too. We also re-clamp
|
|
120
|
+
// after each size change so corner-resizing past a bound snaps back.
|
|
99
121
|
$effect(() => {
|
|
100
122
|
if (!rootEl) return;
|
|
101
123
|
const el = rootEl;
|
|
102
124
|
const ro = new ResizeObserver(() => {
|
|
103
125
|
const r = el.getBoundingClientRect();
|
|
104
126
|
size = { w: r.width, h: r.height };
|
|
127
|
+
clamp();
|
|
105
128
|
});
|
|
106
129
|
ro.observe(el);
|
|
107
130
|
// seed
|
|
@@ -110,15 +133,30 @@
|
|
|
110
133
|
return () => ro.disconnect();
|
|
111
134
|
});
|
|
112
135
|
|
|
113
|
-
// Re-clamp on window resize -
|
|
114
|
-
//
|
|
136
|
+
// Re-clamp on window resize - the "snap back when the viewport shrinks"
|
|
137
|
+
// case. Harmless in parent mode (clamp() handles parent bounds too), so
|
|
138
|
+
// we keep a single global listener instead of branching.
|
|
115
139
|
$effect(() => {
|
|
116
140
|
if (typeof window === 'undefined') return;
|
|
117
|
-
const onResize = () =>
|
|
141
|
+
const onResize = () => clamp();
|
|
118
142
|
window.addEventListener('resize', onResize);
|
|
119
143
|
return () => window.removeEventListener('resize', onResize);
|
|
120
144
|
});
|
|
121
145
|
|
|
146
|
+
// Parent mode: watch the parent element's size too. A flex/grid row
|
|
147
|
+
// resizing around us wouldn't trigger `window.resize`, so without this
|
|
148
|
+
// the window can end up stranded outside a shrunken container until the
|
|
149
|
+
// user nudges it.
|
|
150
|
+
$effect(() => {
|
|
151
|
+
if (typeof window === 'undefined') return;
|
|
152
|
+
if (constrained !== 'parent' || !rootEl) return;
|
|
153
|
+
const parent = rootEl.parentElement;
|
|
154
|
+
if (!parent) return;
|
|
155
|
+
const ro = new ResizeObserver(() => clamp());
|
|
156
|
+
ro.observe(parent);
|
|
157
|
+
return () => ro.disconnect();
|
|
158
|
+
});
|
|
159
|
+
|
|
122
160
|
// The neodrag `position` plugin takes a `current: {x, y}` and forces
|
|
123
161
|
// the element to that transform. Wrapped in a Compartment so updates
|
|
124
162
|
// to `pos` re-run just this plugin (not the whole plugin list).
|
|
@@ -132,7 +170,7 @@
|
|
|
132
170
|
onDrag: ({ offset }) => {
|
|
133
171
|
pos = { x: offset.x, y: offset.y };
|
|
134
172
|
},
|
|
135
|
-
onDragEnd: () =>
|
|
173
|
+
onDragEnd: () => clamp()
|
|
136
174
|
}),
|
|
137
175
|
...(constrained === 'viewport' ? [bounds(BoundsFrom.viewport())] : []),
|
|
138
176
|
...(constrained === 'parent' ? [bounds(BoundsFrom.parent())] : []),
|