@joewinke/jatui 0.4.0 → 0.5.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
@@ -68,6 +68,7 @@ Then import:
68
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
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
 
@@ -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
@@ -125,7 +125,13 @@
125
125
  }
126
126
 
127
127
  function handleItemPointerEnter(item: ActionItem, index: number, event: PointerEvent) {
128
- if (item.disabled || !item.submenu?.length) return;
128
+ // Entering an item WITHOUT a submenu must close any open submenu —
129
+ // otherwise hover-scrolling past a submenu parent (e.g. "Classify
130
+ // sender") leaves its panel stuck open over the rest of the menu.
131
+ if (item.disabled || !item.submenu?.length) {
132
+ closeSubmenu();
133
+ return;
134
+ }
129
135
  const el = event.currentTarget as HTMLElement;
130
136
  openSubmenuAt(el, index);
131
137
  }
@@ -289,7 +295,7 @@
289
295
  aria-expanded={item.submenu?.length ? submenuIndex === i : undefined}
290
296
  aria-label={item.content ? item.label : undefined}
291
297
  class="w-full flex items-center gap-2 text-left px-3 py-1.5 text-sm transition-colors
292
- {item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-200'}
298
+ {item.variant === 'danger' ? 'text-error hover:bg-error/10' : 'hover:bg-base-300'}
293
299
  {item.disabled ? 'opacity-50 cursor-not-allowed' : ''}"
294
300
  disabled={item.disabled}
295
301
  onclick={(e) => handleItemClick(item, i, e)}
@@ -297,6 +303,10 @@
297
303
  onfocus={(e) => {
298
304
  if (item.submenu?.length && !item.disabled) {
299
305
  openSubmenuAt(e.currentTarget as HTMLElement, i);
306
+ } else {
307
+ // Arrow-navigating onto a submenu-less item closes any
308
+ // open submenu, same as pointer hover.
309
+ closeSubmenu();
300
310
  }
301
311
  }}
302
312
  >
@@ -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
@@ -41,6 +41,8 @@ export { default as BulkActionBar } from './components/BulkActionBar.svelte';
41
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.4.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",