@joewinke/jatui 0.2.33 → 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.
@@ -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;
@@ -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;
@@ -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
@@ -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, ActionItem as ContextMenuActionItem, DividerItem as ContextMenuDividerItem, SubmitConfig as ContextMenuSubmitConfig } from './components/ContextMenu.types.js';
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;