@classic-homes/theme-svelte 1.29.0 → 1.30.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.
@@ -3,6 +3,8 @@
3
3
  import { cn } from '../utils.js';
4
4
  import type { Snippet } from 'svelte';
5
5
  import TriggerChild, { type TriggerSnippetProps } from './internal/TriggerChild.svelte';
6
+ import { createDiscardGuard } from './internal/discard-guard.svelte.js';
7
+ import DiscardPrompt from './internal/DiscardPrompt.svelte';
6
8
 
7
9
  interface Props {
8
10
  /** Whether the dialog is open */
@@ -39,6 +41,15 @@
39
41
  * keeps focus where the caller put it (e.g. a field it just revealed).
40
42
  */
41
43
  onCloseAutoFocus?: (event: Event) => void;
44
+ /**
45
+ * Unsaved work inside. When true, an overlay click, Escape or the close
46
+ * button asks "Discard unsaved changes?" instead of closing. Left unset,
47
+ * the dialog counts as dirty once anything inside is edited (typing, or a
48
+ * checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
49
+ * don't count). Pass `false` to turn the prompt off. Closing in code is
50
+ * never blocked.
51
+ */
52
+ dirty?: boolean;
42
53
  }
43
54
 
44
55
  // Every size is clamped to the viewport minus a 1rem gutter on phones; the
@@ -66,6 +77,7 @@
66
77
  headerActions,
67
78
  showClose = true,
68
79
  onCloseAutoFocus,
80
+ dirty,
69
81
  }: Props = $props();
70
82
 
71
83
  const uid = $props.id();
@@ -77,9 +89,21 @@
77
89
  onOpenChange?.(newOpen);
78
90
  }
79
91
 
80
- function handleClose() {
81
- open = false;
82
- onOpenChange?.(false);
92
+ const guard = createDiscardGuard(() => dirty);
93
+ let contentEl: HTMLElement | null = $state(null);
94
+
95
+ $effect(() => {
96
+ if (open) guard.reset();
97
+ });
98
+
99
+ function keepEditing() {
100
+ guard.keep();
101
+ contentEl?.focus();
102
+ }
103
+
104
+ function discard() {
105
+ guard.reset();
106
+ handleOpenChange(false);
83
107
  }
84
108
  </script>
85
109
 
@@ -97,7 +121,13 @@
97
121
  class="fixed inset-0 z-50 bg-foreground/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
98
122
  />
99
123
  <DialogPrimitive.Content
124
+ bind:ref={contentEl}
100
125
  {onCloseAutoFocus}
126
+ onInteractOutside={guard.dismiss}
127
+ onEscapeKeydown={guard.escape}
128
+ oninput={guard.track}
129
+ onchange={guard.track}
130
+ onclick={guard.track}
101
131
  class={cn(
102
132
  'fixed left-[50%] top-[50%] z-50 flex max-h-[calc(100dvh-2rem)] w-full translate-x-[-50%] translate-y-[-50%] flex-col gap-4 border border-border bg-background shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg overflow-hidden',
103
133
  sizeClasses[size],
@@ -133,7 +163,9 @@
133
163
  {@render headerActions()}
134
164
  {/if}
135
165
  {#if showClose}
136
- <DialogPrimitive.Close
166
+ <button
167
+ type="button"
168
+ onclick={() => guard.dismiss() && handleOpenChange(false)}
137
169
  class="flex h-9 w-9 items-center justify-center rounded-md bg-transparent text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none relative after:absolute after:-inset-1 after:content-['']"
138
170
  aria-label="Close dialog"
139
171
  >
@@ -153,7 +185,7 @@
153
185
  <path d="M18 6 6 18" />
154
186
  <path d="m6 6 12 12" />
155
187
  </svg>
156
- </DialogPrimitive.Close>
188
+ </button>
157
189
  {/if}
158
190
  </div>
159
191
  </div>
@@ -164,8 +196,10 @@
164
196
  {@render children()}
165
197
  </div>
166
198
 
167
- <!-- Footer section -->
168
- {#if footer}
199
+ <!-- Footer section; the discard prompt takes its place while it asks -->
200
+ {#if guard.confirming}
201
+ <DiscardPrompt class="px-6 py-4" onKeep={keepEditing} onDiscard={discard} />
202
+ {:else if footer}
169
203
  <div
170
204
  class="flex flex-col-reverse border-t border-border bg-muted/30 px-6 py-4 sm:flex-row sm:justify-end sm:space-x-2"
171
205
  >
@@ -35,6 +35,15 @@ interface Props {
35
35
  * keeps focus where the caller put it (e.g. a field it just revealed).
36
36
  */
37
37
  onCloseAutoFocus?: (event: Event) => void;
38
+ /**
39
+ * Unsaved work inside. When true, an overlay click, Escape or the close
40
+ * button asks "Discard unsaved changes?" instead of closing. Left unset,
41
+ * the dialog counts as dirty once anything inside is edited (typing, or a
42
+ * checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
43
+ * don't count). Pass `false` to turn the prompt off. Closing in code is
44
+ * never blocked.
45
+ */
46
+ dirty?: boolean;
38
47
  }
39
48
  declare const Dialog: import("svelte").Component<Props, {}, "open">;
40
49
  type Dialog = ReturnType<typeof Dialog>;
@@ -561,9 +561,13 @@
561
561
  path: 'M20 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zM8 14a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM5 17a3 3 0 0 1 6 0M14 9h4M14 13h2',
562
562
  },
563
563
  // === Communication ===
564
- bell: { path: 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 0 1-3.46 0' },
564
+ // Bell / bell-off follow current Lucide: rounded base, centred clapper, and a
565
+ // 2→22 slash that stays inside the glyph (the old Feather slash ran 1→23).
566
+ bell: {
567
+ path: 'M10.268 21a2 2 0 0 0 3.464 0M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326',
568
+ },
565
569
  'bell-off': {
566
- path: 'M13.73 21a2 2 0 0 1-3.46 0M18.63 13A17.89 17.89 0 0 1 18 8M6.26 6.26A5.86 5.86 0 0 0 6 8c0 7-3 9-3 9h14M1 1l22 22',
570
+ path: 'M10.268 21a2 2 0 0 0 3.464 0M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05M2 2l20 20',
567
571
  },
568
572
  inbox: {
569
573
  path: 'M22 12h-6l-2 3h-4l-2-3H2M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z',
@@ -4,6 +4,8 @@
4
4
  import type { Snippet } from 'svelte';
5
5
  import { cn } from '../utils.js';
6
6
  import Icon from './Icon.svelte';
7
+ import { createDiscardGuard } from './internal/discard-guard.svelte.js';
8
+ import DiscardPrompt from './internal/DiscardPrompt.svelte';
7
9
 
8
10
  /**
9
11
  * Sheet - a modal panel that slides in from an edge of the screen.
@@ -66,6 +68,15 @@
66
68
  footer?: Snippet;
67
69
  children: Snippet;
68
70
  class?: string;
71
+ /**
72
+ * Unsaved work inside. When true, a scrim click, Escape or the close
73
+ * button asks "Discard unsaved changes?" instead of closing. Left unset,
74
+ * the sheet counts as dirty once anything inside is edited (typing, or a
75
+ * checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
76
+ * don't count). Pass `false` to turn the prompt off. Closing in code is
77
+ * never blocked.
78
+ */
79
+ dirty?: boolean;
69
80
  }
70
81
 
71
82
  let {
@@ -80,6 +91,7 @@
80
91
  footer,
81
92
  children,
82
93
  class: className,
94
+ dirty,
83
95
  }: Props = $props();
84
96
 
85
97
  const styles = $derived(sheet({ side, size }));
@@ -88,16 +100,35 @@
88
100
  open = next;
89
101
  onOpenChange?.(next);
90
102
  }
103
+
104
+ const guard = createDiscardGuard(() => dirty);
105
+ let contentEl: HTMLElement | null = $state(null);
106
+
107
+ $effect(() => {
108
+ if (open) guard.reset();
109
+ });
110
+
111
+ function keepEditing() {
112
+ guard.keep();
113
+ contentEl?.focus();
114
+ }
115
+
116
+ function discard() {
117
+ guard.reset();
118
+ handleOpenChange(false);
119
+ }
91
120
  </script>
92
121
 
93
- <!-- Rendered inside Content, so Close finds its dialog -->
122
+ <!-- A plain button, not Close, so the discard guard can intercept it -->
94
123
  {#snippet closeButton()}
95
- <DialogPrimitive.Close
124
+ <button
125
+ type="button"
126
+ onclick={() => guard.dismiss() && handleOpenChange(false)}
96
127
  class="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
97
128
  aria-label="Close"
98
129
  >
99
130
  <Icon name="close" size={18} aria-hidden="true" />
100
- </DialogPrimitive.Close>
131
+ </button>
101
132
  {/snippet}
102
133
 
103
134
  <DialogPrimitive.Root bind:open onOpenChange={handleOpenChange}>
@@ -105,7 +136,15 @@
105
136
  <DialogPrimitive.Overlay
106
137
  class="fixed inset-0 z-50 bg-foreground/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
107
138
  />
108
- <DialogPrimitive.Content class={cn(styles.content(), className)}>
139
+ <DialogPrimitive.Content
140
+ bind:ref={contentEl}
141
+ class={cn(styles.content(), className)}
142
+ onInteractOutside={guard.dismiss}
143
+ onEscapeKeydown={guard.escape}
144
+ oninput={guard.track}
145
+ onchange={guard.track}
146
+ onclick={guard.track}
147
+ >
109
148
  {#if side === 'bottom'}
110
149
  <!-- Grab bar row; carries the close button when the title is hidden -->
111
150
  <div class="relative flex h-6 shrink-0 items-center justify-center">
@@ -145,7 +184,9 @@
145
184
  <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">
146
185
  {@render children()}
147
186
  </div>
148
- {#if footer}
187
+ {#if guard.confirming}
188
+ <DiscardPrompt class="px-4 py-3" onKeep={keepEditing} onDiscard={discard} />
189
+ {:else if footer}
149
190
  <div class="flex shrink-0 items-center justify-end gap-2 border-t border-border px-4 py-3">
150
191
  {@render footer()}
151
192
  </div>
@@ -22,6 +22,15 @@ interface Props {
22
22
  footer?: Snippet;
23
23
  children: Snippet;
24
24
  class?: string;
25
+ /**
26
+ * Unsaved work inside. When true, a scrim click, Escape or the close
27
+ * button asks "Discard unsaved changes?" instead of closing. Left unset,
28
+ * the sheet counts as dirty once anything inside is edited (typing, or a
29
+ * checkbox/switch/radio click; search inputs and `[data-dirty-ignore]`
30
+ * don't count). Pass `false` to turn the prompt off. Closing in code is
31
+ * never blocked.
32
+ */
33
+ dirty?: boolean;
25
34
  }
26
35
  declare const Sheet: import("svelte").Component<Props, {}, "open">;
27
36
  type Sheet = ReturnType<typeof Sheet>;
@@ -0,0 +1,47 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { cn } from '../../utils.js';
4
+ import { buttonVariants } from '../button-variants.js';
5
+
6
+ interface Props {
7
+ onKeep: () => void;
8
+ onDiscard: () => void;
9
+ class?: string;
10
+ }
11
+
12
+ let { onKeep, onDiscard, class: className }: Props = $props();
13
+
14
+ const id = $props.id();
15
+ let keepEl: HTMLButtonElement | undefined = $state();
16
+
17
+ // Safe default under the pointer/keyboard: a stray Enter keeps the work
18
+ onMount(() => keepEl?.focus());
19
+ </script>
20
+
21
+ <div
22
+ role="group"
23
+ aria-labelledby="{id}-text"
24
+ class={cn(
25
+ 'flex shrink-0 flex-col gap-3 border-t border-border bg-muted/30 sm:flex-row sm:items-center sm:justify-between',
26
+ className
27
+ )}
28
+ >
29
+ <p id="{id}-text" class="text-sm font-medium">Discard unsaved changes?</p>
30
+ <div class="flex flex-col-reverse gap-2 sm:flex-row">
31
+ <button
32
+ bind:this={keepEl}
33
+ type="button"
34
+ class={buttonVariants({ variant: 'outline', size: 'sm' })}
35
+ onclick={onKeep}
36
+ >
37
+ Keep editing
38
+ </button>
39
+ <button
40
+ type="button"
41
+ class={buttonVariants({ variant: 'destructive', size: 'sm' })}
42
+ onclick={onDiscard}
43
+ >
44
+ Discard
45
+ </button>
46
+ </div>
47
+ </div>
@@ -0,0 +1,8 @@
1
+ interface Props {
2
+ onKeep: () => void;
3
+ onDiscard: () => void;
4
+ class?: string;
5
+ }
6
+ declare const DiscardPrompt: import("svelte").Component<Props, {}, "">;
7
+ type DiscardPrompt = ReturnType<typeof DiscardPrompt>;
8
+ export default DiscardPrompt;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared by Dialog and Sheet: once someone has typed into the content, an
3
+ * overlay click, Escape or the close button asks before throwing that work
4
+ * away. Closing in code (`open = false`, a Cancel or Save button) is never
5
+ * blocked.
6
+ *
7
+ * Dirtiness is the caller's `dirty` prop when given, otherwise "an input or
8
+ * change event, or a checkbox/switch/radio click, happened inside since it
9
+ * opened". Search boxes and anything under `[data-dirty-ignore]` don't
10
+ * count, so pickers and filters stay one-click.
11
+ *
12
+ * A factory rather than a class: the package build lowers class fields,
13
+ * which `$state` doesn't survive.
14
+ */
15
+ export declare function createDiscardGuard(dirty: () => boolean | undefined): {
16
+ readonly confirming: boolean;
17
+ /** Forget earlier edits; call whenever the surface opens or is discarded */
18
+ reset(): void;
19
+ /** Hide the prompt and carry on editing */
20
+ keep(): void;
21
+ /** Delegated `oninput` / `onchange` / `onclick` for the content element */
22
+ track(event: Event): void;
23
+ dismiss: (event?: Event) => boolean;
24
+ /** Escape backs out of the prompt first, then behaves like `dismiss` */
25
+ escape(event: KeyboardEvent): void;
26
+ };
@@ -0,0 +1,69 @@
1
+ // Bits UI checkboxes, switches and radios are buttons: they click, not input
2
+ const TOGGLES = '[role="checkbox"], [role="switch"], [role="radio"]';
3
+ /**
4
+ * Shared by Dialog and Sheet: once someone has typed into the content, an
5
+ * overlay click, Escape or the close button asks before throwing that work
6
+ * away. Closing in code (`open = false`, a Cancel or Save button) is never
7
+ * blocked.
8
+ *
9
+ * Dirtiness is the caller's `dirty` prop when given, otherwise "an input or
10
+ * change event, or a checkbox/switch/radio click, happened inside since it
11
+ * opened". Search boxes and anything under `[data-dirty-ignore]` don't
12
+ * count, so pickers and filters stay one-click.
13
+ *
14
+ * A factory rather than a class: the package build lowers class fields,
15
+ * which `$state` doesn't survive.
16
+ */
17
+ export function createDiscardGuard(dirty) {
18
+ let confirming = $state(false);
19
+ let touched = $state(false);
20
+ const isDirty = () => dirty() ?? touched;
21
+ /**
22
+ * An overlay click or the close button. Returns true when it may close;
23
+ * otherwise cancels `event` (if any) and shows the discard prompt.
24
+ */
25
+ function dismiss(event) {
26
+ if (!isDirty())
27
+ return true;
28
+ event?.preventDefault();
29
+ confirming = true;
30
+ return false;
31
+ }
32
+ return {
33
+ get confirming() {
34
+ return confirming;
35
+ },
36
+ /** Forget earlier edits; call whenever the surface opens or is discarded */
37
+ reset() {
38
+ touched = false;
39
+ confirming = false;
40
+ },
41
+ /** Hide the prompt and carry on editing */
42
+ keep() {
43
+ confirming = false;
44
+ },
45
+ /** Delegated `oninput` / `onchange` / `onclick` for the content element */
46
+ track(event) {
47
+ const target = event.target;
48
+ if (!(target instanceof Element))
49
+ return;
50
+ if (event.type === 'click' && !target.closest(TOGGLES))
51
+ return;
52
+ if (target instanceof HTMLInputElement && target.type === 'search')
53
+ return;
54
+ if (target.closest('[data-dirty-ignore]'))
55
+ return;
56
+ touched = true;
57
+ },
58
+ dismiss,
59
+ /** Escape backs out of the prompt first, then behaves like `dismiss` */
60
+ escape(event) {
61
+ if (confirming) {
62
+ event.preventDefault();
63
+ confirming = false;
64
+ return;
65
+ }
66
+ dismiss(event);
67
+ },
68
+ };
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classic-homes/theme-svelte",
3
- "version": "1.29.0",
3
+ "version": "1.30.1",
4
4
  "description": "Svelte components for the Classic theme system",
5
5
  "type": "module",
6
6
  "svelte": "./dist/lib/index.js",