@joewinke/jatui 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -65,11 +65,12 @@ 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
+ | `PresenceStack` | "Who's here" avatar stack for live-collaboration UI — max `maxVisible` avatars + "+N more" overflow bubble, tooltip per avatar ("X is editing FIELD" / "X is viewing"). Purely presentational — feed it viewers from any presence source (e.g. an SSE-backed presence composable). | `viewers: PresenceViewer[]`, `maxVisible?` (default 5), `size?`, `class?` |
71
72
  | `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?` |
73
+ | `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
74
 
74
75
  ### Media
75
76
 
@@ -297,6 +298,10 @@ import type {
297
298
  // ImageVariantSpec — { name, width, height, fit?, background? }
298
299
  // VariantResult — { name, url, width, height, backgroundRemovalFailed? }
299
300
  // VariantState — union: idle | loading | done (VariantResult) | error (message)
301
+
302
+ // PresenceStack types
303
+ import type { PresenceViewer } from "jatui"
304
+ // PresenceViewer — { user_id, name, avatar_url?, typing_scope? }
300
305
  ```
301
306
 
302
307
  ## Live Demos
@@ -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>;
@@ -0,0 +1,64 @@
1
+ <!--
2
+ PresenceStack — "who's here" avatar stack for live-collaboration UI
3
+ (mos-hwduf.4.4). Purely presentational: feed it a PresenceViewer[] from
4
+ any presence source (e.g. a host app's SSE-backed presence composable).
5
+
6
+ Shows up to maxVisible avatars in input order with a "+N more" overflow
7
+ bubble. Each avatar carries a tooltip: "{name} is editing {typing_scope}"
8
+ when typing_scope is set, else "{name} is viewing".
9
+ -->
10
+ <script lang="ts">
11
+ import type { PresenceViewer } from '../types/presence';
12
+ import Avatar from './messaging/Avatar.svelte';
13
+ import Tooltip from './Tooltip.svelte';
14
+
15
+ interface Props {
16
+ viewers: PresenceViewer[];
17
+ maxVisible?: number;
18
+ size?: 'xs' | 'small' | 'medium' | 'large';
19
+ class?: string;
20
+ }
21
+
22
+ let { viewers = [], maxVisible = 5, size = 'small', class: className = '' }: Props = $props();
23
+
24
+ const visible = $derived(viewers.slice(0, maxVisible));
25
+ const overflow = $derived(viewers.slice(maxVisible));
26
+
27
+ function tipFor(viewer: PresenceViewer): string {
28
+ const who = viewer.name ?? 'Someone';
29
+ return viewer.typing_scope ? `${who} is editing ${viewer.typing_scope}` : `${who} is viewing`;
30
+ }
31
+
32
+ const overflowTip = $derived(overflow.map((v) => v.name ?? 'Someone').join(', '));
33
+
34
+ const overflowSizeClasses: Record<string, string> = {
35
+ xs: 'w-5 h-5 text-[8px]',
36
+ small: 'w-8 h-8 text-xs',
37
+ medium: 'w-10 h-10 text-sm',
38
+ large: 'w-12 h-12 text-base'
39
+ };
40
+ </script>
41
+
42
+ <div class="flex -space-x-2 {className}">
43
+ {#each visible as viewer (viewer.user_id)}
44
+ <Tooltip content={tipFor(viewer)} display="inline-block">
45
+ <div class="border-2 border-base-100 rounded-full">
46
+ <Avatar name={viewer.name ?? '?'} avatarUrl={viewer.avatar_url} {size} />
47
+ </div>
48
+ </Tooltip>
49
+ {/each}
50
+
51
+ {#if overflow.length > 0}
52
+ <Tooltip content={overflowTip} display="inline-block">
53
+ <div class="border-2 border-base-100 rounded-full">
54
+ <div
55
+ class="bg-neutral text-neutral-content rounded-full flex items-center justify-center {overflowSizeClasses[
56
+ size
57
+ ]}"
58
+ >
59
+ <span class="font-bold">+{overflow.length}</span>
60
+ </div>
61
+ </div>
62
+ </Tooltip>
63
+ {/if}
64
+ </div>
@@ -0,0 +1,10 @@
1
+ import type { PresenceViewer } from '../types/presence';
2
+ interface Props {
3
+ viewers: PresenceViewer[];
4
+ maxVisible?: number;
5
+ size?: 'xs' | 'small' | 'medium' | 'large';
6
+ class?: string;
7
+ }
8
+ declare const PresenceStack: import("svelte").Component<Props, {}, "">;
9
+ type PresenceStack = ReturnType<typeof PresenceStack>;
10
+ export default PresenceStack;
package/dist/index.d.ts CHANGED
@@ -38,9 +38,11 @@ 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
+ export { default as PresenceStack } from './components/PresenceStack.svelte';
45
+ export type { PresenceViewer } from './types/presence';
44
46
  export { default as DirtyFormHost } from './components/DirtyFormHost.svelte';
45
47
  export { createDirtyGuard } from './utils/dirtyGuard';
46
48
  export type { DirtyGuard, DirtyGuardOptions, BeforeNavigateFn } from './utils/dirtyGuard';
package/dist/index.js CHANGED
@@ -45,6 +45,7 @@ export { default as BulkActionBar } from './components/BulkActionBar.svelte';
45
45
  export { default as HunkDiffView } from './components/HunkDiffView.svelte';
46
46
  // Primitives — accessibility + form-editor scaffolding
47
47
  export { default as Tooltip } from './components/Tooltip.svelte';
48
+ export { default as PresenceStack } from './components/PresenceStack.svelte';
48
49
  export { default as DirtyFormHost } from './components/DirtyFormHost.svelte';
49
50
  export { createDirtyGuard } from './utils/dirtyGuard';
50
51
  export { default as MediaLightbox } from './components/MediaLightbox.svelte';
@@ -0,0 +1,8 @@
1
+ /** One user currently viewing (and optionally editing a field of) an entity. */
2
+ export interface PresenceViewer {
3
+ user_id: string;
4
+ name: string | null;
5
+ avatar_url?: string | null;
6
+ /** Field/scope the user is actively editing, or null/absent if just viewing. */
7
+ typing_scope?: string | null;
8
+ }
@@ -0,0 +1,3 @@
1
+ // Live-collaboration presence types — framework-agnostic wire shape shared by
2
+ // any "who's viewing/editing this" feature (e.g. an SSE `presence.updated` event).
3
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",