@joewinke/jatui 0.3.1 → 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 CHANGED
@@ -65,11 +65,11 @@ Then import:
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. **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?` |
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` (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?` |
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` — reach for
9
- * `FloatingActionBar` directly when you need dropdown groups.
8
+ * Under the hood a thin wrapper over `FloatingActionBar`.
10
9
  *
11
- * Usage:
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
- /** Actions to render. */
48
- actions: BulkAction[];
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 { selection, actions, onClear, loading = false, countLabel = 'selected' }: Props = $props();
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 {count} actions={mapped} {loading} {onClear} {countLabel} />
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
- /** Actions to render. */
19
- actions: BulkAction[];
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. */
@@ -58,6 +58,14 @@
58
58
  class?: string;
59
59
  /** Called after the menu closes, regardless of cause. */
60
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;
61
69
  }
62
70
 
63
71
  let {
@@ -68,7 +76,8 @@
68
76
  header,
69
77
  zIndex = 70,
70
78
  class: className = '',
71
- onClose
79
+ onClose,
80
+ autoFocus = true
72
81
  }: Props = $props();
73
82
 
74
83
  // Submenu state — index into `items` of the currently-open submenu (if any),
@@ -194,6 +203,16 @@
194
203
  e.stopPropagation();
195
204
  const buttons = menuItemButtons();
196
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
+ });
197
216
  }
198
217
  return;
199
218
  }
@@ -224,7 +243,7 @@
224
243
  // action's returnFocusTarget already captured the pre-open activeElement
225
244
  // by the time this effect runs, so focus-return-on-close still works.
226
245
  $effect(() => {
227
- if (open && menuEl) {
246
+ if (open && menuEl && autoFocus) {
228
247
  const el = menuEl;
229
248
  requestAnimationFrame(() => {
230
249
  const buttons = Array.from(
@@ -307,6 +326,7 @@
307
326
  y={submenuY}
308
327
  items={items[submenuIndex].submenu ?? []}
309
328
  zIndex={zIndex + 1}
329
+ autoFocus={false}
310
330
  onClose={closeSubmenu}
311
331
  />
312
332
  {/if}
@@ -16,6 +16,14 @@ interface Props {
16
16
  class?: string;
17
17
  /** Called after the menu closes, regardless of cause. */
18
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;
19
27
  }
20
28
  declare const ContextMenu: import("svelte").Component<Props, {}, "open">;
21
29
  type ContextMenu = ReturnType<typeof ContextMenu>;
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",