@joewinke/jatui 0.2.32 → 0.3.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,14 +65,18 @@ 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). | `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. | `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
+ | `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
+ | `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?` |
70
73
 
71
74
  ### Media
72
75
 
73
76
  | Component | Purpose | Key Props |
74
77
  |-----------|---------|-----------|
75
78
  | `MediaWorkbench` | Full-featured image editor modal — upload, AI generate, AI edit, background removal, annotation canvas. **Preferred over `SmartImageEditor` for new image fields.** Two-column modal: preview left, 4 action tabs right (Generate, Edit, Remove BG, Annotate). Save uploads blob to `uploadEndpoint` or emits `onComplete`. Requires proxy endpoints from jst-23w1l.2. | `value` (bindable `string \| null`), `uploadEndpoint?` (default `/api/media`), `onComplete?`, `accept?` |
79
+ | `MediaLightbox` | Full-screen media preview — z-9040 backdrop, arrow-key paging when given an array, `+/-/0` zoom, download action, Escape/backdrop close. **Use this for the general "just show it big" case** — replaces hand-rolled `fixed inset-0 z-[9040]` modals. For annotated / markup-overlaid image previews, use `ImageLightbox` instead. | `src?`, `filename?`, `kind?: 'image' \| 'video'`, `downloadUrl?`, `items?: MediaItem[]`, `initialIndex?`, `onClose?`, `zIndex?` |
76
80
  | `VideoPlayer` | **The default video surface — reach for this over a bare `<video controls>`.** One timeline band carrying a waveform-envelope scrubber, chapter flags (top edge), bookmark diamonds (bottom edge), diarized speaker colouring and scroll-to-zoom, plus an optional transcript pane (click to seek, active line auto-scroll, solo/skip per speaker) and talk-time legend. **Every intelligence prop is optional** — a bare mp4 still gets the scrubber, hover-time card, wheel volume and keyboard map, and each pane lights up as its data arrives (nothing shifts layout when diarization lands late). Wheel means volume over the video (shift = seek) and zoom over the band. | `src`, `chapters?`, `speakers?`, `transcript?`, `bookmarks?`, `waveform?`, `talkTime?`, `startAt?`, `poster?`, `captionsSrc?`, `transcriptPosition?` (`'below' \| 'side'`), `onTimeUpdate?`, `onAddBookmark?`, `onBookmarkContext?` |
77
81
 
78
82
  **`VideoPlayer` vs `TranscriptPlayer`:** VideoPlayer is the generalization — it does everything TranscriptPlayer does plus the timeline band, and its props are structurally compatible with TranscriptPlayer's (`TranscriptSegment[]`, `TranscriptChapter[]`, `TranscriptBookmark[]`, `TranscriptSpeakerTalkTime[]` all pass straight through). `TranscriptPlayer` stays exported and unchanged for the existing meeting-recordings viewer; use `VideoPlayer` for anything new.
@@ -212,6 +216,25 @@ import { loadGoogleMapsAPI, isGoogleMapsLoaded } from 'jatui'
212
216
  await loadGoogleMapsAPI({ apiKey, libraries: ['marker', 'geometry'] })
213
217
  ```
214
218
 
219
+ ## Global confirm dialog
220
+
221
+ ```typescript
222
+ import { showConfirm } from "jatui"
223
+ // String body (backwards-compatible):
224
+ if (await showConfirm({ title: "Delete?", body: "This cannot be undone." })) { ... }
225
+
226
+ // Snippet body — for rich content (conditional warnings, formatted amounts):
227
+ {#snippet warnBody()}
228
+ <p>You're deleting <strong>{count}</strong> items totalling <strong>{fmt(amount)}</strong>.</p>
229
+ {#if hasPendingWork}<p class="text-warning mt-2">3 items still processing.</p>{/if}
230
+ {/snippet}
231
+ if (await showConfirm({ title: "Delete?", body: warnBody, danger: true })) { ... }
232
+ ```
233
+
234
+ ## Typography utility
235
+
236
+ `text-2xs` — 11px / 16px line-height / +0.01em tracking. Ships in `styles/rail.css` (already imported by JST). Standardizes the ~13 sites of `text-[10px]` / `text-[0.7rem]` micro-captions across JST routes (timestamps, hint text, footnotes). Sits between Tailwind's `text-xs` (12px) and browser-min legibility.
237
+
215
238
  ## Utilities
216
239
 
217
240
  ```typescript
@@ -0,0 +1,78 @@
1
+ <script lang="ts">
2
+ /**
3
+ * BulkActionBar — floating action bar for listNav selections.
4
+ *
5
+ * Simpler API than `FloatingActionBar`: takes a `Set` or `Array` of
6
+ * selected ids and a flat `actions[]`. Renders nothing when the
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.
10
+ *
11
+ * Usage:
12
+ *
13
+ * const nav = listNav({ selectable: true });
14
+ *
15
+ * <ul use:nav>...</ul>
16
+ *
17
+ * <BulkActionBar
18
+ * selection={nav.selection}
19
+ * actions={[
20
+ * { label: 'Assign', onSelect: () => assignBulk(nav.selection) },
21
+ * { label: 'Delete', onSelect: () => deleteBulk(nav.selection), destructive: true },
22
+ * ]}
23
+ * onClear={() => nav.clearSelection()}
24
+ * />
25
+ */
26
+
27
+ import FloatingActionBar from './FloatingActionBar.svelte';
28
+ import type { ActionButton } from './FloatingActionBar.svelte';
29
+
30
+ export interface BulkAction {
31
+ label: string;
32
+ /** Optional SVG path string for a 24×24 icon. */
33
+ icon?: string;
34
+ /** Called when the action is clicked. */
35
+ onSelect: () => void;
36
+ /** Renders in the danger variant. Default false. */
37
+ destructive?: boolean;
38
+ /** Semantic variant override — takes precedence over `destructive`. */
39
+ variant?: ActionButton['variant'];
40
+ /** Unique key (defaults to label). */
41
+ id?: string;
42
+ }
43
+
44
+ interface Props {
45
+ /** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
46
+ selection: Set<unknown> | unknown[] | number;
47
+ /** Actions to render. */
48
+ actions: BulkAction[];
49
+ /** Called when the user clicks the built-in Clear button. */
50
+ onClear?: () => void;
51
+ /** Loading state — disables all actions and shows a spinner. */
52
+ loading?: boolean;
53
+ /** Label for the count. Default "selected". */
54
+ countLabel?: string;
55
+ }
56
+
57
+ let { selection, actions, onClear, loading = false, countLabel = 'selected' }: Props = $props();
58
+
59
+ const count = $derived(
60
+ typeof selection === 'number'
61
+ ? selection
62
+ : Array.isArray(selection)
63
+ ? selection.length
64
+ : selection.size,
65
+ );
66
+
67
+ const mapped = $derived<ActionButton[]>(
68
+ actions.map((a) => ({
69
+ id: a.id ?? a.label,
70
+ label: a.label,
71
+ icon: a.icon,
72
+ variant: a.variant ?? (a.destructive ? 'danger' : 'default'),
73
+ onclick: a.onSelect,
74
+ })),
75
+ );
76
+ </script>
77
+
78
+ <FloatingActionBar {count} actions={mapped} {loading} {onClear} {countLabel} />
@@ -0,0 +1,29 @@
1
+ import type { ActionButton } from './FloatingActionBar.svelte';
2
+ export interface BulkAction {
3
+ label: string;
4
+ /** Optional SVG path string for a 24×24 icon. */
5
+ icon?: string;
6
+ /** Called when the action is clicked. */
7
+ onSelect: () => void;
8
+ /** Renders in the danger variant. Default false. */
9
+ destructive?: boolean;
10
+ /** Semantic variant override — takes precedence over `destructive`. */
11
+ variant?: ActionButton['variant'];
12
+ /** Unique key (defaults to label). */
13
+ id?: string;
14
+ }
15
+ interface Props {
16
+ /** Current selection — Set, Array, or a plain number. Bar hides when empty/zero. */
17
+ selection: Set<unknown> | unknown[] | number;
18
+ /** Actions to render. */
19
+ actions: BulkAction[];
20
+ /** Called when the user clicks the built-in Clear button. */
21
+ onClear?: () => void;
22
+ /** Loading state — disables all actions and shows a spinner. */
23
+ loading?: boolean;
24
+ /** Label for the count. Default "selected". */
25
+ countLabel?: string;
26
+ }
27
+ declare const BulkActionBar: import("svelte").Component<Props, {}, "">;
28
+ type BulkActionBar = ReturnType<typeof BulkActionBar>;
29
+ export default BulkActionBar;
@@ -104,7 +104,11 @@
104
104
  <div class="cd-title-group">
105
105
  <h3 class="cd-title">{opts.title}</h3>
106
106
  {#if opts.body}
107
- <p class="cd-body">{opts.body}</p>
107
+ {#if typeof opts.body === 'string'}
108
+ <p class="cd-body">{opts.body}</p>
109
+ {:else}
110
+ <div class="cd-body">{@render opts.body()}</div>
111
+ {/if}
108
112
  {/if}
109
113
  </div>
110
114
  </div>
@@ -36,11 +36,32 @@
36
36
 
37
37
  import { floating, makeVirtualAnchor } from '../actions/floating.js';
38
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
+
39
57
  type ActionItem = {
40
58
  label: string;
41
59
  /** Optional leading icon — a single emoji/glyph, rendered before the label. */
42
60
  icon?: string;
43
- onSelect: () => void;
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;
44
65
  variant?: 'default' | 'danger';
45
66
  disabled?: boolean;
46
67
  };
@@ -85,10 +106,36 @@
85
106
 
86
107
  function handleSelect(item: ActionItem) {
87
108
  if (item.disabled) return;
88
- item.onSelect();
109
+ if (item.submit) {
110
+ submitAction(item.submit);
111
+ }
112
+ item.onSelect?.();
89
113
  close();
90
114
  }
91
115
 
116
+ function submitAction(cfg: SubmitConfig) {
117
+ if (typeof document === 'undefined') return;
118
+ const form = document.createElement('form');
119
+ form.method = cfg.method ?? 'POST';
120
+ if (cfg.action) form.action = cfg.action;
121
+ form.enctype = cfg.enctype ?? 'application/x-www-form-urlencoded';
122
+ form.style.display = 'none';
123
+ if (cfg.body) {
124
+ for (const [k, v] of Object.entries(cfg.body)) {
125
+ const input = document.createElement('input');
126
+ input.type = 'hidden';
127
+ input.name = k;
128
+ input.value = String(v);
129
+ form.appendChild(input);
130
+ }
131
+ }
132
+ document.body.appendChild(form);
133
+ form.requestSubmit();
134
+ // Leave form in DOM briefly so use:enhance handlers can attach; a
135
+ // microtask cleanup is enough because submission is synchronous.
136
+ queueMicrotask(() => form.remove());
137
+ }
138
+
92
139
  // Virtual anchor tracks the cursor position; rebuilt whenever x or y change.
93
140
  let anchor = $derived(makeVirtualAnchor(x, y));
94
141
 
@@ -1,8 +1,28 @@
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
+ };
1
18
  type ActionItem = {
2
19
  label: string;
3
20
  /** Optional leading icon — a single emoji/glyph, rendered before the label. */
4
21
  icon?: string;
5
- onSelect: () => void;
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;
6
26
  variant?: 'default' | 'danger';
7
27
  disabled?: boolean;
8
28
  };
@@ -0,0 +1,110 @@
1
+ <script lang="ts" generics="T">
2
+ /**
3
+ * DirtyFormHost — declarative wrapper around createDirtyGuard.
4
+ *
5
+ * Renders your form body plus (optionally) a floating "Unsaved changes"
6
+ * pill + Cancel button. Composable API: pass `baseline`, `current`, and
7
+ * `onCancel` via props; the host wires beforeunload + (optional) SvelteKit
8
+ * beforeNavigate. Consumers who want a bespoke pill/Cancel treatment
9
+ * should skip this component and call `createDirtyGuard` directly.
10
+ *
11
+ * Usage:
12
+ * // in a Svelte component:
13
+ * // import { beforeNavigate } from '$app/navigation';
14
+ *
15
+ * <DirtyFormHost {baseline} current={draft} onCancel={() => draft = { ...baseline }} {beforeNavigate}>
16
+ * {#snippet children({ dirty, cancel })}
17
+ * <form>...form body...</form>
18
+ * {/snippet}
19
+ * </DirtyFormHost>
20
+ */
21
+
22
+ import { createDirtyGuard, type BeforeNavigateFn } from '../utils/dirtyGuard';
23
+ import type { Snippet } from 'svelte';
24
+
25
+ interface Props {
26
+ /** Saved (baseline) form value. */
27
+ baseline: T;
28
+ /** Current (draft) form value. */
29
+ current: T;
30
+ /** Snap back to baseline. */
31
+ onCancel?: () => void;
32
+ /** SvelteKit `beforeNavigate` — pass through from `$app/navigation`. */
33
+ beforeNavigate?: BeforeNavigateFn;
34
+ /** Discard-prompt string. Default "Discard unsaved changes?" */
35
+ discardPrompt?: string;
36
+ /** Custom equality. Default JSON.stringify. */
37
+ equals?: (a: T, b: T) => boolean;
38
+ /** Hide the default pill + Cancel chrome and let the snippet render its own. */
39
+ hideChrome?: boolean;
40
+ /** Form body. Receives the guard controller as its first arg. */
41
+ children: Snippet<[{ dirty: boolean; cancel: () => void }]>;
42
+ }
43
+
44
+ let {
45
+ baseline,
46
+ current,
47
+ onCancel,
48
+ beforeNavigate,
49
+ discardPrompt,
50
+ equals,
51
+ hideChrome = false,
52
+ children,
53
+ }: Props = $props();
54
+
55
+ const guard = createDirtyGuard<T>({
56
+ baseline: () => baseline,
57
+ current: () => current,
58
+ onCancel,
59
+ equals,
60
+ beforeNavigate,
61
+ discardPrompt,
62
+ });
63
+ </script>
64
+
65
+ {@render children({ get dirty() { return guard.dirty; }, cancel: guard.cancel })}
66
+
67
+ {#if !hideChrome && guard.dirty}
68
+ <div class="jatui-dirty-chrome" role="status" aria-live="polite">
69
+ <span class="jatui-dirty-pill">Unsaved changes</span>
70
+ <button type="button" class="jatui-dirty-cancel" onclick={guard.cancel}>
71
+ Cancel
72
+ </button>
73
+ </div>
74
+ {/if}
75
+
76
+ <style>
77
+ .jatui-dirty-chrome {
78
+ position: fixed;
79
+ bottom: 1rem;
80
+ right: 1rem;
81
+ display: flex;
82
+ align-items: center;
83
+ gap: 0.5rem;
84
+ padding: 0.375rem 0.5rem 0.375rem 0.75rem;
85
+ background: color-mix(in oklch, var(--color-base-100) 96%, transparent);
86
+ border: 1px solid var(--color-warning);
87
+ border-radius: 999px;
88
+ box-shadow: 0 8px 24px oklch(0 0 0 / 0.28);
89
+ z-index: 60;
90
+ font-size: 0.8125rem;
91
+ }
92
+ .jatui-dirty-pill {
93
+ color: var(--color-warning);
94
+ font-weight: 600;
95
+ }
96
+ .jatui-dirty-cancel {
97
+ padding: 0.1875rem 0.5625rem;
98
+ font-size: 0.75rem;
99
+ font-weight: 500;
100
+ background: transparent;
101
+ border: 1px solid var(--color-base-300);
102
+ border-radius: 999px;
103
+ color: var(--color-base-content);
104
+ cursor: pointer;
105
+ transition: background 0.15s;
106
+ }
107
+ .jatui-dirty-cancel:hover {
108
+ background: var(--color-base-200);
109
+ }
110
+ </style>
@@ -0,0 +1,65 @@
1
+ /**
2
+ * DirtyFormHost — declarative wrapper around createDirtyGuard.
3
+ *
4
+ * Renders your form body plus (optionally) a floating "Unsaved changes"
5
+ * pill + Cancel button. Composable API: pass `baseline`, `current`, and
6
+ * `onCancel` via props; the host wires beforeunload + (optional) SvelteKit
7
+ * beforeNavigate. Consumers who want a bespoke pill/Cancel treatment
8
+ * should skip this component and call `createDirtyGuard` directly.
9
+ *
10
+ * Usage:
11
+ * // in a Svelte component:
12
+ * // import { beforeNavigate } from '$app/navigation';
13
+ *
14
+ * <DirtyFormHost {baseline} current={draft} onCancel={() => draft = { ...baseline }} {beforeNavigate}>
15
+ * {#snippet children({ dirty, cancel })}
16
+ * <form>...form body...</form>
17
+ * {/snippet}
18
+ * </DirtyFormHost>
19
+ */
20
+ import { type BeforeNavigateFn } from '../utils/dirtyGuard';
21
+ import type { Snippet } from 'svelte';
22
+ declare function $$render<T>(): {
23
+ props: {
24
+ /** Saved (baseline) form value. */
25
+ baseline: T;
26
+ /** Current (draft) form value. */
27
+ current: T;
28
+ /** Snap back to baseline. */
29
+ onCancel?: () => void;
30
+ /** SvelteKit `beforeNavigate` — pass through from `$app/navigation`. */
31
+ beforeNavigate?: BeforeNavigateFn;
32
+ /** Discard-prompt string. Default "Discard unsaved changes?" */
33
+ discardPrompt?: string;
34
+ /** Custom equality. Default JSON.stringify. */
35
+ equals?: (a: T, b: T) => boolean;
36
+ /** Hide the default pill + Cancel chrome and let the snippet render its own. */
37
+ hideChrome?: boolean;
38
+ /** Form body. Receives the guard controller as its first arg. */
39
+ children: Snippet<[{
40
+ dirty: boolean;
41
+ cancel: () => void;
42
+ }]>;
43
+ };
44
+ exports: {};
45
+ bindings: "";
46
+ slots: {};
47
+ events: {};
48
+ };
49
+ declare class __sveltets_Render<T> {
50
+ props(): ReturnType<typeof $$render<T>>['props'];
51
+ events(): ReturnType<typeof $$render<T>>['events'];
52
+ slots(): ReturnType<typeof $$render<T>>['slots'];
53
+ bindings(): "";
54
+ exports(): {};
55
+ }
56
+ interface $$IsomorphicComponent {
57
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
58
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
59
+ } & ReturnType<__sveltets_Render<T>['exports']>;
60
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
61
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
62
+ }
63
+ declare const DirtyFormHost: $$IsomorphicComponent;
64
+ type DirtyFormHost<T> = InstanceType<typeof DirtyFormHost<T>>;
65
+ export default DirtyFormHost;
@@ -263,6 +263,12 @@
263
263
  padding: 0.4rem 0.25rem;
264
264
  }
265
265
 
266
+ /* Unstyled otherwise, this inherits the host page's text color instead of
267
+ one legible against the panel's own hardcoded dark background. */
268
+ .kso-description {
269
+ color: oklch(0.85 0.02 250);
270
+ }
271
+
266
272
  .kso-section-title {
267
273
  font-size: 0.7rem;
268
274
  text-transform: uppercase;
@@ -0,0 +1,340 @@
1
+ <script lang="ts">
2
+ /**
3
+ * MediaLightbox — full-screen media preview.
4
+ *
5
+ * Standard z-9040 backdrop, keyboard nav (Esc to close, ArrowLeft/Right
6
+ * to page through an array), zoom controls, download action. Replaces the
7
+ * hand-rolled `fixed inset-0 z-[9040]` lightboxes in the codebase.
8
+ *
9
+ * For annotated / markup-overlaid image previews, use `ImageLightbox`
10
+ * (which composes AnnotationLayer + MarkupOverlay). This primitive is
11
+ * for the general "just show it big" case.
12
+ *
13
+ * Usage — single item:
14
+ * <MediaLightbox
15
+ * src="https://cdn/photo.jpg"
16
+ * filename="photo.jpg"
17
+ * onClose={() => open = false}
18
+ * />
19
+ *
20
+ * Usage — carousel:
21
+ * <MediaLightbox items={[
22
+ * { src, filename, kind: 'image' },
23
+ * { src: videoSrc, filename: 'clip.mp4', kind: 'video' },
24
+ * ]} initialIndex={0} onClose={close} />
25
+ */
26
+
27
+ import { fade } from 'svelte/transition';
28
+
29
+ export type MediaItem = {
30
+ src: string;
31
+ filename?: string;
32
+ kind?: 'image' | 'video';
33
+ downloadUrl?: string;
34
+ };
35
+
36
+ interface Props {
37
+ /** Single-item shorthand — either provide `src`+`filename`+`kind`, or `items`+`initialIndex`. */
38
+ src?: string;
39
+ filename?: string;
40
+ kind?: 'image' | 'video';
41
+ downloadUrl?: string;
42
+ /** Array of items for carousel navigation. Overrides `src` when provided. */
43
+ items?: MediaItem[];
44
+ /** Start index into `items`. Default 0. */
45
+ initialIndex?: number;
46
+ /** Called when the user closes via Escape, backdrop click, or ×. */
47
+ onClose?: () => void;
48
+ /** Stacking context. Default 9040 (matches the app's existing overlay tier). */
49
+ zIndex?: number;
50
+ }
51
+
52
+ let {
53
+ src,
54
+ filename,
55
+ kind = 'image',
56
+ downloadUrl,
57
+ items,
58
+ initialIndex = 0,
59
+ onClose,
60
+ zIndex = 9040,
61
+ }: Props = $props();
62
+
63
+ // Normalize into an items array; single-item form is index 0.
64
+ const list = $derived<MediaItem[]>(
65
+ items && items.length > 0
66
+ ? items
67
+ : src
68
+ ? [{ src, filename, kind, downloadUrl }]
69
+ : [],
70
+ );
71
+
72
+ let index = $state(0);
73
+ let zoom = $state(1);
74
+
75
+ // Snap index to a valid position when items/initialIndex are set.
76
+ $effect(() => {
77
+ const max = (items?.length ?? 1) - 1;
78
+ index = Math.max(0, Math.min(initialIndex, max));
79
+ });
80
+
81
+ const current = $derived<MediaItem | null>(list[index] ?? null);
82
+ const isVideo = $derived(current?.kind === 'video');
83
+ const canPage = $derived(list.length > 1);
84
+
85
+ function next() {
86
+ if (!canPage) return;
87
+ index = (index + 1) % list.length;
88
+ zoom = 1;
89
+ }
90
+ function prev() {
91
+ if (!canPage) return;
92
+ index = (index - 1 + list.length) % list.length;
93
+ zoom = 1;
94
+ }
95
+ function close() {
96
+ onClose?.();
97
+ }
98
+ function zoomIn() {
99
+ zoom = Math.min(4, zoom + 0.25);
100
+ }
101
+ function zoomOut() {
102
+ zoom = Math.max(0.25, zoom - 0.25);
103
+ }
104
+ function resetZoom() {
105
+ zoom = 1;
106
+ }
107
+
108
+ function handleKeydown(e: KeyboardEvent) {
109
+ if (e.key === 'Escape') {
110
+ e.preventDefault();
111
+ close();
112
+ } else if (e.key === 'ArrowRight') {
113
+ e.preventDefault();
114
+ next();
115
+ } else if (e.key === 'ArrowLeft') {
116
+ e.preventDefault();
117
+ prev();
118
+ } else if (e.key === '+' || e.key === '=') {
119
+ e.preventDefault();
120
+ zoomIn();
121
+ } else if (e.key === '-' || e.key === '_') {
122
+ e.preventDefault();
123
+ zoomOut();
124
+ } else if (e.key === '0') {
125
+ e.preventDefault();
126
+ resetZoom();
127
+ }
128
+ }
129
+
130
+ $effect(() => {
131
+ if (typeof window === 'undefined') return;
132
+ window.addEventListener('keydown', handleKeydown);
133
+ return () => window.removeEventListener('keydown', handleKeydown);
134
+ });
135
+ </script>
136
+
137
+ {#if current}
138
+ <div
139
+ class="mlb-scrim"
140
+ style="z-index: {zIndex};"
141
+ transition:fade={{ duration: 120 }}
142
+ role="dialog"
143
+ aria-modal="true"
144
+ aria-label={current.filename ?? 'Media preview'}
145
+ >
146
+ <!-- Backdrop click closes. Real button underlay so it's keyboard-safe. -->
147
+ <button
148
+ type="button"
149
+ class="mlb-backdrop"
150
+ aria-label="Close preview"
151
+ onclick={close}
152
+ ></button>
153
+
154
+ <!-- Header chrome -->
155
+ <div class="mlb-header">
156
+ <span class="mlb-title" title={current.filename ?? ''}>
157
+ {current.filename ?? 'Preview'}
158
+ {#if canPage}
159
+ <span class="mlb-count">{index + 1} / {list.length}</span>
160
+ {/if}
161
+ </span>
162
+ <div class="mlb-actions">
163
+ {#if !isVideo}
164
+ <button type="button" class="mlb-btn" onclick={zoomOut} aria-label="Zoom out">−</button>
165
+ <button type="button" class="mlb-btn" onclick={resetZoom} aria-label="Reset zoom">
166
+ {Math.round(zoom * 100)}%
167
+ </button>
168
+ <button type="button" class="mlb-btn" onclick={zoomIn} aria-label="Zoom in">+</button>
169
+ {/if}
170
+ <a
171
+ class="mlb-btn"
172
+ href={current.downloadUrl ?? current.src}
173
+ download={current.filename ?? ''}
174
+ aria-label="Download"
175
+ >
176
+ Download
177
+ </a>
178
+ <button type="button" class="mlb-btn mlb-close" onclick={close} aria-label="Close">
179
+ ×
180
+ </button>
181
+ </div>
182
+ </div>
183
+
184
+ <!-- Prev/next chrome -->
185
+ {#if canPage}
186
+ <button type="button" class="mlb-nav mlb-nav-prev" onclick={prev} aria-label="Previous">
187
+ ‹
188
+ </button>
189
+ <button type="button" class="mlb-nav mlb-nav-next" onclick={next} aria-label="Next">
190
+ ›
191
+ </button>
192
+ {/if}
193
+
194
+ <!-- Media -->
195
+ <div class="mlb-stage">
196
+ {#if isVideo}
197
+ <!-- svelte-ignore a11y_media_has_caption -->
198
+ <video
199
+ src={current.src}
200
+ controls
201
+ autoplay
202
+ class="mlb-video"
203
+ ></video>
204
+ {:else}
205
+ <img
206
+ src={current.src}
207
+ alt={current.filename ?? 'Preview'}
208
+ class="mlb-image"
209
+ style="transform: scale({zoom});"
210
+ />
211
+ {/if}
212
+ </div>
213
+ </div>
214
+ {/if}
215
+
216
+ <style>
217
+ .mlb-scrim {
218
+ position: fixed;
219
+ inset: 0;
220
+ background: oklch(0 0 0 / 0.86);
221
+ display: flex;
222
+ align-items: center;
223
+ justify-content: center;
224
+ }
225
+ .mlb-backdrop {
226
+ position: absolute;
227
+ inset: 0;
228
+ background: transparent;
229
+ border: none;
230
+ cursor: zoom-out;
231
+ }
232
+ .mlb-header {
233
+ position: absolute;
234
+ top: 0;
235
+ left: 0;
236
+ right: 0;
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: space-between;
240
+ gap: 0.75rem;
241
+ padding: 0.625rem 1rem;
242
+ background: linear-gradient(to bottom, oklch(0 0 0 / 0.55), transparent);
243
+ color: var(--color-base-100, #fff);
244
+ pointer-events: none;
245
+ z-index: 2;
246
+ }
247
+ .mlb-header > * {
248
+ pointer-events: auto;
249
+ }
250
+ .mlb-title {
251
+ font-size: 0.875rem;
252
+ font-weight: 500;
253
+ display: flex;
254
+ align-items: center;
255
+ gap: 0.75rem;
256
+ min-width: 0;
257
+ overflow: hidden;
258
+ text-overflow: ellipsis;
259
+ white-space: nowrap;
260
+ }
261
+ .mlb-count {
262
+ font-size: 0.75rem;
263
+ color: oklch(0.85 0 0 / 0.8);
264
+ font-variant-numeric: tabular-nums;
265
+ }
266
+ .mlb-actions {
267
+ display: flex;
268
+ align-items: center;
269
+ gap: 0.375rem;
270
+ }
271
+ .mlb-btn {
272
+ display: inline-flex;
273
+ align-items: center;
274
+ justify-content: center;
275
+ min-width: 2rem;
276
+ height: 2rem;
277
+ padding: 0 0.625rem;
278
+ font-size: 0.8125rem;
279
+ font-weight: 500;
280
+ color: oklch(0.95 0 0);
281
+ background: oklch(1 0 0 / 0.1);
282
+ border: 1px solid oklch(1 0 0 / 0.15);
283
+ border-radius: 0.375rem;
284
+ cursor: pointer;
285
+ text-decoration: none;
286
+ transition: background 0.15s;
287
+ }
288
+ .mlb-btn:hover {
289
+ background: oklch(1 0 0 / 0.2);
290
+ }
291
+ .mlb-close {
292
+ font-size: 1.25rem;
293
+ line-height: 1;
294
+ }
295
+ .mlb-nav {
296
+ position: absolute;
297
+ top: 50%;
298
+ transform: translateY(-50%);
299
+ width: 3rem;
300
+ height: 3rem;
301
+ background: oklch(1 0 0 / 0.1);
302
+ color: oklch(0.95 0 0);
303
+ font-size: 2rem;
304
+ line-height: 1;
305
+ border: 1px solid oklch(1 0 0 / 0.15);
306
+ border-radius: 999px;
307
+ cursor: pointer;
308
+ z-index: 2;
309
+ transition: background 0.15s;
310
+ }
311
+ .mlb-nav:hover {
312
+ background: oklch(1 0 0 / 0.2);
313
+ }
314
+ .mlb-nav-prev {
315
+ left: 1rem;
316
+ }
317
+ .mlb-nav-next {
318
+ right: 1rem;
319
+ }
320
+ .mlb-stage {
321
+ position: relative;
322
+ z-index: 1;
323
+ max-width: 92vw;
324
+ max-height: 88vh;
325
+ display: flex;
326
+ align-items: center;
327
+ justify-content: center;
328
+ }
329
+ .mlb-image {
330
+ max-width: 92vw;
331
+ max-height: 88vh;
332
+ object-fit: contain;
333
+ transition: transform 0.15s;
334
+ transform-origin: center center;
335
+ }
336
+ .mlb-video {
337
+ max-width: 92vw;
338
+ max-height: 88vh;
339
+ }
340
+ </style>
@@ -0,0 +1,24 @@
1
+ export type MediaItem = {
2
+ src: string;
3
+ filename?: string;
4
+ kind?: 'image' | 'video';
5
+ downloadUrl?: string;
6
+ };
7
+ interface Props {
8
+ /** Single-item shorthand — either provide `src`+`filename`+`kind`, or `items`+`initialIndex`. */
9
+ src?: string;
10
+ filename?: string;
11
+ kind?: 'image' | 'video';
12
+ downloadUrl?: string;
13
+ /** Array of items for carousel navigation. Overrides `src` when provided. */
14
+ items?: MediaItem[];
15
+ /** Start index into `items`. Default 0. */
16
+ initialIndex?: number;
17
+ /** Called when the user closes via Escape, backdrop click, or ×. */
18
+ onClose?: () => void;
19
+ /** Stacking context. Default 9040 (matches the app's existing overlay tier). */
20
+ zIndex?: number;
21
+ }
22
+ declare const MediaLightbox: import("svelte").Component<Props, {}, "">;
23
+ type MediaLightbox = ReturnType<typeof MediaLightbox>;
24
+ export default MediaLightbox;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Tooltip — component wrapper around the shared `use:tooltip` action.
4
+ *
5
+ * Prefer `use:tooltip` directly on any element you already control. This
6
+ * component form is convenient when you want to wrap an existing chunk
7
+ * of markup (a chip, a badge, an icon) without editing its element to
8
+ * add an attribute — e.g. inside a template that isn't easily touched.
9
+ * Both surfaces share the same warm/cool window and a11y wiring under
10
+ * the hood.
11
+ *
12
+ * Replaces the `<span class="tooltip" tabindex="0" data-tip="...">` hack:
13
+ * that pattern makes non-interactive spans "keyboard reachable" without
14
+ * ever routing to anything (a keyboard user tabs to a dead stop). This
15
+ * wrapper propagates the tip onto whatever's inside — if the child is
16
+ * already focusable (button/link/input), keyboard users get the tooltip
17
+ * on focus; if not, only pointer users do, which is the correct default.
18
+ *
19
+ * Usage:
20
+ * <Tooltip content="Reset to defaults">
21
+ * <button type="button" onclick={reset}>Reset</button>
22
+ * </Tooltip>
23
+ */
24
+
25
+ import { tooltip } from '../actions/tooltip';
26
+ import type { Snippet } from 'svelte';
27
+
28
+ interface Props {
29
+ /** Tooltip content (plain text). */
30
+ content: string;
31
+ /** Preferred placement. Default 'top'. */
32
+ placement?: 'top' | 'bottom' | 'left' | 'right';
33
+ /** ms to wait before showing (default 400; 0 = instant). */
34
+ delayMs?: number;
35
+ /** Force-disable — the wrapper renders its children with no tooltip attached. */
36
+ disabled?: boolean;
37
+ /** Wrapper element display. Default 'inline-flex' — matches the anchor's baseline behaviour. */
38
+ display?: 'inline' | 'inline-flex' | 'inline-block' | 'block' | 'contents';
39
+ children: Snippet;
40
+ }
41
+
42
+ let {
43
+ content,
44
+ placement = 'top',
45
+ delayMs = 400,
46
+ disabled = false,
47
+ display = 'inline-flex',
48
+ children,
49
+ }: Props = $props();
50
+ </script>
51
+
52
+ {#if disabled}
53
+ <span class="jatui-tooltip-wrap" style="display: {display};">{@render children()}</span>
54
+ {:else}
55
+ <span
56
+ class="jatui-tooltip-wrap"
57
+ style="display: {display};"
58
+ use:tooltip={{ text: content, placement, delayMs }}
59
+ >
60
+ {@render children()}
61
+ </span>
62
+ {/if}
63
+
64
+ <style>
65
+ .jatui-tooltip-wrap {
66
+ align-items: center;
67
+ }
68
+ </style>
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ interface Props {
3
+ /** Tooltip content (plain text). */
4
+ content: string;
5
+ /** Preferred placement. Default 'top'. */
6
+ placement?: 'top' | 'bottom' | 'left' | 'right';
7
+ /** ms to wait before showing (default 400; 0 = instant). */
8
+ delayMs?: number;
9
+ /** Force-disable — the wrapper renders its children with no tooltip attached. */
10
+ disabled?: boolean;
11
+ /** Wrapper element display. Default 'inline-flex' — matches the anchor's baseline behaviour. */
12
+ display?: 'inline' | 'inline-flex' | 'inline-block' | 'block' | 'contents';
13
+ children: Snippet;
14
+ }
15
+ declare const Tooltip: import("svelte").Component<Props, {}, "">;
16
+ type Tooltip = ReturnType<typeof Tooltip>;
17
+ export default Tooltip;
package/dist/index.d.ts CHANGED
@@ -37,7 +37,15 @@ export { default as DateRangePicker } from './components/DateRangePicker.svelte'
37
37
  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
+ export { default as BulkActionBar } from './components/BulkActionBar.svelte';
41
+ export type { BulkAction } from './components/BulkActionBar.svelte';
40
42
  export { default as HunkDiffView } from './components/HunkDiffView.svelte';
43
+ export { default as Tooltip } from './components/Tooltip.svelte';
44
+ export { default as DirtyFormHost } from './components/DirtyFormHost.svelte';
45
+ export { createDirtyGuard } from './utils/dirtyGuard';
46
+ export type { DirtyGuard, DirtyGuardOptions, BeforeNavigateFn } from './utils/dirtyGuard';
47
+ export { default as MediaLightbox } from './components/MediaLightbox.svelte';
48
+ export type { MediaItem } from './components/MediaLightbox.svelte';
41
49
  export { default as Button } from './components/Button.svelte';
42
50
  export { default as Badge } from './components/Badge.svelte';
43
51
  export { default as Card } from './components/Card.svelte';
@@ -90,7 +98,7 @@ export type { ShortenedLink } from './components/LinkShortener.svelte';
90
98
  export type { SearchDropdownOption, SearchDropdownGroup } from './components/SearchDropdown.svelte';
91
99
  export type { ChipSuggestion, SuggestionGroup, ChipInfo } from './components/ChipInput.svelte';
92
100
  export type { SortOption } from './components/SortDropdown.svelte';
93
- export type { ContextMenuItem } from './components/ContextMenu.svelte';
101
+ export type { ContextMenuItem, SubmitConfig as ContextMenuSubmitConfig } from './components/ContextMenu.svelte';
94
102
  export type { DisplaySegment } from './components/InlineEdit.svelte';
95
103
  export type { ActionButton, ActionDropdown, DropdownItem } from './components/FloatingActionBar.svelte';
96
104
  export type { SelectOption } from './components/SelectInput.svelte';
package/dist/index.js CHANGED
@@ -41,7 +41,13 @@ export { default as DateRangePicker } from './components/DateRangePicker.svelte'
41
41
  export { default as SortDropdown } from './components/SortDropdown.svelte';
42
42
  export { default as InlineEdit } from './components/InlineEdit.svelte';
43
43
  export { default as FloatingActionBar } from './components/FloatingActionBar.svelte';
44
+ export { default as BulkActionBar } from './components/BulkActionBar.svelte';
44
45
  export { default as HunkDiffView } from './components/HunkDiffView.svelte';
46
+ // Primitives — accessibility + form-editor scaffolding
47
+ export { default as Tooltip } from './components/Tooltip.svelte';
48
+ export { default as DirtyFormHost } from './components/DirtyFormHost.svelte';
49
+ export { createDirtyGuard } from './utils/dirtyGuard';
50
+ export { default as MediaLightbox } from './components/MediaLightbox.svelte';
45
51
  // Components — from Flush
46
52
  export { default as Button } from './components/Button.svelte';
47
53
  export { default as Badge } from './components/Badge.svelte';
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * Global promise-based confirm dialog.
3
- * Usage: const ok = await showConfirm({ title: '...', body: '...' })
3
+ * Usage:
4
+ * const ok = await showConfirm({ title: '...', body: '...' }) // string body
5
+ * const ok = await showConfirm({ title, body: warningSnippet }) // rich snippet body
6
+ *
7
+ * `body` accepts either a plain string (backwards-compatible) or a Svelte
8
+ * `Snippet` for rich content — conditional warnings, formatted amounts, etc.
4
9
  */
10
+ import type { Snippet } from 'svelte';
5
11
  export type ConfirmOptions = {
6
12
  title: string;
7
- body?: string;
13
+ /** Plain-text body OR a Snippet for rich content. */
14
+ body?: string | Snippet;
8
15
  confirmLabel?: string;
9
16
  cancelLabel?: string;
10
17
  danger?: boolean;
@@ -61,3 +61,16 @@
61
61
  animation: none;
62
62
  }
63
63
  }
64
+
65
+ /* ─── text-2xs — 11px micro-caption utility ─────────────────────────────
66
+ Standardizes the ~13+ ad-hoc text-[10px] / text-[0.7rem] micro-captions
67
+ scattered across JST routes (timestamps, hint text, footnote captions).
68
+ Between Tailwind's `text-xs` (0.75rem / 12px) and browser-min legibility.
69
+ Slight positive letter-spacing (0.01em) keeps it readable at 11px.
70
+ ─────────────────────────────────────────────────────────────────────── */
71
+
72
+ .text-2xs {
73
+ font-size: 0.6875rem; /* 11px */
74
+ line-height: 1rem; /* 16px */
75
+ letter-spacing: 0.01em;
76
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * createDirtyGuard — form dirty-state manager for a Svelte 5 editor.
3
+ *
4
+ * Wraps the four-move F12 pattern: dirty detection (baseline vs current),
5
+ * `beforeunload` guard while dirty, `beforeNavigate` guard for internal
6
+ * SvelteKit navigation, and a `cancel()` that snaps the draft back to
7
+ * baseline. UI (pill + Cancel button) is left to the caller so it can match
8
+ * the surrounding surface.
9
+ *
10
+ * SvelteKit's `beforeNavigate` is only wired when the caller passes it in via
11
+ * `beforeNavigate`. This keeps jatui a plain Svelte package with no
12
+ * SvelteKit peer-dep.
13
+ *
14
+ * Usage:
15
+ * import { beforeNavigate } from '$app/navigation';
16
+ * const guard = createDirtyGuard({
17
+ * baseline: () => initial,
18
+ * current: () => draft,
19
+ * onCancel: () => { draft = { ...initial } },
20
+ * beforeNavigate,
21
+ * });
22
+ * // In markup:
23
+ * {#if guard.dirty}<span class="pill">Unsaved changes</span>{/if}
24
+ */
25
+ export type BeforeNavigateFn = (handler: (nav: {
26
+ type: string;
27
+ cancel: () => void;
28
+ }) => void) => void;
29
+ export interface DirtyGuardOptions<T> {
30
+ /** Returns the baseline (saved) form value. */
31
+ baseline: () => T;
32
+ /** Returns the current (draft) form value. */
33
+ current: () => T;
34
+ /** Called when the user clicks Cancel or confirms a discard. Snap draft to baseline. */
35
+ onCancel?: () => void;
36
+ /** Custom equality — default is JSON.stringify comparison. */
37
+ equals?: (a: T, b: T) => boolean;
38
+ /** SvelteKit `beforeNavigate` — pass through from `$app/navigation` when available. */
39
+ beforeNavigate?: BeforeNavigateFn;
40
+ /** Prompt string for the internal-nav confirm. Default "Discard unsaved changes?" */
41
+ discardPrompt?: string;
42
+ }
43
+ export interface DirtyGuard {
44
+ /** True whenever current !== baseline (reactive). */
45
+ readonly dirty: boolean;
46
+ /** Snap the draft back to baseline (calls the caller's onCancel). */
47
+ cancel: () => void;
48
+ }
49
+ export declare function createDirtyGuard<T>(opts: DirtyGuardOptions<T>): DirtyGuard;
@@ -0,0 +1,61 @@
1
+ /* dirtyGuard.svelte.js generated by Svelte v5.53.11 */
2
+ import * as $ from 'svelte/internal/client';
3
+ import { untrack } from 'svelte';
4
+
5
+ function defaultEquals(a, b) {
6
+ try {
7
+ return JSON.stringify(a) === JSON.stringify(b);
8
+ } catch {
9
+ return a === b;
10
+ }
11
+ }
12
+
13
+ export function createDirtyGuard(opts) {
14
+ const eq = opts.equals ?? defaultEquals;
15
+ const dirty = $.derived(() => !eq(opts.baseline(), opts.current()));
16
+
17
+ // beforeunload — arm only while dirty (browser-native prompt).
18
+ $.user_effect(() => {
19
+ if (!$.get(dirty)) return;
20
+ if (typeof window === 'undefined') return;
21
+
22
+ function handler(e) {
23
+ e.preventDefault();
24
+ e.returnValue = '';
25
+ }
26
+
27
+ window.addEventListener('beforeunload', handler);
28
+
29
+ return () => window.removeEventListener('beforeunload', handler);
30
+ });
31
+
32
+ // SvelteKit internal navigation — client-side back/next/link nav does not
33
+ // fire beforeunload. Only wire when the caller passed beforeNavigate in.
34
+ if (opts.beforeNavigate) {
35
+ opts.beforeNavigate((nav) => {
36
+ if (!untrack(() => $.get(dirty))) return;
37
+
38
+ // 'leave' is browser tab-close / reload — already handled above.
39
+ if (nav.type === 'leave') return;
40
+
41
+ if (typeof window === 'undefined') return;
42
+
43
+ const prompt = opts.discardPrompt ?? 'Discard unsaved changes?';
44
+
45
+ if (!window.confirm(prompt)) {
46
+ nav.cancel();
47
+ }
48
+ });
49
+ }
50
+
51
+ function cancel() {
52
+ opts.onCancel?.();
53
+ }
54
+
55
+ return {
56
+ get dirty() {
57
+ return $.get(dirty);
58
+ },
59
+ cancel
60
+ };
61
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.2.32",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",