svelte-p5-components 0.4.1 → 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.
@@ -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;