@joewinke/jatui 0.3.0 → 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 CHANGED
@@ -58,14 +58,14 @@ 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) **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
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}`) |
@@ -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. */
@@ -98,14 +71,43 @@
98
71
  onClose
99
72
  }: Props = $props();
100
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
+
101
85
  function close() {
102
86
  if (!open) return;
87
+ closeSubmenu();
103
88
  open = false;
104
89
  onClose?.();
105
90
  }
106
91
 
107
- function handleSelect(item: ActionItem) {
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) {
108
100
  if (item.disabled) return;
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
+ }
109
111
  if (item.submit) {
110
112
  submitAction(item.submit);
111
113
  }
@@ -113,6 +115,12 @@
113
115
  close();
114
116
  }
115
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
+
116
124
  function submitAction(cfg: SubmitConfig) {
117
125
  if (typeof document === 'undefined') return;
118
126
  const form = document.createElement('form');
@@ -138,19 +146,67 @@
138
146
 
139
147
  // Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
140
148
  let anchor = $derived(makeVirtualAnchor(x, y));
149
+ let submenuAnchor = $derived(makeVirtualAnchor(submenuX, submenuY));
141
150
 
142
151
  let menuEl = $state<HTMLDivElement | null>(null);
143
152
 
144
153
  function menuItemButtons(): HTMLButtonElement[] {
145
154
  if (!menuEl) return [];
146
- return Array.from(menuEl.querySelectorAll('button[role="menuitem"]:not(:disabled)'));
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);
147
170
  }
148
171
 
149
172
  function handleKeydown(e: KeyboardEvent) {
150
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
+ }
151
185
  close();
152
186
  return;
153
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
+ }
154
210
  if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) return;
155
211
  const buttons = menuItemButtons();
156
212
  if (!buttons.length) return;
@@ -172,12 +228,17 @@
172
228
  const el = menuEl;
173
229
  requestAnimationFrame(() => {
174
230
  const buttons = Array.from(
175
- el.querySelectorAll('button[role="menuitem"]:not(:disabled)')
231
+ el.querySelectorAll(':scope > button[role="menuitem"]:not(:disabled)')
176
232
  ) as HTMLButtonElement[];
177
233
  buttons[0]?.focus();
178
234
  });
179
235
  }
180
236
  });
237
+
238
+ // Close any open submenu when the parent menu closes.
239
+ $effect(() => {
240
+ if (!open && submenuIndex !== null) submenuIndex = null;
241
+ });
181
242
  </script>
182
243
 
183
244
  {#if open}
@@ -198,23 +259,56 @@
198
259
  </p>
199
260
  {/if}
200
261
 
201
- {#each items as item}
262
+ {#each items as item, i}
202
263
  {#if 'divider' in item}
203
264
  <div class="border-t border-base-300 my-1"></div>
204
265
  {:else}
205
266
  <button
206
267
  type="button"
207
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}
208
272
  class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
209
273
  {item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
210
274
  {item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
211
275
  disabled={item.disabled}
212
- onclick={() => handleSelect(item)}
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
+ }}
213
283
  >
214
- {#if item.icon}<span class="shrink-0">{item.icon}</span>{/if}
215
- <span class="truncate">{item.label}</span>
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}
216
293
  </button>
217
294
  {/if}
218
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}
219
313
  </div>
220
314
  {/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;
@@ -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
- zIndex?: number;
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
- zIndex?: number;
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
@@ -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.svelte';
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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",