@joewinke/jatui 0.2.33 → 0.3.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.
- package/CLAUDE.md +25 -2
- package/dist/components/BulkActionBar.svelte +78 -0
- package/dist/components/BulkActionBar.svelte.d.ts +29 -0
- package/dist/components/ConfirmDialog.svelte +5 -1
- package/dist/components/ContextMenu.svelte +161 -20
- package/dist/components/ContextMenu.svelte.d.ts +1 -12
- package/dist/components/ContextMenu.types.d.ts +62 -0
- package/dist/components/ContextMenu.types.js +1 -0
- package/dist/components/DirtyFormHost.svelte +110 -0
- package/dist/components/DirtyFormHost.svelte.d.ts +65 -0
- package/dist/components/MediaLightbox.svelte +340 -0
- package/dist/components/MediaLightbox.svelte.d.ts +24 -0
- package/dist/components/Modal.svelte +8 -2
- package/dist/components/Modal.svelte.d.ts +7 -1
- package/dist/components/Tooltip.svelte +68 -0
- package/dist/components/Tooltip.svelte.d.ts +17 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +6 -0
- package/dist/stores/confirmDialog.d.ts +9 -2
- package/dist/styles/rail.css +13 -0
- package/dist/utils/dirtyGuard.d.ts +49 -0
- package/dist/utils/dirtyGuard.js +61 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -58,21 +58,25 @@ Then import:
|
|
|
58
58
|
| `Badge` | Status/label badge | `variant`, `size`, `children` (snippet) |
|
|
59
59
|
| `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
|
|
60
60
|
| `Card` | Content card container | `title`, `children` (snippet) |
|
|
61
|
-
| `Modal` | Dialog overlay | `open` (bindable), `title`, `children` (snippet) |
|
|
61
|
+
| `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number` |
|
|
62
62
|
| `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
|
|
63
63
|
| `Drawer` | Slide-out side panel | `open` (bindable), `side`, `children` (snippet) |
|
|
64
64
|
| `LoadingSpinner` | Spinner animation | `size`, `color` |
|
|
65
65
|
| `Sparkline` | Inline mini chart | `data: number[]`, `width`, `height`, `color` |
|
|
66
66
|
| `LazyImage` | Image with lazy loading + placeholder | `src`, `alt`, `placeholder` |
|
|
67
67
|
| `HunkDiffView` | Side-by-side diff with line numbers, red/green backgrounds, strikethrough on removed lines, inline word-level highlights via the `diff` lib. Used for reviewing YAML/text changes per hunk. | `oldText: string \| null`, `newText: string \| null`, `unified?: boolean` |
|
|
68
|
-
| `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `onClose?` |
|
|
68
|
+
| `ContextMenu` | Floating right-click (or programmatic) action menu, positioned via `use:floating` at a cursor point. Real `<button>`s, per-item `icon`/`variant`/`disabled`, dividers, click-outside + Escape dismiss, arrow-key/Home/End roving nav (focus starts on the first item). Items may declare `submit: SubmitConfig` to POST to a SvelteKit form action instead of (or in addition to) `onSelect` — a hidden `<form>` is built, requestSubmit()ed, then removed, so `use:enhance` fires exactly like a regular form submission. **Items also support nested `submenu: ContextMenuItem[]`** (right-side popover on hover/focus/ArrowRight, chevron indicator, ArrowLeft/Escape closes just the submenu) **and inline `content: Snippet`** (replaces the default icon+label row with an arbitrary widget — the wrapping button still closes the menu, so widgets that shouldn't must `event.stopPropagation()`). | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `onClose?` |
|
|
69
69
|
| `ContextMenuTrigger` | Wraps content with the standard right-click + "⋯" touch affordance, backed by `ContextMenu`. Use instead of hand-rolling `oncontextmenu` + a kebab button per row/card. | `items: ContextMenuItem[]`, `children` (snippet), `header?`, `triggerLabel?`, `hideButton?`, `zIndex?`, `class?` |
|
|
70
|
+
| `Tooltip` | Component-shaped wrapper around the shared `use:tooltip` action — for cases where you can't easily add an attribute to the target element (wrap a chip / badge / icon in the tooltip instead). Warm/cool window + `aria-describedby` are shared with the action. Prefer `use:tooltip` directly when you own the element. | `content: string`, `placement?`, `delayMs?`, `disabled?`, `display?`, `children` (snippet) |
|
|
71
|
+
| `DirtyFormHost` | Composable form dirty-state manager — renders your form body plus a floating "Unsaved changes" pill + Cancel button; wires `beforeunload` and (optional) SvelteKit `beforeNavigate`. Callers who want bespoke pill/Cancel chrome use `createDirtyGuard()` directly and skip the component. Extracted from the F12 pattern in `/admin/roles/[id]`. | `baseline: T`, `current: T`, `onCancel?`, `beforeNavigate?`, `discardPrompt?`, `equals?`, `hideChrome?`, `children` (snippet with `{dirty, cancel}`) |
|
|
72
|
+
| `BulkActionBar` | Floating action bar that appears when a `listNav` selection is non-empty. Simpler API than `FloatingActionBar` (takes a `Set`, `Array`, or plain number for `selection`, and a flat `actions[]` with `{label, onSelect, destructive?}`) — reach for `FloatingActionBar` directly when you need dropdown groups. | `selection: Set \| Array \| number`, `actions: BulkAction[]`, `onClear?`, `loading?`, `countLabel?` |
|
|
70
73
|
|
|
71
74
|
### Media
|
|
72
75
|
|
|
73
76
|
| Component | Purpose | Key Props |
|
|
74
77
|
|-----------|---------|-----------|
|
|
75
78
|
| `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
|
|
79
|
+
| `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
|
|
76
80
|
| `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker) and talk-time legend. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and keyboard map, and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek) and zoom over the band. | `src`, `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
|
|
77
81
|
|
|
78
82
|
**`VideoPlayer` vs `TranscriptPlayer`:** VideoPlayer is the generalization — it does everything TranscriptPlayer does plus the timeline band, and its props are structurally compatible with TranscriptPlayer's (`TranscriptSegment[]`, `TranscriptChapter[]`, `TranscriptBookmark[]`, `TranscriptSpeakerTalkTime[]` all pass straight through). `TranscriptPlayer` stays exported and unchanged for the existing meeting-recordings viewer; use `VideoPlayer` for anything new.
|
|
@@ -212,6 +216,25 @@ import { loadGoogleMapsAPI, isGoogleMapsLoaded } from 'jatui'
|
|
|
212
216
|
await loadGoogleMapsAPI({ apiKey, libraries: ['marker', 'geometry'] })
|
|
213
217
|
```
|
|
214
218
|
|
|
219
|
+
## Global confirm dialog
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
import { showConfirm } from "jatui"
|
|
223
|
+
// String body (backwards-compatible):
|
|
224
|
+
if (await showConfirm({ title: "Delete?", body: "This cannot be undone." })) { ... }
|
|
225
|
+
|
|
226
|
+
// Snippet body — for rich content (conditional warnings, formatted amounts):
|
|
227
|
+
{#snippet warnBody()}
|
|
228
|
+
<p>You're deleting <strong>{count}</strong> items totalling <strong>{fmt(amount)}</strong>.</p>
|
|
229
|
+
{#if hasPendingWork}<p class="text-warning mt-2">3 items still processing.</p>{/if}
|
|
230
|
+
{/snippet}
|
|
231
|
+
if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ... }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Typography utility
|
|
235
|
+
|
|
236
|
+
`text-2xs` — 11px / 16px line-height / +0.01em tracking. Ships in `styles/rail.css` (already imported by JST). Standardizes the ~13 sites of `text-[10px]` / `text-[0.7rem]` micro-captions across JST routes (timestamps, hint text, footnotes). Sits between Tailwind's `text-xs` (12px) and browser-min legibility.
|
|
237
|
+
|
|
215
238
|
## Utilities
|
|
216
239
|
|
|
217
240
|
```typescript
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* BulkActionBar — floating action bar for listNav selections.
|
|
4
|
+
*
|
|
5
|
+
* Simpler API than `FloatingActionBar`: takes a `Set` or `Array` of
|
|
6
|
+
* selected ids and a flat `actions[]`. Renders nothing when the
|
|
7
|
+
* selection is empty; auto-fades in when there is at least one item.
|
|
8
|
+
* Under the hood a thin wrapper over `FloatingActionBar` — reach for
|
|
9
|
+
* `FloatingActionBar` directly when you need dropdown groups.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
*
|
|
13
|
+
* const nav = listNav({ selectable: true });
|
|
14
|
+
*
|
|
15
|
+
* <ul use:nav>...</ul>
|
|
16
|
+
*
|
|
17
|
+
* <BulkActionBar
|
|
18
|
+
* selection={nav.selection}
|
|
19
|
+
* actions={[
|
|
20
|
+
* { label: 'Assign', onSelect: () => assignBulk(nav.selection) },
|
|
21
|
+
* { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true },
|
|
22
|
+
* ]}
|
|
23
|
+
* onClear={() => nav.clearSelection()}
|
|
24
|
+
* />
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import FloatingActionBar from './FloatingActionBar.svelte';
|
|
28
|
+
import type { ActionButton } from './FloatingActionBar.svelte';
|
|
29
|
+
|
|
30
|
+
export interface BulkAction {
|
|
31
|
+
label: string;
|
|
32
|
+
/** Optional SVG path string for a 24×24 icon. */
|
|
33
|
+
icon?: string;
|
|
34
|
+
/** Called when the action is clicked. */
|
|
35
|
+
onSelect: () => void;
|
|
36
|
+
/** Renders in the danger variant. Default false. */
|
|
37
|
+
destructive?: boolean;
|
|
38
|
+
/** Semantic variant override — takes precedence over `destructive`. */
|
|
39
|
+
variant?: ActionButton['variant'];
|
|
40
|
+
/** Unique key (defaults to label). */
|
|
41
|
+
id?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Props {
|
|
45
|
+
/** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
|
|
46
|
+
selection: Set<unknown> | unknown[] | number;
|
|
47
|
+
/** Actions to render. */
|
|
48
|
+
actions: BulkAction[];
|
|
49
|
+
/** Called when the user clicks the built-in Clear button. */
|
|
50
|
+
onClear?: () => void;
|
|
51
|
+
/** Loading state — disables all actions and shows a spinner. */
|
|
52
|
+
loading?: boolean;
|
|
53
|
+
/** Label for the count. Default "selected". */
|
|
54
|
+
countLabel?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let { selection, actions, onClear, loading = false, countLabel = 'selected' }: Props = $props();
|
|
58
|
+
|
|
59
|
+
const count = $derived(
|
|
60
|
+
typeof selection === 'number'
|
|
61
|
+
? selection
|
|
62
|
+
: Array.isArray(selection)
|
|
63
|
+
? selection.length
|
|
64
|
+
: selection.size,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const mapped = $derived<ActionButton[]>(
|
|
68
|
+
actions.map((a) => ({
|
|
69
|
+
id: a.id ?? a.label,
|
|
70
|
+
label: a.label,
|
|
71
|
+
icon: a.icon,
|
|
72
|
+
variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
|
|
73
|
+
onclick: a.onSelect,
|
|
74
|
+
})),
|
|
75
|
+
);
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<FloatingActionBar {count} actions={mapped} {loading} {onClear} {countLabel} />
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ActionButton } from './FloatingActionBar.svelte';
|
|
2
|
+
export interface BulkAction {
|
|
3
|
+
label: string;
|
|
4
|
+
/** Optional SVG path string for a 24×24 icon. */
|
|
5
|
+
icon?: string;
|
|
6
|
+
/** Called when the action is clicked. */
|
|
7
|
+
onSelect: () => void;
|
|
8
|
+
/** Renders in the danger variant. Default false. */
|
|
9
|
+
destructive?: boolean;
|
|
10
|
+
/** Semantic variant override — takes precedence over `destructive`. */
|
|
11
|
+
variant?: ActionButton['variant'];
|
|
12
|
+
/** Unique key (defaults to label). */
|
|
13
|
+
id?: string;
|
|
14
|
+
}
|
|
15
|
+
interface Props {
|
|
16
|
+
/** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
|
|
17
|
+
selection: Set<unknown> | unknown[] | number;
|
|
18
|
+
/** Actions to render. */
|
|
19
|
+
actions: BulkAction[];
|
|
20
|
+
/** Called when the user clicks the built-in Clear button. */
|
|
21
|
+
onClear?: () => void;
|
|
22
|
+
/** Loading state — disables all actions and shows a spinner. */
|
|
23
|
+
loading?: boolean;
|
|
24
|
+
/** Label for the count. Default "selected". */
|
|
25
|
+
countLabel?: string;
|
|
26
|
+
}
|
|
27
|
+
declare const BulkActionBar: import("svelte").Component<Props, {}, "">;
|
|
28
|
+
type BulkActionBar = ReturnType<typeof BulkActionBar>;
|
|
29
|
+
export default BulkActionBar;
|
|
@@ -104,7 +104,11 @@
|
|
|
104
104
|
<div class="cd-title-group">
|
|
105
105
|
<h3 class="cd-title">{opts.title}</h3>
|
|
106
106
|
{#if opts.body}
|
|
107
|
-
|
|
107
|
+
{#if typeof opts.body === 'string'}
|
|
108
|
+
<p class="cd-body">{opts.body}</p>
|
|
109
|
+
{:else}
|
|
110
|
+
<div class="cd-body">{@render opts.body()}</div>
|
|
111
|
+
{/if}
|
|
108
112
|
{/if}
|
|
109
113
|
</div>
|
|
110
114
|
</div>
|
|
@@ -31,21 +31,15 @@
|
|
|
31
31
|
* Keyboard: ArrowUp/ArrowDown/Home/End move focus among items, Escape
|
|
32
32
|
* dismisses (handled by use:floating), Enter/Space activates the focused
|
|
33
33
|
* button natively. Focus moves to the first item when the menu opens and
|
|
34
|
-
* returns to the trigger when it closes.
|
|
34
|
+
* returns to the trigger when it closes. Items may declare a nested
|
|
35
|
+
* `submenu` array — ArrowRight opens it, ArrowLeft returns focus to the
|
|
36
|
+
* parent item (and closes the submenu). Items may also carry a `content`
|
|
37
|
+
* Snippet to render arbitrary widgets in place of the default label row.
|
|
35
38
|
*/
|
|
36
39
|
|
|
37
40
|
import { floating, makeVirtualAnchor } from '../actions/floating.js';
|
|
38
|
-
|
|
39
|
-
type ActionItem
|
|
40
|
-
label: string;
|
|
41
|
-
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
42
|
-
icon?: string;
|
|
43
|
-
onSelect: () => void;
|
|
44
|
-
variant?: 'default' | 'danger';
|
|
45
|
-
disabled?: boolean;
|
|
46
|
-
};
|
|
47
|
-
type DividerItem = { divider: true };
|
|
48
|
-
export type ContextMenuItem = ActionItem | DividerItem;
|
|
41
|
+
import SelfContextMenu from './ContextMenu.svelte';
|
|
42
|
+
import type { ActionItem, ContextMenuItem, SubmitConfig } from './ContextMenu.types.js';
|
|
49
43
|
|
|
50
44
|
interface Props {
|
|
51
45
|
/** Whether the menu is currently shown. Bindable. */
|
|
@@ -77,33 +71,142 @@
|
|
|
77
71
|
onClose
|
|
78
72
|
}: Props = $props();
|
|
79
73
|
|
|
74
|
+
// Submenu state — index into `items` of the currently-open submenu (if any),
|
|
75
|
+
// plus its viewport anchor coordinates. A single open submenu at a time.
|
|
76
|
+
let submenuIndex = $state<number | null>(null);
|
|
77
|
+
let submenuX = $state(0);
|
|
78
|
+
let submenuY = $state(0);
|
|
79
|
+
|
|
80
|
+
function closeSubmenu() {
|
|
81
|
+
if (submenuIndex === null) return;
|
|
82
|
+
submenuIndex = null;
|
|
83
|
+
}
|
|
84
|
+
|
|
80
85
|
function close() {
|
|
81
86
|
if (!open) return;
|
|
87
|
+
closeSubmenu();
|
|
82
88
|
open = false;
|
|
83
89
|
onClose?.();
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
function
|
|
92
|
+
function openSubmenuAt(el: HTMLElement, index: number) {
|
|
93
|
+
const rect = el.getBoundingClientRect();
|
|
94
|
+
submenuX = rect.right;
|
|
95
|
+
submenuY = rect.top;
|
|
96
|
+
submenuIndex = index;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function handleItemClick(item: ActionItem, index: number, event: MouseEvent) {
|
|
87
100
|
if (item.disabled) return;
|
|
88
|
-
item.
|
|
101
|
+
if (item.submenu && item.submenu.length) {
|
|
102
|
+
// Submenu owner: open (or toggle) instead of firing onSelect/close.
|
|
103
|
+
const el = event.currentTarget as HTMLElement;
|
|
104
|
+
if (submenuIndex === index) {
|
|
105
|
+
closeSubmenu();
|
|
106
|
+
} else {
|
|
107
|
+
openSubmenuAt(el, index);
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (item.submit) {
|
|
112
|
+
submitAction(item.submit);
|
|
113
|
+
}
|
|
114
|
+
item.onSelect?.();
|
|
89
115
|
close();
|
|
90
116
|
}
|
|
91
117
|
|
|
118
|
+
function handleItemPointerEnter(item: ActionItem, index: number, event: PointerEvent) {
|
|
119
|
+
if (item.disabled || !item.submenu?.length) return;
|
|
120
|
+
const el = event.currentTarget as HTMLElement;
|
|
121
|
+
openSubmenuAt(el, index);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function submitAction(cfg: SubmitConfig) {
|
|
125
|
+
if (typeof document === 'undefined') return;
|
|
126
|
+
const form = document.createElement('form');
|
|
127
|
+
form.method = cfg.method ?? 'POST';
|
|
128
|
+
if (cfg.action) form.action = cfg.action;
|
|
129
|
+
form.enctype = cfg.enctype ?? 'application/x-www-form-urlencoded';
|
|
130
|
+
form.style.display = 'none';
|
|
131
|
+
if (cfg.body) {
|
|
132
|
+
for (const [k, v] of Object.entries(cfg.body)) {
|
|
133
|
+
const input = document.createElement('input');
|
|
134
|
+
input.type = 'hidden';
|
|
135
|
+
input.name = k;
|
|
136
|
+
input.value = String(v);
|
|
137
|
+
form.appendChild(input);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
document.body.appendChild(form);
|
|
141
|
+
form.requestSubmit();
|
|
142
|
+
// Leave form in DOM briefly so use:enhance handlers can attach; a
|
|
143
|
+
// microtask cleanup is enough because submission is synchronous.
|
|
144
|
+
queueMicrotask(() => form.remove());
|
|
145
|
+
}
|
|
146
|
+
|
|
92
147
|
// Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
|
|
93
148
|
let anchor = $derived(makeVirtualAnchor(x, y));
|
|
149
|
+
let submenuAnchor = $derived(makeVirtualAnchor(submenuX, submenuY));
|
|
94
150
|
|
|
95
151
|
let menuEl = $state<HTMLDivElement | null>(null);
|
|
96
152
|
|
|
97
153
|
function menuItemButtons(): HTMLButtonElement[] {
|
|
98
154
|
if (!menuEl) return [];
|
|
99
|
-
|
|
155
|
+
// Only DIRECT children — don't include nested submenu items when nav-ing
|
|
156
|
+
// this level.
|
|
157
|
+
return Array.from(
|
|
158
|
+
menuEl.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function focusedItemIndex(): number {
|
|
163
|
+
if (!menuEl) return -1;
|
|
164
|
+
const activeBtn = document.activeElement as HTMLElement | null;
|
|
165
|
+
if (!activeBtn) return -1;
|
|
166
|
+
const buttons = Array.from(
|
|
167
|
+
menuEl.querySelectorAll(':scope > button[role="menuitem"]')
|
|
168
|
+
) as HTMLButtonElement[];
|
|
169
|
+
return buttons.indexOf(activeBtn as HTMLButtonElement);
|
|
100
170
|
}
|
|
101
171
|
|
|
102
172
|
function handleKeydown(e: KeyboardEvent) {
|
|
103
173
|
if (e.key === 'Escape') {
|
|
174
|
+
// If a submenu is open, close only the submenu on Escape; otherwise
|
|
175
|
+
// dismiss the whole menu.
|
|
176
|
+
if (submenuIndex !== null) {
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
const activeIdx = submenuIndex;
|
|
179
|
+
closeSubmenu();
|
|
180
|
+
// Return focus to the parent item that owned the submenu.
|
|
181
|
+
const buttons = menuItemButtons();
|
|
182
|
+
buttons[activeIdx]?.focus();
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
104
185
|
close();
|
|
105
186
|
return;
|
|
106
187
|
}
|
|
188
|
+
if (e.key === 'ArrowRight') {
|
|
189
|
+
const idx = focusedItemIndex();
|
|
190
|
+
if (idx < 0) return;
|
|
191
|
+
const item = items[idx];
|
|
192
|
+
if (item && !('divider' in item) && item.submenu?.length && !item.disabled) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
e.stopPropagation();
|
|
195
|
+
const buttons = menuItemButtons();
|
|
196
|
+
openSubmenuAt(buttons[idx], idx);
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (e.key === 'ArrowLeft') {
|
|
201
|
+
// Only meaningful when this instance is itself a submenu — the
|
|
202
|
+
// parent listens for the resulting close event via onClose.
|
|
203
|
+
if (submenuIndex !== null) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
closeSubmenu();
|
|
207
|
+
}
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
107
210
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
108
211
|
const buttons = menuItemButtons();
|
|
109
212
|
if (!buttons.length) return;
|
|
@@ -125,12 +228,17 @@
|
|
|
125
228
|
const el = menuEl;
|
|
126
229
|
requestAnimationFrame(() => {
|
|
127
230
|
const buttons = Array.from(
|
|
128
|
-
el.querySelectorAll('button[role="menuitem"]:not(:disabled)')
|
|
231
|
+
el.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
|
|
129
232
|
) as HTMLButtonElement[];
|
|
130
233
|
buttons[0]?.focus();
|
|
131
234
|
});
|
|
132
235
|
}
|
|
133
236
|
});
|
|
237
|
+
|
|
238
|
+
// Close any open submenu when the parent menu closes.
|
|
239
|
+
$effect(() => {
|
|
240
|
+
if (!open && submenuIndex !== null) submenuIndex = null;
|
|
241
|
+
});
|
|
134
242
|
</script>
|
|
135
243
|
|
|
136
244
|
{#if open}
|
|
@@ -151,23 +259,56 @@
|
|
|
151
259
|
</p>
|
|
152
260
|
{/if}
|
|
153
261
|
|
|
154
|
-
{#each items as item}
|
|
262
|
+
{#each items as item, i}
|
|
155
263
|
{#if 'divider' in item}
|
|
156
264
|
<div class="border-t border-base-300 my-1"></div>
|
|
157
265
|
{:else}
|
|
158
266
|
<button
|
|
159
267
|
type="button"
|
|
160
268
|
role="menuitem"
|
|
269
|
+
aria-haspopup={item.submenu?.length ? 'menu' : undefined}
|
|
270
|
+
aria-expanded={item.submenu?.length ? submenuIndex === i : undefined}
|
|
271
|
+
aria-label={item.content ? item.label : undefined}
|
|
161
272
|
class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
|
|
162
273
|
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
163
274
|
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
164
275
|
disabled={item.disabled}
|
|
165
|
-
onclick={() =>
|
|
276
|
+
onclick={(e) => handleItemClick(item, i, e)}
|
|
277
|
+
onpointerenter={(e) => handleItemPointerEnter(item, i, e)}
|
|
278
|
+
onfocus={(e) => {
|
|
279
|
+
if (item.submenu?.length && !item.disabled) {
|
|
280
|
+
openSubmenuAt(e.currentTarget as HTMLElement, i);
|
|
281
|
+
}
|
|
282
|
+
}}
|
|
166
283
|
>
|
|
167
|
-
{#if item.
|
|
168
|
-
|
|
284
|
+
{#if item.content}
|
|
285
|
+
{@render item.content()}
|
|
286
|
+
{:else}
|
|
287
|
+
{#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
|
|
288
|
+
<span class="truncate flex-1">{item.label}</span>
|
|
289
|
+
{#if item.submenu?.length}
|
|
290
|
+
<span class="text-base-content/50 shrink-0" aria-hidden="true">▸</span>
|
|
291
|
+
{/if}
|
|
292
|
+
{/if}
|
|
169
293
|
</button>
|
|
170
294
|
{/if}
|
|
171
295
|
{/each}
|
|
296
|
+
|
|
297
|
+
{#if submenuIndex !== null && items[submenuIndex] && !('divider' in items[submenuIndex]) && items[submenuIndex].submenu}
|
|
298
|
+
<!--
|
|
299
|
+
Nested INSIDE menuEl so the parent's click-outside detection
|
|
300
|
+
(`node.contains(e.target)`) treats submenu clicks as "inside".
|
|
301
|
+
Visually the submenu still renders at its own floating position
|
|
302
|
+
because it uses `position: fixed` via use:floating.
|
|
303
|
+
-->
|
|
304
|
+
<SelfContextMenu
|
|
305
|
+
open={true}
|
|
306
|
+
x={submenuX}
|
|
307
|
+
y={submenuY}
|
|
308
|
+
items={items[submenuIndex].submenu ?? []}
|
|
309
|
+
zIndex={zIndex + 1}
|
|
310
|
+
onClose={closeSubmenu}
|
|
311
|
+
/>
|
|
312
|
+
{/if}
|
|
172
313
|
</div>
|
|
173
314
|
{/if}
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
type
|
|
2
|
-
label: string;
|
|
3
|
-
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
4
|
-
icon?: string;
|
|
5
|
-
onSelect: () => void;
|
|
6
|
-
variant?: 'default' | 'danger';
|
|
7
|
-
disabled?: boolean;
|
|
8
|
-
};
|
|
9
|
-
type DividerItem = {
|
|
10
|
-
divider: true;
|
|
11
|
-
};
|
|
12
|
-
export type ContextMenuItem = ActionItem | DividerItem;
|
|
1
|
+
import type { ContextMenuItem } from './ContextMenu.types.js';
|
|
13
2
|
interface Props {
|
|
14
3
|
/** Whether the menu is currently shown. Bindable. */
|
|
15
4
|
open: boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
/**
|
|
3
|
+
* Shared types for ContextMenu. Extracted from the .svelte file so
|
|
4
|
+
* svelte-package's .d.ts generation doesn't stumble on the recursive
|
|
5
|
+
* ContextMenuItem → ActionItem.submenu → ContextMenuItem[] shape (which
|
|
6
|
+
* caused "Exported variable 'ContextMenu' has or is using private name
|
|
7
|
+
* 'ActionItem'" during package build).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* `submit` — post to a SvelteKit form action when the item is clicked.
|
|
11
|
+
* Renders a hidden `<form method="POST">` with the action + body, then
|
|
12
|
+
* `requestSubmit()`s it. Use this when the row action should ride the
|
|
13
|
+
* standard `use:enhance` pipeline (toasts, invalidation, redirects) with
|
|
14
|
+
* no extra plumbing per row.
|
|
15
|
+
*/
|
|
16
|
+
export type SubmitConfig = {
|
|
17
|
+
/** SvelteKit action, e.g. "?/delete". Default: current URL. */
|
|
18
|
+
action?: string;
|
|
19
|
+
/** HTTP method. Default 'POST'. */
|
|
20
|
+
method?: 'POST' | 'GET';
|
|
21
|
+
/** Form fields to send as hidden inputs. */
|
|
22
|
+
body?: Record<string, string | number | boolean>;
|
|
23
|
+
/** Enctype. Default 'application/x-www-form-urlencoded'. */
|
|
24
|
+
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data';
|
|
25
|
+
};
|
|
26
|
+
export type ActionItem = {
|
|
27
|
+
label: string;
|
|
28
|
+
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
29
|
+
icon?: string;
|
|
30
|
+
/** Called when the item is clicked. Optional when `submit` or `submenu` is provided. */
|
|
31
|
+
onSelect?: () => void;
|
|
32
|
+
/** If set, clicking the item submits a form built from this config. */
|
|
33
|
+
submit?: SubmitConfig;
|
|
34
|
+
/**
|
|
35
|
+
* Nested submenu items. When present, the item shows a chevron and does
|
|
36
|
+
* NOT fire `onSelect` on click — hovering, focusing, ArrowRight or Enter
|
|
37
|
+
* opens the submenu to the right of the parent item (flipping left on
|
|
38
|
+
* viewport overflow). ArrowLeft or Escape closes just the submenu. Submenus
|
|
39
|
+
* nest arbitrarily deep.
|
|
40
|
+
*/
|
|
41
|
+
submenu?: ContextMenuItem[];
|
|
42
|
+
/**
|
|
43
|
+
* Custom content rendered in place of the default icon+label row. When
|
|
44
|
+
* provided, `icon` and `label` are ignored for display but `label`
|
|
45
|
+
* should still be set for accessibility (used as the button's
|
|
46
|
+
* aria-label). Use this for inline widgets — segmented toggles, small
|
|
47
|
+
* inputs, chips — that live inside a menu row without needing a
|
|
48
|
+
* bespoke shell around the whole menu.
|
|
49
|
+
*
|
|
50
|
+
* The wrapping `<button role="menuitem">` still receives clicks (and
|
|
51
|
+
* closes the menu by default), so any interactive widget in the
|
|
52
|
+
* snippet that should NOT close the menu must call
|
|
53
|
+
* `event.stopPropagation()` on its own click handler.
|
|
54
|
+
*/
|
|
55
|
+
content?: Snippet;
|
|
56
|
+
variant?: 'default' | 'danger';
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
};
|
|
59
|
+
export type DividerItem = {
|
|
60
|
+
divider: true;
|
|
61
|
+
};
|
|
62
|
+
export type ContextMenuItem = ActionItem | DividerItem;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
/**
|
|
3
|
+
* DirtyFormHost — declarative wrapper around createDirtyGuard.
|
|
4
|
+
*
|
|
5
|
+
* Renders your form body plus (optionally) a floating "Unsaved changes"
|
|
6
|
+
* pill + Cancel button. Composable API: pass `baseline`, `current`, and
|
|
7
|
+
* `onCancel` via props; the host wires beforeunload + (optional) SvelteKit
|
|
8
|
+
* beforeNavigate. Consumers who want a bespoke pill/Cancel treatment
|
|
9
|
+
* should skip this component and call `createDirtyGuard` directly.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* // in a Svelte component:
|
|
13
|
+
* // import { beforeNavigate } from '$app/navigation';
|
|
14
|
+
*
|
|
15
|
+
* <DirtyFormHost {baseline} current={draft} onCancel={() => draft = { ...baseline }} {beforeNavigate}>
|
|
16
|
+
* {#snippet children({ dirty, cancel })}
|
|
17
|
+
* <form>...form body...</form>
|
|
18
|
+
* {/snippet}
|
|
19
|
+
* </DirtyFormHost>
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createDirtyGuard, type BeforeNavigateFn } from '../utils/dirtyGuard';
|
|
23
|
+
import type { Snippet } from 'svelte';
|
|
24
|
+
|
|
25
|
+
interface Props {
|
|
26
|
+
/** Saved (baseline) form value. */
|
|
27
|
+
baseline: T;
|
|
28
|
+
/** Current (draft) form value. */
|
|
29
|
+
current: T;
|
|
30
|
+
/** Snap back to baseline. */
|
|
31
|
+
onCancel?: () => void;
|
|
32
|
+
/** SvelteKit `beforeNavigate` — pass through from `$app/navigation`. */
|
|
33
|
+
beforeNavigate?: BeforeNavigateFn;
|
|
34
|
+
/** Discard-prompt string. Default "Discard unsaved changes?" */
|
|
35
|
+
discardPrompt?: string;
|
|
36
|
+
/** Custom equality. Default JSON.stringify. */
|
|
37
|
+
equals?: (a: T, b: T) => boolean;
|
|
38
|
+
/** Hide the default pill + Cancel chrome and let the snippet render its own. */
|
|
39
|
+
hideChrome?: boolean;
|
|
40
|
+
/** Form body. Receives the guard controller as its first arg. */
|
|
41
|
+
children: Snippet<[{ dirty: boolean; cancel: () => void }]>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
baseline,
|
|
46
|
+
current,
|
|
47
|
+
onCancel,
|
|
48
|
+
beforeNavigate,
|
|
49
|
+
discardPrompt,
|
|
50
|
+
equals,
|
|
51
|
+
hideChrome = false,
|
|
52
|
+
children,
|
|
53
|
+
}: Props = $props();
|
|
54
|
+
|
|
55
|
+
const guard = createDirtyGuard<T>({
|
|
56
|
+
baseline: () => baseline,
|
|
57
|
+
current: () => current,
|
|
58
|
+
onCancel,
|
|
59
|
+
equals,
|
|
60
|
+
beforeNavigate,
|
|
61
|
+
discardPrompt,
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{@render children({ get dirty() { return guard.dirty; }, cancel: guard.cancel })}
|
|
66
|
+
|
|
67
|
+
{#if !hideChrome && guard.dirty}
|
|
68
|
+
<div class="jatui-dirty-chrome" role="status" aria-live="polite">
|
|
69
|
+
<span class="jatui-dirty-pill">Unsaved changes</span>
|
|
70
|
+
<button type="button" class="jatui-dirty-cancel" onclick={guard.cancel}>
|
|
71
|
+
Cancel
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
<style>
|
|
77
|
+
.jatui-dirty-chrome {
|
|
78
|
+
position: fixed;
|
|
79
|
+
bottom: 1rem;
|
|
80
|
+
right: 1rem;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 0.5rem;
|
|
84
|
+
padding: 0.375rem 0.5rem 0.375rem 0.75rem;
|
|
85
|
+
background: color-mix(in oklch, var(--color-base-100) 96%, transparent);
|
|
86
|
+
border: 1px solid var(--color-warning);
|
|
87
|
+
border-radius: 999px;
|
|
88
|
+
box-shadow: 0 8px 24px oklch(0 0 0 / 0.28);
|
|
89
|
+
z-index: 60;
|
|
90
|
+
font-size: 0.8125rem;
|
|
91
|
+
}
|
|
92
|
+
.jatui-dirty-pill {
|
|
93
|
+
color: var(--color-warning);
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
}
|
|
96
|
+
.jatui-dirty-cancel {
|
|
97
|
+
padding: 0.1875rem 0.5625rem;
|
|
98
|
+
font-size: 0.75rem;
|
|
99
|
+
font-weight: 500;
|
|
100
|
+
background: transparent;
|
|
101
|
+
border: 1px solid var(--color-base-300);
|
|
102
|
+
border-radius: 999px;
|
|
103
|
+
color: var(--color-base-content);
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
transition: background 0.15s;
|
|
106
|
+
}
|
|
107
|
+
.jatui-dirty-cancel:hover {
|
|
108
|
+
background: var(--color-base-200);
|
|
109
|
+
}
|
|
110
|
+
</style>
|