@widgetic/design-system 0.5.23 → 0.5.25

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.
Files changed (57) hide show
  1. package/dist/components/CopyableHeader.svelte +5 -3
  2. package/dist/components/index.d.ts +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/modals/BaseModal.svelte +257 -166
  5. package/dist/components/modals/BaseModal.svelte.d.ts +3 -0
  6. package/dist/components/modals/NewItemDialog.svelte +5 -7
  7. package/dist/components/modals/Portal.svelte +9 -7
  8. package/dist/components/modals/RenameDialog.svelte +5 -7
  9. package/dist/components/modals/modalStore.d.ts +9 -1
  10. package/dist/components/modals/modalStore.js +44 -11
  11. package/dist/components/navigation/Breadcrumbs.svelte +10 -4
  12. package/dist/components/navigation/breadcrumbsAvailableWidth.d.ts +10 -0
  13. package/dist/components/navigation/breadcrumbsAvailableWidth.js +10 -0
  14. package/dist/components/search-input/SearchInput.svelte +25 -13
  15. package/dist/components/search-input/SearchInput.svelte.d.ts +4 -0
  16. package/dist/components/search-input/index2.d.ts +14 -9
  17. package/dist/components/shell/NavDrawer.svelte +1 -1
  18. package/dist/components/shell/NavRow.svelte +4 -1
  19. package/dist/components/shell/SettingsDialog.svelte +48 -29
  20. package/dist/components/shell/SettingsDialog.svelte.d.ts +0 -4
  21. package/dist/components/shell/WidgetRow.svelte.d.ts +1 -1
  22. package/dist/components/shell/index.d.ts +0 -1
  23. package/dist/components/shell/index.js +0 -1
  24. package/dist/components/shell/nested-modal-guard.d.ts +25 -0
  25. package/dist/components/shell/nested-modal-guard.js +33 -0
  26. package/dist/components/ui/alert/index.d.ts +2 -2
  27. package/dist/components/ui/alert/index.js +4 -1
  28. package/dist/components/ui/avatar/avatar.svelte +22 -3
  29. package/dist/components/ui/button/button.svelte +1 -1
  30. package/dist/components/ui/button/button.svelte.d.ts +2 -2
  31. package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
  32. package/dist/components/ui/command/command-item.svelte +1 -1
  33. package/dist/components/ui/command/command-link-item.svelte +1 -1
  34. package/dist/components/ui/dialog/dialog-content.svelte +8 -1
  35. package/dist/components/ui/dialog/dialog-overlay.svelte +8 -1
  36. package/dist/components/ui/drawer/drawer-overlay.svelte +1 -1
  37. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +1 -1
  38. package/dist/components/ui/dropdown-menu/dropdown-menu-item.svelte +1 -1
  39. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +1 -1
  40. package/dist/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
  41. package/dist/components/ui/icon-button/icon-button.svelte +1 -1
  42. package/dist/components/ui/icon-button/icon-button.svelte.d.ts +2 -2
  43. package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
  44. package/dist/components/ui/select/select-content.svelte +5 -0
  45. package/dist/components/ui/select/select-item.svelte +8 -1
  46. package/dist/components/ui/select/select-trigger.svelte +3 -2
  47. package/dist/components/ui/stepper-button/stepper-button.svelte +1 -1
  48. package/dist/components/ui/switch/switch.svelte +4 -1
  49. package/dist/styles/global.css +3 -2
  50. package/dist/styles/tokens.css +21 -1
  51. package/dist/utils/inputModality.d.ts +16 -0
  52. package/dist/utils/inputModality.js +55 -0
  53. package/dist/utils/overlayMotion.d.ts +49 -0
  54. package/dist/utils/overlayMotion.js +71 -0
  55. package/package.json +1 -1
  56. package/dist/components/shell/messages.d.ts +0 -7
  57. package/dist/components/shell/messages.js +0 -5
@@ -130,9 +130,10 @@
130
130
  }
131
131
  </script>
132
132
 
133
- <div class={cn('copyable-header flex w-full flex-col gap-3', className)} {style}>
133
+ <!-- `min-w-0` on the root and on the title row: a `truncate` title has its whole text as min-content, so inside a grid or flex parent (a dialog) the row would push the copy button out of the parent instead of cutting the title. -->
134
+ <div class={cn('copyable-header flex w-full min-w-0 flex-col gap-3', className)} {style}>
134
135
  <!-- Title row: section label + copy control (Figma 32px). -->
135
- <div class={cn('flex h-8 items-center justify-between gap-3', headerBackgroundColor)}>
136
+ <div class={cn('flex h-8 min-w-0 items-center justify-between gap-3', headerBackgroundColor)}>
136
137
  <div class={cn('text-heading-section text-foreground min-w-0 truncate', headerTextClass)}>
137
138
  {headerText}
138
139
  </div>
@@ -262,7 +263,8 @@
262
263
  </div>
263
264
 
264
265
  {#if showHelpRow}
265
- <div class="flex flex-wrap items-center gap-1">
266
+ <!-- Figma: the help row is 20 high (the 14/20 link sets it), whatever the size of the link. -->
267
+ <div class="flex min-h-5 flex-wrap items-center gap-1">
266
268
  {#if helpPrompt}
267
269
  <span class="text-caption text-muted-foreground">{helpPrompt}</span>
268
270
  {/if}
@@ -16,7 +16,7 @@ export { Breadcrumbs, type BreadcrumbsProps } from './navigation';
16
16
  export { default as PDFViewer } from './pdf/PDFViewer.svelte';
17
17
  export { SearchInput } from './search-input';
18
18
  export { ShadEditor, checkShadEditorMessages, type ShadEditorMessageKey, type TipTapEditor, type TipTapEditorLevel } from './shad-editor';
19
- export { NavRow, navRowVariants, NavSectionLabel, ProfileRow, profileRowVariants, Sidebar, WidgetRow, widgetRowVariants, PageHeader, TopBar, NavDrawer, AppShell, ErrorState, defaultErrorStateMessages, type ErrorStateMessages, NotificationRow, type NotificationRowProps, SettingsNav, type SettingsNavProps, SettingsDialog, type SettingsDialogProps, Stat, type StatProps, statVariants, Banner, type BannerProps, type BannerTone, bannerVariants, Link, type LinkProps, type LinkSize, linkVariants, type SettingsNavGroup, type SettingsNavItem, settingsNavItemIsActive, externalLinkAttributes, statHintClass, type StatTone, type SettingsDialogMessages, defaultSettingsDialogMessages } from './shell';
19
+ export { NavRow, navRowVariants, NavSectionLabel, ProfileRow, profileRowVariants, Sidebar, WidgetRow, widgetRowVariants, PageHeader, TopBar, NavDrawer, AppShell, ErrorState, defaultErrorStateMessages, type ErrorStateMessages, NotificationRow, type NotificationRowProps, SettingsNav, type SettingsNavProps, SettingsDialog, type SettingsDialogProps, Stat, type StatProps, statVariants, Banner, type BannerProps, type BannerTone, bannerVariants, Link, type LinkProps, type LinkSize, linkVariants, type SettingsNavGroup, type SettingsNavItem, settingsNavItemIsActive, externalLinkAttributes, statHintClass, type StatTone } from './shell';
20
20
  export { ToasterModal, showToast } from './toast';
21
21
  export * as Accordion from './ui/accordion';
22
22
  export * as Alert from './ui/alert';
@@ -17,7 +17,7 @@ export { Breadcrumbs } from './navigation';
17
17
  export { default as PDFViewer } from './pdf/PDFViewer.svelte';
18
18
  export { SearchInput } from './search-input';
19
19
  export { ShadEditor, checkShadEditorMessages } from './shad-editor';
20
- export { NavRow, navRowVariants, NavSectionLabel, ProfileRow, profileRowVariants, Sidebar, WidgetRow, widgetRowVariants, PageHeader, TopBar, NavDrawer, AppShell, ErrorState, defaultErrorStateMessages, NotificationRow, SettingsNav, SettingsDialog, Stat, statVariants, Banner, bannerVariants, Link, linkVariants, settingsNavItemIsActive, externalLinkAttributes, statHintClass, defaultSettingsDialogMessages } from './shell';
20
+ export { NavRow, navRowVariants, NavSectionLabel, ProfileRow, profileRowVariants, Sidebar, WidgetRow, widgetRowVariants, PageHeader, TopBar, NavDrawer, AppShell, ErrorState, defaultErrorStateMessages, NotificationRow, SettingsNav, SettingsDialog, Stat, statVariants, Banner, bannerVariants, Link, linkVariants, settingsNavItemIsActive, externalLinkAttributes, statHintClass } from './shell';
21
21
  export { ToasterModal, showToast } from './toast';
22
22
  export * as Accordion from './ui/accordion';
23
23
  export * as Alert from './ui/alert';
@@ -1,8 +1,7 @@
1
1
  <script lang="ts">
2
2
  // Svelte imports
3
3
  import { fade, scale } from 'svelte/transition';
4
- import { cubicOut } from 'svelte/easing';
5
- import { createEventDispatcher, onMount } from 'svelte';
4
+ import { createEventDispatcher, onDestroy, onMount } from 'svelte';
6
5
  const dispatch = createEventDispatcher();
7
6
 
8
7
  // Design system componentsimports
@@ -10,7 +9,20 @@
10
9
  import { IconX } from '@tabler/icons-svelte';
11
10
 
12
11
  // Modal store
13
- import { modalState, openModal, closeModal } from './modalStore';
12
+ import {
13
+ modalState,
14
+ openModal,
15
+ closeModal,
16
+ registerModalInstance,
17
+ unregisterModalInstance,
18
+ isTopModalInstance
19
+ } from './modalStore';
20
+ import {
21
+ overlayBackdropBlurPx,
22
+ overlayContentScaleStart,
23
+ overlayTransitionDurationMs,
24
+ overlayTransitionEasing
25
+ } from '../../utils/overlayMotion.js';
14
26
 
15
27
  /* COMPONENT PROPS */
16
28
  // class and style props
@@ -42,15 +54,21 @@
42
54
  // A bare modal has no title row, no close button and no padding: its content draws all of it (the File Browser's
43
55
  // search dialog is one: the search field is the top row). Close it with Escape or a click on the backdrop.
44
56
  export let bare: boolean = false;
57
+ /** When false, clicks and taps on the scrim do not close (destructive confirms can set this). Default matches Figma: scrim dismisses. */
58
+ export let closeOnBackdropClick = true;
59
+ /** When false, Escape does not close. Default: Escape dismisses unless a nested control already handled the key. */
60
+ export let closeOnEscape = true;
45
61
 
46
62
  // children prop(svelte 5) to replace the traditional slot(svelte 4)
47
63
  export let children: any;
48
64
 
49
65
  /* CONSTANTS */
50
- // Modal transition and style variables
51
- const transitionDuration = 200;
52
- const backdropBlur = '4px';
53
- const easing = cubicOut;
66
+ const transitionDuration = overlayTransitionDurationMs;
67
+ const backdropBlur = `${overlayBackdropBlurPx}px`;
68
+ const easing = overlayTransitionEasing;
69
+ const transitionOpts = { duration: transitionDuration, easing };
70
+
71
+ const focusRingGutterPx = 4;
54
72
 
55
73
  // Update constants for modal constraints
56
74
  const PADDING = 24; // Consistent padding for sides and bottom
@@ -66,7 +84,7 @@
66
84
  // All Content Container vars
67
85
  const contentPadding = 16;
68
86
  let allContentHeight = 0;
69
- // The card draws a 1px border and 24px of padding (a bare modal has no padding) around the title row and the content.
87
+ // The card draws a 1px border and 24px of padding (a bare modal has none) around the title row and the content.
70
88
  const CARD_BORDER = 1;
71
89
  const CARD_PADDING = 24;
72
90
  $: cardChromeHeight = 2 * CARD_BORDER + (bare ? 0 : 2 * CARD_PADDING);
@@ -83,6 +101,17 @@
83
101
  $: contentAvailableHeight = Math.max(0, modalHeight - cardChromeHeight - titleRowHeight);
84
102
  let updateContentDimInterval: any;
85
103
 
104
+ /** Stays true until exit transitions finish or the instance is destroyed. */
105
+ let keepPortalMounted = false;
106
+ /** True while exit `out:` runs after `open` became false (parent `{#if}` or bind). */
107
+ let exiting = false;
108
+ let prevOpenForExit = open;
109
+ /** `openModal` pushed and this instance registered — popped only after outro or destroy. */
110
+ let storePushed = false;
111
+ let instanceToken: symbol | undefined;
112
+
113
+ let reduceMotion = false;
114
+
86
115
  // Show logs
87
116
  let showLogs = false;
88
117
 
@@ -96,73 +125,67 @@
96
125
  }
97
126
  }
98
127
 
128
+ function pushModalStore() {
129
+ if (!storePushed) {
130
+ openModal(modalType, modalData);
131
+ instanceToken = registerModalInstance();
132
+ storePushed = true;
133
+ }
134
+ }
135
+
136
+ function popModalStore() {
137
+ if (storePushed) {
138
+ closeModal();
139
+ if (instanceToken !== undefined) {
140
+ unregisterModalInstance(instanceToken);
141
+ instanceToken = undefined;
142
+ }
143
+ storePushed = false;
144
+ }
145
+ }
146
+
147
+ function isTopModal(): boolean {
148
+ return instanceToken !== undefined && isTopModalInstance(instanceToken);
149
+ }
150
+
99
151
  /**
100
152
  * LIFECYCLE HOOKS
101
153
  * */
102
154
  // Check stored state on mount and reopen if needed
103
155
  onMount(() => {
104
- if ($modalState.isOpen && !open) {
105
- // if(showLogs) console.log('Checking modal restoration:', {
106
- // currentType: modalType,
107
- // storedType: $modalState.modalType,
108
- // storedData: $modalState.data
109
- // });
156
+ try {
157
+ reduceMotion =
158
+ typeof window !== 'undefined' &&
159
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
160
+ } catch {
161
+ reduceMotion = false;
162
+ }
110
163
 
164
+ if ($modalState.isOpen && !open) {
111
165
  // Only reopen if this is the correct modal type
112
166
  if ($modalState.modalType === modalType) {
113
- // if(showLogs) console.log('Restoring modal state for:', modalType);
114
167
  open = true;
115
168
  modalData = $modalState.data;
116
169
  dispatch('restore', $modalState.data);
117
170
  }
118
171
  }
119
172
 
120
- // Initialize update content dimensions
121
- // updateViewportDimensions();
122
-
123
- // Add keydown listener
124
173
  window.addEventListener('keydown', handleKeydown);
125
174
 
126
- // Add resize listener
127
- // window.addEventListener('resize', updateViewportDimensions);
128
-
129
175
  return () => {
130
- // window.removeEventListener('resize', updateViewportDimensions);
131
176
  window.removeEventListener('keydown', handleKeydown);
132
-
133
- // Close the modal if it is open
134
- if (open) {
135
- closeModal();
136
- toggleUpdateContentDimensions(false);
137
- }
177
+ popModalStore();
178
+ toggleUpdateContentDimensions(false);
138
179
  };
139
180
  });
140
181
 
141
- // Function to update viewport dimensions
142
- function updateViewportDimensions() {
143
- // not used anymore
144
- // read window width and height
145
- // viewportWidth = window.innerWidth;
146
- // viewportHeight = window.innerHeight;
147
-
148
- // update the conetent holder height
149
- // setTimeout(() => {
150
- allContentHeight = 2 * contentPadding + titleHeight + contentHolderHeight;
151
- // if(showLogs) console.log('Modal current height:', allContentHeight);
152
- // let availableHeight = modalHeight - 2 * contentPadding;
153
- // if(showLogs) console.log('Modal current height:', allContentHeight, availableHeight);
154
- // contentHolderHeight = allContentHeight > availableHeight ? (availableHeight - titleHeight) + 'px' : 'auto';
155
- // modalFinalHeight = titleHeight + contentHeight + 2 * PADDING;
156
- // if(showLogs) console.log('Modal current height:', modalHeight, modalFinalHeight);
157
- // modalFinalHeight = Math.min(modalHeight, contentCurrentHeight);
158
- // }, 10);
159
- // if(showLogs) console.log('Updating viewport dimensions:', viewportWidth, viewportHeight);
160
- }
182
+ onDestroy(() => {
183
+ popModalStore();
184
+ });
161
185
 
162
186
  // Reactive Statement to Calculate dynamic modal position and dimensions
163
187
  $: modalStyle = (() => {
164
188
  if (viewportWidth === 0 || viewportHeight === 0) {
165
- // Return default styles during SSR
166
189
  return {
167
190
  width: width,
168
191
  height: height,
@@ -173,22 +196,17 @@
173
196
  };
174
197
  }
175
198
 
176
- // Parse provided dimensions
177
199
  const providedWidth = parseInt(width);
178
200
  const providedHeight = parseInt(height);
179
201
 
180
- // Calculate maximum dimensions
181
202
  const maxWidth = viewportWidth - 2 * PADDING;
182
203
  const maxHeight = viewportHeight - 2 * PADDING;
183
204
 
184
- // Calculate actual dimensions
185
205
  const actualWidth = Math.min(providedWidth, maxWidth);
186
206
  const actualHeight = Math.min(providedHeight, maxHeight);
187
207
 
188
- // If the actual height is less than maxHeight, center it vertically(but ensure it never goes above PADDING
189
208
  const centeredTop = Math.max(PADDING, (viewportHeight - actualHeight) / 2);
190
209
 
191
- // Return the calculated styles
192
210
  return {
193
211
  width: `${actualWidth}px`,
194
212
  height: `${actualHeight}px`,
@@ -200,9 +218,9 @@
200
218
  })();
201
219
 
202
220
  function handleKeydown(event: KeyboardEvent) {
221
+ if (!isTopModal()) return;
203
222
  if (!open) return;
204
223
 
205
- // Only log keyboard events that we actually handle
206
224
  if (['Backspace', 'Escape', 'Space'].includes(event.key)) {
207
225
  // if(showLogs) console.log('Modal handling key:', event.key, 'Target:', (event.target as HTMLElement).tagName);
208
226
  }
@@ -211,22 +229,26 @@
211
229
  const isInput =
212
230
  target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
213
231
 
214
- // Let input fields handle their own events
232
+ if (event.key === 'Escape' && closeOnEscape) {
233
+ // Escape: close from inputs too unless a nested listbox/combobox already called preventDefault.
234
+ if (!event.defaultPrevented) {
235
+ event.preventDefault();
236
+ event.stopPropagation();
237
+ handleClose();
238
+ }
239
+ return;
240
+ }
241
+
215
242
  if (isInput) {
216
243
  return;
217
244
  }
218
245
 
219
- // Prevent backspace navigation when not in input
220
246
  if (event.key === 'Backspace') {
221
247
  event.preventDefault();
222
248
  event.stopPropagation();
223
249
  return;
224
250
  }
225
251
 
226
- if (event.key === 'Escape') {
227
- handleClose();
228
- }
229
-
230
252
  if (enableSpaceKey && open && event.code === 'Space') {
231
253
  event.preventDefault();
232
254
  event.stopPropagation();
@@ -235,53 +257,57 @@
235
257
  }
236
258
 
237
259
  function handleClose() {
238
- // if(showLogs) console.log('handleClose');
239
-
240
- // change the open state to false
241
260
  open = false;
242
-
243
- // Close the modal
244
- closeModal();
245
-
246
- // Stop updating the content dimensions
247
261
  toggleUpdateContentDimensions(false);
248
-
249
- // Dispatch close event
250
262
  dispatch('close');
251
263
  }
252
264
 
253
- // Only update the modal store when the open state actually changes
254
- let previousOpen = open;
255
- $: if (open !== previousOpen) {
256
- previousOpen = open;
257
-
258
- // if(showLogs) console.log('Modal open state changed:', { open, modalType, modalData });
259
-
260
- // Open the modal if the open state is true
261
- if (open) {
262
- openModal(modalType, modalData);
263
- toggleUpdateContentDimensions(true);
264
- } else {
265
- closeModal();
266
- toggleUpdateContentDimensions(false);
267
- }
265
+ function handleBackdropPointerDown(event: PointerEvent) {
266
+ if (!open || !closeOnBackdropClick || !isTopModal()) return;
267
+ // Only a press that *starts* on the scrim closes; starting inside the card and releasing outside does not.
268
+ if (event.target !== event.currentTarget) return;
269
+ event.preventDefault();
270
+ event.stopPropagation();
271
+ handleClose();
268
272
  }
269
273
 
270
- // Improved backdrop click handler
271
274
  function handleBackdropClick(event: MouseEvent) {
272
- // Prevent any propagation
275
+ if (!open || !closeOnBackdropClick || !isTopModal()) return;
276
+ // Only a press that *starts* on the scrim closes; starting inside the card and releasing outside does not.
277
+ if (event.target !== event.currentTarget) return;
273
278
  event.preventDefault();
274
279
  event.stopPropagation();
280
+ }
275
281
 
276
- // Only handle clicks directly on the backdrop
277
- if (event.target === event.currentTarget) {
278
- // if(showLogs) console.log('Modal backdrop clicked!', event.type);
279
- handleClose();
282
+ function handlePanelOutroEnd() {
283
+ exiting = false;
284
+ popModalStore();
285
+ if (!open) {
286
+ keepPortalMounted = false;
280
287
  }
281
288
  }
289
+
290
+ // Only update the modal store when `open` is true (push once per open); closing is handled in outro / destroy.
291
+ $: if (open) {
292
+ pushModalStore();
293
+ keepPortalMounted = true;
294
+ exiting = false;
295
+ }
296
+
297
+ $: if (prevOpenForExit && !open) {
298
+ exiting = true;
299
+ keepPortalMounted = true;
300
+ }
301
+ $: prevOpenForExit = open;
302
+
303
+ $: if (open) {
304
+ toggleUpdateContentDimensions(true);
305
+ } else if (!exiting) {
306
+ toggleUpdateContentDimensions(false);
307
+ }
282
308
  </script>
283
309
 
284
- {#if open}
310
+ {#if keepPortalMounted}
285
311
  <Portal
286
312
  class="{className ? className + ' ' : ''}base-modal-portal"
287
313
  target={target || `modal-${modalType || 'default'}`}
@@ -289,26 +315,31 @@
289
315
  bind:clientWidth={viewportWidth}
290
316
  bind:clientHeight={viewportHeight}
291
317
  >
292
- <!-- Backdrop Container with blur restricted to file browser -->
293
- <!-- svelte-ignore a11y_no_static_element_interactions -->
294
- <!-- svelte-ignore a11y_click_events_have_key_events -->
295
- <div
296
- class="modal-backdrop-container bg-inverse/40 pointer-events-auto absolute inset-0"
297
- style="backdrop-filter: blur({backdropBlur}); z-index: 999;"
298
- transition:fade={{ duration: transitionDuration, easing }}
299
- on:mousedown={handleBackdropClick}
300
- on:click={handleBackdropClick}
301
- ></div>
302
-
303
- <!-- Modal Main Container -->
304
- <div
305
- class="modal-root-container pointer-events-none absolute inset-x-0 flex h-full w-full items-center justify-center"
306
- style="z-index: 1000;"
307
- >
308
- <!-- Modal Content Container -->
309
- <div
310
- class="modal-content-inner-container bg-background border-border rounded-panel pointer-events-auto fixed mx-6 overflow-hidden border select-text {bare ? 'p-0' : 'p-6'}"
311
- style="
318
+ {#if open || exiting}
319
+ <!-- Host-scoped scrim (absolute fills the portal host, not the viewport). -->
320
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
321
+ <div class="modal-dismiss-layer pointer-events-none absolute inset-0 z-[1000]">
322
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
323
+ <div
324
+ role="presentation"
325
+ data-widgetic-overlay="base-modal-backdrop"
326
+ class="modal-backdrop-container bg-muted-foreground/80 pointer-events-auto absolute inset-0"
327
+ style="backdrop-filter: blur({backdropBlur}); z-index: 0;"
328
+ in:fade|global={reduceMotion ? { duration: 0 } : transitionOpts}
329
+ out:fade|global={reduceMotion ? { duration: 0 } : transitionOpts}
330
+ on:pointerdown={handleBackdropPointerDown}
331
+ on:click={handleBackdropClick}
332
+ ></div>
333
+ <div
334
+ class="modal-root-container pointer-events-none absolute inset-0 z-[1] flex items-center justify-center px-6"
335
+ >
336
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
337
+ <div
338
+ data-widgetic-overlay="base-modal-panel"
339
+ class="modal-content-inner-container bg-background border-border rounded-panel pointer-events-auto relative max-h-[calc(100vh-3rem)] select-text overflow-hidden border {bare
340
+ ? 'p-0'
341
+ : 'p-6'}"
342
+ style="
312
343
  width: {modalStyle.width};
313
344
  height: {allContentHeight}px;
314
345
  min-width: {bare ? 100 : 2 * contentPadding + 100}px;
@@ -321,58 +352,111 @@
321
352
  -webkit-user-select: text;
322
353
  -moz-user-select: text;
323
354
  -ms-user-select: text;
324
- z-index: 1003;
355
+ z-index: 2;
325
356
  "
326
- bind:offsetHeight={modalHeight}
327
- in:scale={{ duration: transitionDuration, start: 0.95, opacity: 0, easing }}
328
- out:scale={{ duration: transitionDuration, start: 0.95, opacity: 0, easing }}
329
- on:click|stopPropagation
330
- >
331
- <!-- Content Title (a bare modal has none) -->
332
- {#if !bare}
333
- <div
334
- bind:clientHeight={titleHeight}
335
- class="modal-title-container flex items-start justify-between gap-2 overflow-hidden {titlePadding ===
336
- 'small'
337
- ? 'mb-2'
338
- : 'mb-4'}"
339
- aria-selected={isSelected}
340
- >
341
- {#if title}
342
- <h2 class="text-heading-page text-foreground min-w-0 flex-1 text-start">{title}</h2>
343
- {:else}
344
- <div class="min-w-0 flex-1"></div>
345
- {/if}
346
- <IconButton variant="ghost" label={closeLabel} onclick={handleClose}>
347
- <IconX class="size-4 stroke-[1.333]" />
348
- </IconButton>
349
- </div>
350
- {/if}
351
-
352
- <!-- Content Slot Container -->
353
- {#snippet contentSlot()}
354
- <div class="modal-content-slot-container w-full" bind:clientHeight={contentHolderHeight}>
355
- {@render children()}
356
- </div>
357
- {/snippet}
358
-
359
- <!-- Content with Scroll Area -->
360
- {#if showScroll}
361
- <WScrollArea
362
- class="modal-content-container scroll-container"
363
- type="always"
364
- scrollOutside={true}
365
- style="height: {contentAvailableHeight}px"
357
+ bind:offsetHeight={modalHeight}
358
+ in:scale|global={reduceMotion
359
+ ? { duration: 0 }
360
+ : {
361
+ duration: transitionDuration,
362
+ start: overlayContentScaleStart,
363
+ opacity: 0,
364
+ easing
365
+ }}
366
+ out:scale|global={reduceMotion
367
+ ? { duration: 0 }
368
+ : {
369
+ duration: transitionDuration,
370
+ start: overlayContentScaleStart,
371
+ opacity: 0,
372
+ easing
373
+ }}
374
+ on:outroend={handlePanelOutroEnd}
375
+ on:pointerdown|stopPropagation
376
+ on:click|stopPropagation
366
377
  >
367
- {@render contentSlot()}
368
- </WScrollArea>
369
- {:else}
370
- <div class="modal-content-container" style="height: {contentAvailableHeight}px;">
371
- {@render contentSlot()}
378
+ {#if !bare}
379
+ <div
380
+ bind:clientHeight={titleHeight}
381
+ class="modal-title-container flex items-start justify-between gap-2 {titlePadding === 'small'
382
+ ? 'mb-2'
383
+ : 'mb-4'}"
384
+ aria-selected={isSelected}
385
+ >
386
+ {#if title}
387
+ <h2 class="text-heading-page text-foreground min-w-0 flex-1 break-words text-start">
388
+ {title}
389
+ </h2>
390
+ {:else}
391
+ <div class="min-w-0 flex-1"></div>
392
+ {/if}
393
+ <IconButton variant="ghost" label={closeLabel} onclick={handleClose}>
394
+ <IconX class="size-4 stroke-[1.333]" />
395
+ </IconButton>
396
+ </div>
397
+ {/if}
398
+
399
+ {#snippet contentSlot()}
400
+ <div
401
+ class="modal-content-slot-container w-full"
402
+ bind:clientHeight={contentHolderHeight}
403
+ >
404
+ {@render children()}
405
+ </div>
406
+ {/snippet}
407
+
408
+ {#if bare}
409
+ {#if showScroll}
410
+ <WScrollArea
411
+ class="modal-content-container scroll-container"
412
+ type="always"
413
+ scrollOutside={true}
414
+ style="height: {contentAvailableHeight}px"
415
+ >
416
+ {@render contentSlot()}
417
+ </WScrollArea>
418
+ {:else}
419
+ <div class="modal-content-container" style="height: {contentAvailableHeight}px;">
420
+ {@render contentSlot()}
421
+ </div>
422
+ {/if}
423
+ {:else}
424
+ <!--
425
+ Focus ring gutter: the clip wrapper uses margin -4px / padding +4px so the overflow clip edge sits
426
+ outside the content box; the inner flow/scroll root keeps the same footprint as before (no inner
427
+ padding), so layout parity stays 25px from the card border. For showScroll, the scroll root is
428
+ extended by 8px and an inner wrapper provides the 4px pad for rings without changing measured height.
429
+ -->
430
+ <div
431
+ class="modal-focus-ring-clip min-h-0 overflow-hidden"
432
+ style="margin: -{focusRingGutterPx}px; padding: {focusRingGutterPx}px; border-radius: inherit;"
433
+ >
434
+ {#if showScroll}
435
+ <WScrollArea
436
+ class="modal-scroll-gutter-host scroll-container max-h-full min-h-0"
437
+ type="always"
438
+ scrollOutside={true}
439
+ style="margin: -{focusRingGutterPx}px; width: calc(100% + {2 *
440
+ focusRingGutterPx}px); height: {contentAvailableHeight + 2 * focusRingGutterPx}px;"
441
+ >
442
+ <div class="modal-scroll-focus-gutter box-border min-h-0 p-1">
443
+ {@render contentSlot()}
444
+ </div>
445
+ </WScrollArea>
446
+ {:else}
447
+ <div
448
+ class="modal-content-flow max-h-full min-h-0"
449
+ style="height: {contentAvailableHeight}px;"
450
+ >
451
+ {@render contentSlot()}
452
+ </div>
453
+ {/if}
454
+ </div>
455
+ {/if}
372
456
  </div>
373
- {/if}
457
+ </div>
374
458
  </div>
375
- </div>
459
+ {/if}
376
460
  </Portal>
377
461
  {/if}
378
462
 
@@ -383,9 +467,18 @@
383
467
  overflow: hidden;
384
468
  }
385
469
 
386
- /* Add specific styling for the backdrop to ensure it captures all clicks */
470
+ /* Non-scroll body: no overflow:hidden here — the outer card + clip wrapper handle clipping; avoids cutting focus rings. */
471
+ .modal-content-flow {
472
+ width: 100%;
473
+ overflow: visible;
474
+ }
475
+
476
+ .modal-dismiss-layer {
477
+ pointer-events: none;
478
+ }
479
+
387
480
  .modal-backdrop-container {
388
- pointer-events: all !important;
481
+ pointer-events: auto;
389
482
  }
390
483
 
391
484
  :global(.ScrollAreaViewport) {
@@ -398,7 +491,6 @@
398
491
  display: none;
399
492
  }
400
493
 
401
- /* Ensure text selection works in modals */
402
494
  :global(.base-modal-portal),
403
495
  :global(.modal-content-inner-container),
404
496
  :global(.modal-content-root-container) {
@@ -408,7 +500,6 @@
408
500
  -ms-user-select: text !important;
409
501
  }
410
502
 
411
- /* Ensure text selection works in input fields */
412
503
  :global(.base-modal-portal input),
413
504
  :global(.base-modal-portal textarea),
414
505
  :global(.base-modal-portal select),
@@ -29,8 +29,11 @@ declare const BaseModal: $$__sveltets_2_IsomorphicComponent<{
29
29
  animateSize?: boolean;
30
30
  closeLabel?: string;
31
31
  bare?: boolean;
32
+ /** When false, clicks and taps on the scrim do not close (destructive confirms can set this). Default matches Figma: scrim dismisses. */ closeOnBackdropClick?: boolean;
33
+ /** When false, Escape does not close. Default: Escape dismisses unless a nested control already handled the key. */ closeOnEscape?: boolean;
32
34
  children: any;
33
35
  }, {
36
+ pointerdown: PointerEvent;
34
37
  click: PointerEvent;
35
38
  restore: CustomEvent<any>;
36
39
  spaceKey: CustomEvent<any>;
@@ -172,14 +172,12 @@
172
172
  aria-invalid={Boolean(currentError)}
173
173
  class={currentError ? 'border-destructive' : ''}
174
174
  onkeydown={(e) => {
175
- if (e.key === 'Escape') {
176
- handleClose();
177
- } else {
175
+ if (e.key !== 'Escape') {
178
176
  e.stopPropagation();
179
- if (e.key === 'Enter') {
180
- e.preventDefault();
181
- handleSubmit();
182
- }
177
+ }
178
+ if (e.key === 'Enter') {
179
+ e.preventDefault();
180
+ handleSubmit();
183
181
  }
184
182
  }}
185
183
  oninput={(e) => {