@joewinke/jatui 0.3.0 → 0.4.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/CLAUDE.md +3 -3
- package/dist/components/BulkActionBar.svelte +70 -8
- package/dist/components/BulkActionBar.svelte.d.ts +19 -3
- package/dist/components/ContextMenu.svelte +156 -42
- package/dist/components/ContextMenu.svelte.d.ts +9 -32
- package/dist/components/ContextMenu.types.d.ts +62 -0
- package/dist/components/ContextMenu.types.js +1 -0
- package/dist/components/Modal.svelte +8 -2
- package/dist/components/Modal.svelte.d.ts +7 -1
- package/dist/index.d.ts +2 -2
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -58,18 +58,18 @@ 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). 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. | `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 — submenus open on parent-item focus **without stealing focus into the submenu**; ArrowRight is the explicit "enter the submenu" gesture) **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()`). `autoFocus` (default true) can be set false to suppress auto-focus of the first item on open. | `open` (bindable), `x`, `y`, `items: ContextMenuItem[]`, `header?`, `zIndex?`, `autoFocus?`, `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
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
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
|
|
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`; a flat `actions[]` with `{label, onSelect, destructive?}`; and now `groups[]` for dropdown-menu actions (each group is one button on the bar that opens a menu of `{label, onSelect}` items — same shape as `BulkAction`). Flat + grouped can be mixed. Reach for `FloatingActionBar` directly only when you need finer per-item dropdown styling (dot colors, icon snippets). | `selection: Set \| Array \| number`, `actions?: BulkAction[]`, `groups?: BulkActionGroup[]`, `onClear?`, `loading?`, `countLabel?` |
|
|
73
73
|
|
|
74
74
|
### Media
|
|
75
75
|
|
|
@@ -5,10 +5,16 @@
|
|
|
5
5
|
* Simpler API than `FloatingActionBar`: takes a `Set` or `Array` of
|
|
6
6
|
* selected ids and a flat `actions[]`. Renders nothing when the
|
|
7
7
|
* selection is empty; auto-fades in when there is at least one item.
|
|
8
|
-
* Under the hood a thin wrapper over `FloatingActionBar
|
|
9
|
-
* `FloatingActionBar` directly when you need dropdown groups.
|
|
8
|
+
* Under the hood a thin wrapper over `FloatingActionBar`.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
10
|
+
* For actions that group naturally under a common menu (e.g. "Move to
|
|
11
|
+
* Stage" with 5 stage options), pass `groups[]` alongside `actions[]`.
|
|
12
|
+
* Each group renders as a single button that opens a dropdown of its
|
|
13
|
+
* `actions` — same `{ label, onSelect, destructive?, variant?, icon? }`
|
|
14
|
+
* shape as flat actions. Flat + grouped can be mixed; flat actions
|
|
15
|
+
* render first, then grouped dropdowns.
|
|
16
|
+
*
|
|
17
|
+
* Usage — flat only:
|
|
12
18
|
*
|
|
13
19
|
* const nav = listNav({ selectable: true });
|
|
14
20
|
*
|
|
@@ -22,10 +28,22 @@
|
|
|
22
28
|
* ]}
|
|
23
29
|
* onClear={() => nav.clearSelection()}
|
|
24
30
|
* />
|
|
31
|
+
*
|
|
32
|
+
* Usage — grouped:
|
|
33
|
+
*
|
|
34
|
+
* <BulkActionBar
|
|
35
|
+
* selection={nav.selection}
|
|
36
|
+
* actions={[{ label: 'Delete', onSelect: del, destructive: true }]}
|
|
37
|
+
* groups={[
|
|
38
|
+
* { label: 'Stage', actions: STAGES.map((s) => ({
|
|
39
|
+
* label: s.name, onSelect: () => moveTo(s.id),
|
|
40
|
+
* })) },
|
|
41
|
+
* ]}
|
|
42
|
+
* />
|
|
25
43
|
*/
|
|
26
44
|
|
|
27
45
|
import FloatingActionBar from './FloatingActionBar.svelte';
|
|
28
|
-
import type { ActionButton } from './FloatingActionBar.svelte';
|
|
46
|
+
import type { ActionButton, ActionDropdown } from './FloatingActionBar.svelte';
|
|
29
47
|
|
|
30
48
|
export interface BulkAction {
|
|
31
49
|
label: string;
|
|
@@ -41,11 +59,28 @@
|
|
|
41
59
|
id?: string;
|
|
42
60
|
}
|
|
43
61
|
|
|
62
|
+
export interface BulkActionGroup {
|
|
63
|
+
/** Button label — clicking opens the dropdown. */
|
|
64
|
+
label: string;
|
|
65
|
+
/** Optional SVG path string for a 24×24 icon on the group button. */
|
|
66
|
+
icon?: string;
|
|
67
|
+
/** Actions rendered inside the dropdown. `destructive` on a nested action
|
|
68
|
+
* currently only affects semantics for readers — the dropdown item row
|
|
69
|
+
* is styled uniformly. */
|
|
70
|
+
actions: BulkAction[];
|
|
71
|
+
/** Dropdown-button variant. Default 'default'. */
|
|
72
|
+
variant?: ActionDropdown['variant'];
|
|
73
|
+
/** Unique key (defaults to label). */
|
|
74
|
+
id?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
44
77
|
interface Props {
|
|
45
78
|
/** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
|
|
46
79
|
selection: Set<unknown> | unknown[] | number;
|
|
47
|
-
/**
|
|
48
|
-
actions
|
|
80
|
+
/** Flat actions rendered as top-level buttons. */
|
|
81
|
+
actions?: BulkAction[];
|
|
82
|
+
/** Grouped actions rendered as dropdown buttons. */
|
|
83
|
+
groups?: BulkActionGroup[];
|
|
49
84
|
/** Called when the user clicks the built-in Clear button. */
|
|
50
85
|
onClear?: () => void;
|
|
51
86
|
/** Loading state — disables all actions and shows a spinner. */
|
|
@@ -54,7 +89,14 @@
|
|
|
54
89
|
countLabel?: string;
|
|
55
90
|
}
|
|
56
91
|
|
|
57
|
-
let {
|
|
92
|
+
let {
|
|
93
|
+
selection,
|
|
94
|
+
actions = [],
|
|
95
|
+
groups = [],
|
|
96
|
+
onClear,
|
|
97
|
+
loading = false,
|
|
98
|
+
countLabel = 'selected',
|
|
99
|
+
}: Props = $props();
|
|
58
100
|
|
|
59
101
|
const count = $derived(
|
|
60
102
|
typeof selection === 'number'
|
|
@@ -73,6 +115,26 @@
|
|
|
73
115
|
onclick: a.onSelect,
|
|
74
116
|
})),
|
|
75
117
|
);
|
|
118
|
+
|
|
119
|
+
const mappedDropdowns = $derived<ActionDropdown[]>(
|
|
120
|
+
groups.map((g) => ({
|
|
121
|
+
id: g.id ?? g.label,
|
|
122
|
+
label: g.label,
|
|
123
|
+
icon: g.icon,
|
|
124
|
+
variant: g.variant ?? 'default',
|
|
125
|
+
items: g.actions.map((a) => ({
|
|
126
|
+
label: a.label,
|
|
127
|
+
onclick: a.onSelect,
|
|
128
|
+
})),
|
|
129
|
+
})),
|
|
130
|
+
);
|
|
76
131
|
</script>
|
|
77
132
|
|
|
78
|
-
<FloatingActionBar
|
|
133
|
+
<FloatingActionBar
|
|
134
|
+
{count}
|
|
135
|
+
actions={mapped}
|
|
136
|
+
dropdowns={mappedDropdowns}
|
|
137
|
+
{loading}
|
|
138
|
+
{onClear}
|
|
139
|
+
{countLabel}
|
|
140
|
+
/>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ActionButton } from './FloatingActionBar.svelte';
|
|
1
|
+
import type { ActionButton, ActionDropdown } from './FloatingActionBar.svelte';
|
|
2
2
|
export interface BulkAction {
|
|
3
3
|
label: string;
|
|
4
4
|
/** Optional SVG path string for a 24×24 icon. */
|
|
@@ -12,11 +12,27 @@ export interface BulkAction {
|
|
|
12
12
|
/** Unique key (defaults to label). */
|
|
13
13
|
id?: string;
|
|
14
14
|
}
|
|
15
|
+
export interface BulkActionGroup {
|
|
16
|
+
/** Button label — clicking opens the dropdown. */
|
|
17
|
+
label: string;
|
|
18
|
+
/** Optional SVG path string for a 24×24 icon on the group button. */
|
|
19
|
+
icon?: string;
|
|
20
|
+
/** Actions rendered inside the dropdown. `destructive` on a nested action
|
|
21
|
+
* currently only affects semantics for readers — the dropdown item row
|
|
22
|
+
* is styled uniformly. */
|
|
23
|
+
actions: BulkAction[];
|
|
24
|
+
/** Dropdown-button variant. Default 'default'. */
|
|
25
|
+
variant?: ActionDropdown['variant'];
|
|
26
|
+
/** Unique key (defaults to label). */
|
|
27
|
+
id?: string;
|
|
28
|
+
}
|
|
15
29
|
interface Props {
|
|
16
30
|
/** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
|
|
17
31
|
selection: Set<unknown> | unknown[] | number;
|
|
18
|
-
/**
|
|
19
|
-
actions
|
|
32
|
+
/** Flat actions rendered as top-level buttons. */
|
|
33
|
+
actions?: BulkAction[];
|
|
34
|
+
/** Grouped actions rendered as dropdown buttons. */
|
|
35
|
+
groups?: BulkActionGroup[];
|
|
20
36
|
/** Called when the user clicks the built-in Clear button. */
|
|
21
37
|
onClear?: () => void;
|
|
22
38
|
/** Loading state — disables all actions and shows a spinner. */
|
|
@@ -31,42 +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
|
-
|
|
40
|
-
* `submit` — post to a SvelteKit form action when the item is clicked.
|
|
41
|
-
* Renders a hidden `<form method="POST">` with the action + body, then
|
|
42
|
-
* `requestSubmit()`s it. Use this when the row action should ride the
|
|
43
|
-
* standard `use:enhance` pipeline (toasts, invalidation, redirects) with
|
|
44
|
-
* no extra plumbing per row.
|
|
45
|
-
*/
|
|
46
|
-
export type SubmitConfig = {
|
|
47
|
-
/** SvelteKit action, e.g. "?/delete". Default: current URL. */
|
|
48
|
-
action?: string;
|
|
49
|
-
/** HTTP method. Default 'POST'. */
|
|
50
|
-
method?: 'POST' | 'GET';
|
|
51
|
-
/** Form fields to send as hidden inputs. */
|
|
52
|
-
body?: Record<string, string | number | boolean>;
|
|
53
|
-
/** Enctype. Default 'application/x-www-form-urlencoded'. */
|
|
54
|
-
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data';
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
type ActionItem = {
|
|
58
|
-
label: string;
|
|
59
|
-
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
60
|
-
icon?: string;
|
|
61
|
-
/** Called when the item is clicked. Optional when `submit` is provided. */
|
|
62
|
-
onSelect?: () => void;
|
|
63
|
-
/** If set, clicking the item submits a form built from this config. */
|
|
64
|
-
submit?: SubmitConfig;
|
|
65
|
-
variant?: 'default' | 'danger';
|
|
66
|
-
disabled?: boolean;
|
|
67
|
-
};
|
|
68
|
-
type DividerItem = { divider: true };
|
|
69
|
-
export type ContextMenuItem = ActionItem | DividerItem;
|
|
41
|
+
import SelfContextMenu from './ContextMenu.svelte';
|
|
42
|
+
import type { ActionItem, ContextMenuItem, SubmitConfig } from './ContextMenu.types.js';
|
|
70
43
|
|
|
71
44
|
interface Props {
|
|
72
45
|
/** Whether the menu is currently shown. Bindable. */
|
|
@@ -85,6 +58,14 @@
|
|
|
85
58
|
class?: string;
|
|
86
59
|
/** Called after the menu closes, regardless of cause. */
|
|
87
60
|
onClose?: () => void;
|
|
61
|
+
/**
|
|
62
|
+
* Move focus to the first item on open. Default `true` for top-level
|
|
63
|
+
* menus; the component sets this to `false` when it recursively renders
|
|
64
|
+
* itself as a nested submenu so that arrow-navigating over a parent item
|
|
65
|
+
* with a submenu doesn't yank keyboard focus out of the parent row.
|
|
66
|
+
* ArrowRight from the parent explicitly focuses into the submenu.
|
|
67
|
+
*/
|
|
68
|
+
autoFocus?: boolean;
|
|
88
69
|
}
|
|
89
70
|
|
|
90
71
|
let {
|
|
@@ -95,17 +76,47 @@
|
|
|
95
76
|
header,
|
|
96
77
|
zIndex = 70,
|
|
97
78
|
class: className = '',
|
|
98
|
-
onClose
|
|
79
|
+
onClose,
|
|
80
|
+
autoFocus = true
|
|
99
81
|
}: Props = $props();
|
|
100
82
|
|
|
83
|
+
// Submenu state — index into `items` of the currently-open submenu (if any),
|
|
84
|
+
// plus its viewport anchor coordinates. A single open submenu at a time.
|
|
85
|
+
let submenuIndex = $state<number | null>(null);
|
|
86
|
+
let submenuX = $state(0);
|
|
87
|
+
let submenuY = $state(0);
|
|
88
|
+
|
|
89
|
+
function closeSubmenu() {
|
|
90
|
+
if (submenuIndex === null) return;
|
|
91
|
+
submenuIndex = null;
|
|
92
|
+
}
|
|
93
|
+
|
|
101
94
|
function close() {
|
|
102
95
|
if (!open) return;
|
|
96
|
+
closeSubmenu();
|
|
103
97
|
open = false;
|
|
104
98
|
onClose?.();
|
|
105
99
|
}
|
|
106
100
|
|
|
107
|
-
function
|
|
101
|
+
function openSubmenuAt(el: HTMLElement, index: number) {
|
|
102
|
+
const rect = el.getBoundingClientRect();
|
|
103
|
+
submenuX = rect.right;
|
|
104
|
+
submenuY = rect.top;
|
|
105
|
+
submenuIndex = index;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handleItemClick(item: ActionItem, index: number, event: MouseEvent) {
|
|
108
109
|
if (item.disabled) return;
|
|
110
|
+
if (item.submenu && item.submenu.length) {
|
|
111
|
+
// Submenu owner: open (or toggle) instead of firing onSelect/close.
|
|
112
|
+
const el = event.currentTarget as HTMLElement;
|
|
113
|
+
if (submenuIndex === index) {
|
|
114
|
+
closeSubmenu();
|
|
115
|
+
} else {
|
|
116
|
+
openSubmenuAt(el, index);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
109
120
|
if (item.submit) {
|
|
110
121
|
submitAction(item.submit);
|
|
111
122
|
}
|
|
@@ -113,6 +124,12 @@
|
|
|
113
124
|
close();
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
function handleItemPointerEnter(item: ActionItem, index: number, event: PointerEvent) {
|
|
128
|
+
if (item.disabled || !item.submenu?.length) return;
|
|
129
|
+
const el = event.currentTarget as HTMLElement;
|
|
130
|
+
openSubmenuAt(el, index);
|
|
131
|
+
}
|
|
132
|
+
|
|
116
133
|
function submitAction(cfg: SubmitConfig) {
|
|
117
134
|
if (typeof document === 'undefined') return;
|
|
118
135
|
const form = document.createElement('form');
|
|
@@ -138,19 +155,77 @@
|
|
|
138
155
|
|
|
139
156
|
// Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
|
|
140
157
|
let anchor = $derived(makeVirtualAnchor(x, y));
|
|
158
|
+
let submenuAnchor = $derived(makeVirtualAnchor(submenuX, submenuY));
|
|
141
159
|
|
|
142
160
|
let menuEl = $state<HTMLDivElement | null>(null);
|
|
143
161
|
|
|
144
162
|
function menuItemButtons(): HTMLButtonElement[] {
|
|
145
163
|
if (!menuEl) return [];
|
|
146
|
-
|
|
164
|
+
// Only DIRECT children — don't include nested submenu items when nav-ing
|
|
165
|
+
// this level.
|
|
166
|
+
return Array.from(
|
|
167
|
+
menuEl.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function focusedItemIndex(): number {
|
|
172
|
+
if (!menuEl) return -1;
|
|
173
|
+
const activeBtn = document.activeElement as HTMLElement | null;
|
|
174
|
+
if (!activeBtn) return -1;
|
|
175
|
+
const buttons = Array.from(
|
|
176
|
+
menuEl.querySelectorAll(':scope > button[role="menuitem"]')
|
|
177
|
+
) as HTMLButtonElement[];
|
|
178
|
+
return buttons.indexOf(activeBtn as HTMLButtonElement);
|
|
147
179
|
}
|
|
148
180
|
|
|
149
181
|
function handleKeydown(e: KeyboardEvent) {
|
|
150
182
|
if (e.key === 'Escape') {
|
|
183
|
+
// If a submenu is open, close only the submenu on Escape; otherwise
|
|
184
|
+
// dismiss the whole menu.
|
|
185
|
+
if (submenuIndex !== null) {
|
|
186
|
+
e.stopPropagation();
|
|
187
|
+
const activeIdx = submenuIndex;
|
|
188
|
+
closeSubmenu();
|
|
189
|
+
// Return focus to the parent item that owned the submenu.
|
|
190
|
+
const buttons = menuItemButtons();
|
|
191
|
+
buttons[activeIdx]?.focus();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
151
194
|
close();
|
|
152
195
|
return;
|
|
153
196
|
}
|
|
197
|
+
if (e.key === 'ArrowRight') {
|
|
198
|
+
const idx = focusedItemIndex();
|
|
199
|
+
if (idx < 0) return;
|
|
200
|
+
const item = items[idx];
|
|
201
|
+
if (item && !('divider' in item) && item.submenu?.length && !item.disabled) {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
e.stopPropagation();
|
|
204
|
+
const buttons = menuItemButtons();
|
|
205
|
+
openSubmenuAt(buttons[idx], idx);
|
|
206
|
+
// ArrowRight is an explicit "enter the submenu" gesture — focus
|
|
207
|
+
// its first item. Hover/onfocus open the submenu without moving
|
|
208
|
+
// focus (see autoFocus prop on the recursive instance).
|
|
209
|
+
const el = menuEl;
|
|
210
|
+
requestAnimationFrame(() => {
|
|
211
|
+
const first = el?.querySelector(
|
|
212
|
+
':scope > [role="menu"] > button[role="menuitem"]:not(:disabled)'
|
|
213
|
+
) as HTMLButtonElement | null;
|
|
214
|
+
first?.focus();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (e.key === 'ArrowLeft') {
|
|
220
|
+
// Only meaningful when this instance is itself a submenu — the
|
|
221
|
+
// parent listens for the resulting close event via onClose.
|
|
222
|
+
if (submenuIndex !== null) {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
closeSubmenu();
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
154
229
|
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
|
|
155
230
|
const buttons = menuItemButtons();
|
|
156
231
|
if (!buttons.length) return;
|
|
@@ -168,16 +243,21 @@
|
|
|
168
243
|
// action's returnFocusTarget already captured the pre-open activeElement
|
|
169
244
|
// by the time this effect runs, so focus-return-on-close still works.
|
|
170
245
|
$effect(() => {
|
|
171
|
-
if (open && menuEl) {
|
|
246
|
+
if (open && menuEl && autoFocus) {
|
|
172
247
|
const el = menuEl;
|
|
173
248
|
requestAnimationFrame(() => {
|
|
174
249
|
const buttons = Array.from(
|
|
175
|
-
el.querySelectorAll('button[role="menuitem"]:not(:disabled)')
|
|
250
|
+
el.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
|
|
176
251
|
) as HTMLButtonElement[];
|
|
177
252
|
buttons[0]?.focus();
|
|
178
253
|
});
|
|
179
254
|
}
|
|
180
255
|
});
|
|
256
|
+
|
|
257
|
+
// Close any open submenu when the parent menu closes.
|
|
258
|
+
$effect(() => {
|
|
259
|
+
if (!open && submenuIndex !== null) submenuIndex = null;
|
|
260
|
+
});
|
|
181
261
|
</script>
|
|
182
262
|
|
|
183
263
|
{#if open}
|
|
@@ -198,23 +278,57 @@
|
|
|
198
278
|
</p>
|
|
199
279
|
{/if}
|
|
200
280
|
|
|
201
|
-
{#each items as item}
|
|
281
|
+
{#each items as item, i}
|
|
202
282
|
{#if 'divider' in item}
|
|
203
283
|
<div class="border-t border-base-300 my-1"></div>
|
|
204
284
|
{:else}
|
|
205
285
|
<button
|
|
206
286
|
type="button"
|
|
207
287
|
role="menuitem"
|
|
288
|
+
aria-haspopup={item.submenu?.length ? 'menu' : undefined}
|
|
289
|
+
aria-expanded={item.submenu?.length ? submenuIndex === i : undefined}
|
|
290
|
+
aria-label={item.content ? item.label : undefined}
|
|
208
291
|
class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
|
|
209
292
|
{item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
|
|
210
293
|
{item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
|
|
211
294
|
disabled={item.disabled}
|
|
212
|
-
onclick={() =>
|
|
295
|
+
onclick={(e) => handleItemClick(item, i, e)}
|
|
296
|
+
onpointerenter={(e) => handleItemPointerEnter(item, i, e)}
|
|
297
|
+
onfocus={(e) => {
|
|
298
|
+
if (item.submenu?.length && !item.disabled) {
|
|
299
|
+
openSubmenuAt(e.currentTarget as HTMLElement, i);
|
|
300
|
+
}
|
|
301
|
+
}}
|
|
213
302
|
>
|
|
214
|
-
{#if item.
|
|
215
|
-
|
|
303
|
+
{#if item.content}
|
|
304
|
+
{@render item.content()}
|
|
305
|
+
{:else}
|
|
306
|
+
{#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
|
|
307
|
+
<span class="truncate flex-1">{item.label}</span>
|
|
308
|
+
{#if item.submenu?.length}
|
|
309
|
+
<span class="text-base-content/50 shrink-0" aria-hidden="true">▸</span>
|
|
310
|
+
{/if}
|
|
311
|
+
{/if}
|
|
216
312
|
</button>
|
|
217
313
|
{/if}
|
|
218
314
|
{/each}
|
|
315
|
+
|
|
316
|
+
{#if submenuIndex !== null && items[submenuIndex] && !('divider' in items[submenuIndex]) && items[submenuIndex].submenu}
|
|
317
|
+
<!--
|
|
318
|
+
Nested INSIDE menuEl so the parent's click-outside detection
|
|
319
|
+
(`node.contains(e.target)`) treats submenu clicks as "inside".
|
|
320
|
+
Visually the submenu still renders at its own floating position
|
|
321
|
+
because it uses `position: fixed` via use:floating.
|
|
322
|
+
-->
|
|
323
|
+
<SelfContextMenu
|
|
324
|
+
open={true}
|
|
325
|
+
x={submenuX}
|
|
326
|
+
y={submenuY}
|
|
327
|
+
items={items[submenuIndex].submenu ?? []}
|
|
328
|
+
zIndex={zIndex + 1}
|
|
329
|
+
autoFocus={false}
|
|
330
|
+
onClose={closeSubmenu}
|
|
331
|
+
/>
|
|
332
|
+
{/if}
|
|
219
333
|
</div>
|
|
220
334
|
{/if}
|
|
@@ -1,35 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* `submit` — post to a SvelteKit form action when the item is clicked.
|
|
3
|
-
* Renders a hidden `<form method="POST">` with the action + body, then
|
|
4
|
-
* `requestSubmit()`s it. Use this when the row action should ride the
|
|
5
|
-
* standard `use:enhance` pipeline (toasts, invalidation, redirects) with
|
|
6
|
-
* no extra plumbing per row.
|
|
7
|
-
*/
|
|
8
|
-
export type SubmitConfig = {
|
|
9
|
-
/** SvelteKit action, e.g. "?/delete". Default: current URL. */
|
|
10
|
-
action?: string;
|
|
11
|
-
/** HTTP method. Default 'POST'. */
|
|
12
|
-
method?: 'POST' | 'GET';
|
|
13
|
-
/** Form fields to send as hidden inputs. */
|
|
14
|
-
body?: Record<string, string | number | boolean>;
|
|
15
|
-
/** Enctype. Default 'application/x-www-form-urlencoded'. */
|
|
16
|
-
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data';
|
|
17
|
-
};
|
|
18
|
-
type ActionItem = {
|
|
19
|
-
label: string;
|
|
20
|
-
/** Optional leading icon — a single emoji/glyph, rendered before the label. */
|
|
21
|
-
icon?: string;
|
|
22
|
-
/** Called when the item is clicked. Optional when `submit` is provided. */
|
|
23
|
-
onSelect?: () => void;
|
|
24
|
-
/** If set, clicking the item submits a form built from this config. */
|
|
25
|
-
submit?: SubmitConfig;
|
|
26
|
-
variant?: 'default' | 'danger';
|
|
27
|
-
disabled?: boolean;
|
|
28
|
-
};
|
|
29
|
-
type DividerItem = {
|
|
30
|
-
divider: true;
|
|
31
|
-
};
|
|
32
|
-
export type ContextMenuItem = ActionItem | DividerItem;
|
|
1
|
+
import type { ContextMenuItem } from './ContextMenu.types.js';
|
|
33
2
|
interface Props {
|
|
34
3
|
/** Whether the menu is currently shown. Bindable. */
|
|
35
4
|
open: boolean;
|
|
@@ -47,6 +16,14 @@ interface Props {
|
|
|
47
16
|
class?: string;
|
|
48
17
|
/** Called after the menu closes, regardless of cause. */
|
|
49
18
|
onClose?: () => void;
|
|
19
|
+
/**
|
|
20
|
+
* Move focus to the first item on open. Default `true` for top-level
|
|
21
|
+
* menus; the component sets this to `false` when it recursively renders
|
|
22
|
+
* itself as a nested submenu so that arrow-navigating over a parent item
|
|
23
|
+
* with a submenu doesn't yank keyboard focus out of the parent row.
|
|
24
|
+
* ArrowRight from the parent explicitly focuses into the submenu.
|
|
25
|
+
*/
|
|
26
|
+
autoFocus?: boolean;
|
|
50
27
|
}
|
|
51
28
|
declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
|
|
52
29
|
type ContextMenu = ReturnType<typeof ContextMenu>;
|
|
@@ -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 {};
|
|
@@ -23,7 +23,13 @@
|
|
|
23
23
|
contentClass?: string;
|
|
24
24
|
ariaLabel?: string;
|
|
25
25
|
dismissible?: boolean;
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Stacking z-index. Accepts a numeric literal (`9040`) or any CSS
|
|
28
|
+
* `z-index` string, including CSS custom properties
|
|
29
|
+
* (`"var(--z-lightbox)"`). Strings are used as-is; numbers are
|
|
30
|
+
* coerced to strings. Default `1000`.
|
|
31
|
+
*/
|
|
32
|
+
zIndex?: string | number;
|
|
27
33
|
/**
|
|
28
34
|
* When true (default), keydown events fired inside the modal are stopped
|
|
29
35
|
* from propagating to window — preventing page-level list/keyboard
|
|
@@ -183,7 +189,7 @@
|
|
|
183
189
|
<dialog
|
|
184
190
|
bind:this={modalElement}
|
|
185
191
|
class={modalClass}
|
|
186
|
-
style="z-index: {zIndex}"
|
|
192
|
+
style="z-index: {typeof zIndex === 'number' ? String(zIndex) : zIndex}"
|
|
187
193
|
aria-modal="true"
|
|
188
194
|
aria-label={ariaLabel || title}
|
|
189
195
|
open
|
|
@@ -11,7 +11,13 @@ interface Props {
|
|
|
11
11
|
contentClass?: string;
|
|
12
12
|
ariaLabel?: string;
|
|
13
13
|
dismissible?: boolean;
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Stacking z-index. Accepts a numeric literal (`9040`) or any CSS
|
|
16
|
+
* `z-index` string, including CSS custom properties
|
|
17
|
+
* (`"var(--z-lightbox)"`). Strings are used as-is; numbers are
|
|
18
|
+
* coerced to strings. Default `1000`.
|
|
19
|
+
*/
|
|
20
|
+
zIndex?: string | number;
|
|
15
21
|
/**
|
|
16
22
|
* When true (default), keydown events fired inside the modal are stopped
|
|
17
23
|
* from propagating to window — preventing page-level list/keyboard
|
package/dist/index.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export { default as SortDropdown } from './components/SortDropdown.svelte';
|
|
|
38
38
|
export { default as InlineEdit } from './components/InlineEdit.svelte';
|
|
39
39
|
export { default as FloatingActionBar } from './components/FloatingActionBar.svelte';
|
|
40
40
|
export { default as BulkActionBar } from './components/BulkActionBar.svelte';
|
|
41
|
-
export type { BulkAction } from './components/BulkActionBar.svelte';
|
|
41
|
+
export type { BulkAction, BulkActionGroup } from './components/BulkActionBar.svelte';
|
|
42
42
|
export { default as HunkDiffView } from './components/HunkDiffView.svelte';
|
|
43
43
|
export { default as Tooltip } from './components/Tooltip.svelte';
|
|
44
44
|
export { default as DirtyFormHost } from './components/DirtyFormHost.svelte';
|
|
@@ -98,7 +98,7 @@ export type { ShortenedLink } from './components/LinkShortener.svelte';
|
|
|
98
98
|
export type { SearchDropdownOption, SearchDropdownGroup } from './components/SearchDropdown.svelte';
|
|
99
99
|
export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
|
|
100
100
|
export type { SortOption } from './components/SortDropdown.svelte';
|
|
101
|
-
export type { ContextMenuItem, SubmitConfig as ContextMenuSubmitConfig } from './components/ContextMenu.
|
|
101
|
+
export type { ContextMenuItem, ActionItem as ContextMenuActionItem, DividerItem as ContextMenuDividerItem, SubmitConfig as ContextMenuSubmitConfig } from './components/ContextMenu.types.js';
|
|
102
102
|
export type { DisplaySegment } from './components/InlineEdit.svelte';
|
|
103
103
|
export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
|
|
104
104
|
export type { SelectOption } from './components/SelectInput.svelte';
|