@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
@@ -53,7 +53,7 @@
53
53
  // also carries (e.g. target `app-header-widget-picker` for a dropdown
54
54
  // whose root div has that same class, so outside-click detection can
55
55
  // find it). The slot is already rendered inside `portal` when onMount
56
- // runs, so the class lookup above can return that inner div. Moving
56
+ // runs, so the class lookup can return that inner div. Moving
57
57
  // `portal` into it then throws
58
58
  // "HierarchyRequestError: The new child element contains the parent".
59
59
  // Anything equal to, inside, or containing `portal` is not a host.
@@ -79,13 +79,15 @@
79
79
  });
80
80
 
81
81
  onDestroy(() => {
82
- // Additional cleanup on component destroy
83
- if (portal && host && host.contains(portal)) {
84
- host.removeChild(portal);
82
+ const portalEl = portal;
83
+ const hostEl = host;
84
+ // Remove as soon as Svelte tears this Portal down (after global outros on portalled content have finished).
85
+ // Deferring removal with setTimeout left detached DOM on screen at full opacity after transitions were torn down.
86
+ if (portalEl && hostEl && hostEl.contains(portalEl)) {
87
+ hostEl.removeChild(portalEl);
85
88
  }
86
- // Only remove hosts Portal created on body — never delete editor portal slots
87
- if (hostCreatedByPortal && host?.parentNode) {
88
- host.remove();
89
+ if (hostCreatedByPortal && hostEl?.parentNode) {
90
+ hostEl.remove();
89
91
  }
90
92
  });
91
93
  </script>
@@ -209,14 +209,12 @@
209
209
  class={validationMessage ? 'border-destructive' : ''}
210
210
  oninput={handleInput}
211
211
  onkeydown={(e) => {
212
- if (e.key === 'Escape') {
213
- handleClose();
214
- } else {
212
+ if (e.key !== 'Escape') {
215
213
  e.stopPropagation();
216
- if (e.key === 'Enter') {
217
- e.preventDefault();
218
- handleRename();
219
- }
214
+ }
215
+ if (e.key === 'Enter') {
216
+ e.preventDefault();
217
+ handleRename();
220
218
  }
221
219
  }}
222
220
  onfocus={(e) => {
@@ -1,10 +1,18 @@
1
1
  interface ModalState {
2
2
  isOpen: boolean;
3
+ /** Top-most open modal generation; used with `openModal` / `closeModal` for restore and global `modalState`. */
4
+ generation?: number;
3
5
  modalType?: string;
4
6
  data?: any;
5
7
  }
6
8
  export declare const modalState: import("svelte/store").Writable<ModalState>;
7
9
  export declare const isModalOpen: import("svelte/store").Writable<boolean>;
8
- export declare function openModal(modalType?: string, data?: any): void;
10
+ export declare function openModal(modalType?: string, data?: any): number;
9
11
  export declare function closeModal(): void;
12
+ /** Call when a BaseModal instance opens — returns an opaque token used with `isTopModalInstance` / `unregisterModalInstance`. */
13
+ export declare function registerModalInstance(): symbol;
14
+ /** Remove this instance from the stack (e.g. closed or destroyed). Safe if already removed. */
15
+ export declare function unregisterModalInstance(token: symbol): void;
16
+ /** True when this instance should handle backdrop / Escape while several modals are stacked. */
17
+ export declare function isTopModalInstance(token: symbol): boolean;
10
18
  export {};
@@ -1,7 +1,4 @@
1
1
  import { writable } from 'svelte/store';
2
- // import { browser } from '$app/environment';
3
- // Determine if running in browser (avoids reference errors during SSR)
4
- const browser = typeof window !== 'undefined';
5
2
  // Debug flag – set to true when you need to trace modal behaviour
6
3
  const showLogs = false;
7
4
  /*
@@ -12,26 +9,62 @@ const showLogs = false;
12
9
  * clean slate after any refresh.
13
10
  */
14
11
  // Always start with a closed modal on fresh page loads
15
- const getInitialState = () => ({ isOpen: false });
12
+ const getInitialState = () => ({ isOpen: false, generation: 0 });
16
13
  // Central writable store for runtime modal state
17
14
  export const modalState = writable(getInitialState());
18
15
  // Backwards-compatibility helper – exposes only the boolean flag
19
16
  export const isModalOpen = writable(getInitialState().isOpen);
20
17
  // Keep `isModalOpen` in sync (without persisting to localStorage)
21
- modalState.subscribe(state => {
18
+ modalState.subscribe((state) => {
22
19
  // if (showLogs) console.log('Modal state updated:', state);
23
20
  isModalOpen.set(state.isOpen);
24
21
  });
22
+ let generationCounter = 0;
23
+ const openStack = [];
24
+ /** Which BaseModal instance is top-most for backdrop + Escape (independent of `generation` timing). */
25
+ const modalInstanceStack = [];
26
+ function syncStoreFromStack() {
27
+ const top = openStack[openStack.length - 1];
28
+ if (!top) {
29
+ modalState.set(getInitialState());
30
+ }
31
+ else {
32
+ modalState.set({
33
+ isOpen: true,
34
+ generation: top.generation,
35
+ modalType: top.modalType,
36
+ data: top.data
37
+ });
38
+ }
39
+ }
25
40
  // Helper functions to manage modal state
26
41
  export function openModal(modalType, data) {
27
42
  // if (showLogs) console.log('Opening modal:', { modalType, data });
28
- modalState.set({
29
- isOpen: true,
30
- modalType,
31
- data,
32
- });
43
+ generationCounter += 1;
44
+ const entry = { generation: generationCounter, modalType, data };
45
+ openStack.push(entry);
46
+ syncStoreFromStack();
47
+ return entry.generation;
33
48
  }
34
49
  export function closeModal() {
35
50
  // if (showLogs) console.log('Closing modal');
36
- modalState.set({ isOpen: false });
51
+ openStack.pop();
52
+ syncStoreFromStack();
53
+ }
54
+ /** Call when a BaseModal instance opens — returns an opaque token used with `isTopModalInstance` / `unregisterModalInstance`. */
55
+ export function registerModalInstance() {
56
+ const token = Symbol('base-modal-instance');
57
+ modalInstanceStack.push(token);
58
+ return token;
59
+ }
60
+ /** Remove this instance from the stack (e.g. closed or destroyed). Safe if already removed. */
61
+ export function unregisterModalInstance(token) {
62
+ const idx = modalInstanceStack.lastIndexOf(token);
63
+ if (idx >= 0)
64
+ modalInstanceStack.splice(idx, 1);
65
+ }
66
+ /** True when this instance should handle backdrop / Escape while several modals are stacked. */
67
+ export function isTopModalInstance(token) {
68
+ const n = modalInstanceStack.length;
69
+ return n > 0 && modalInstanceStack[n - 1] === token;
37
70
  }
@@ -15,6 +15,7 @@
15
15
  // Types Import
16
16
  import type { BreadcrumbsProps } from './types.js';
17
17
  import { dirForUserText, useTextDirection } from '../textDirection.js';
18
+ import { breadcrumbsAvailableWidthPx } from './breadcrumbsAvailableWidth.js';
18
19
 
19
20
  // Environment Import
20
21
  // import { browser } from '$app/environment';
@@ -106,8 +107,8 @@
106
107
  function updateAvailableWidth() {
107
108
  if (!browser || !navElement || !breadcrumbsContainer) return;
108
109
 
109
- // First try to get the header from FileBrowser structure
110
- const containerElement = navElement.closest('header') || navElement;
110
+ const headerAncestor = navElement.closest('header');
111
+ const containerElement = headerAncestor || navElement;
111
112
  headerElement = containerElement;
112
113
 
113
114
  // Get all the measurements we need
@@ -127,7 +128,12 @@
127
128
  const rightOffset = measurements.rightContent ? measurements.rightContent.width : 24;
128
129
 
129
130
  // Calculate available width for breadcrumbs
130
- availableWidth = measurements.container.width - leftOffset - rightOffset - 72; // 72px for padding
131
+ availableWidth = breadcrumbsAvailableWidthPx({
132
+ containerWidthPx: measurements.container.width,
133
+ leftOffsetPx: leftOffset,
134
+ rightOffsetPx: rightOffset,
135
+ headerAncestor
136
+ });
131
137
  }
132
138
 
133
139
  function handleMediaQuery(e: MediaQueryListEvent | MediaQueryList) {
@@ -162,7 +168,7 @@
162
168
  handleMediaQuery(mediaQuery);
163
169
  mediaQuery.addEventListener('change', handleMediaQuery);
164
170
 
165
- // Get the header from FileBrowser structure
171
+ // First try to get the header from FileBrowser structure
166
172
  if (navElement) {
167
173
  const header = navElement.closest('header');
168
174
  if (header) {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Pure helpers for Breadcrumbs width math (header vs non-header container).
3
+ */
4
+ export declare function breadcrumbsLayoutPaddingPx(headerAncestor: HTMLElement | null): number;
5
+ export declare function breadcrumbsAvailableWidthPx(options: {
6
+ containerWidthPx: number;
7
+ leftOffsetPx: number;
8
+ rightOffsetPx: number;
9
+ headerAncestor: HTMLElement | null;
10
+ }): number;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Pure helpers for Breadcrumbs width math (header vs non-header container).
3
+ */
4
+ export function breadcrumbsLayoutPaddingPx(headerAncestor) {
5
+ return headerAncestor ? 72 : 0;
6
+ }
7
+ export function breadcrumbsAvailableWidthPx(options) {
8
+ const layoutPaddingPx = breadcrumbsLayoutPaddingPx(options.headerAncestor);
9
+ return options.containerWidthPx - options.leftOffsetPx - options.rightOffsetPx - layoutPaddingPx;
10
+ }
@@ -1,52 +1,64 @@
1
1
  <!--
2
2
  Figma: Search Field (component set 189:1138, node 189:1138).
3
+ The same field works as a plain search control and as the textbox of a combobox: pass `inputAttributes` (role, aria-*,
4
+ autocomplete) from the parent; `type`, `value`, and the input `class` always come from this component so the look stays one.
3
5
  -->
4
6
  <script lang="ts">
5
- import IconSearch from "@tabler/icons-svelte/icons/search";
6
- import IconX from "@tabler/icons-svelte/icons/x";
7
+ import IconSearch from '@tabler/icons-svelte/icons/search';
8
+ import IconX from '@tabler/icons-svelte/icons/x';
7
9
 
8
- export let placeholder = "Search…";
9
- export let value = "";
10
+ export let placeholder = 'Search…';
11
+ export let value = '';
10
12
  export let onInput: (event: Event) => Promise<void> | void = () => {};
11
13
  export let onKeydown: (event: KeyboardEvent) => void = () => {};
12
14
  export let onClear: () => void = () => {
13
- value = "";
15
+ value = '';
14
16
  };
15
17
  export let onPaste: (event: ClipboardEvent) => void = () => {};
16
- export let containerClass = "";
17
- export let inputClass = "";
18
- export let clearLabel = "Clear search";
18
+ export let onFocus: (event: FocusEvent) => void = () => {};
19
+ export let onBlur: (event: FocusEvent) => void = () => {};
20
+ export let containerClass = '';
21
+ export let inputClass = '';
22
+ export let clearLabel = 'Clear search';
23
+ /** Bindable ref to the native input (e.g. combobox controllers call focus()). */
24
+ export let inputElement: HTMLInputElement | null = null;
25
+ /** Spread first; this component’s `type`, `value`, and `class` are set after so they override clashing keys from the spread. */
26
+ export let inputAttributes: Record<string, unknown> = {};
19
27
  </script>
20
28
 
21
29
  <div class="w-full {containerClass}">
22
30
  <div
23
- class="group relative flex h-9 w-full items-center gap-2 rounded-lg bg-accent px-3 transition-colors duration-fast focus-within:bg-accent hover:bg-accent-hover"
31
+ class="group bg-accent duration-fast focus-within:bg-accent hover:bg-accent-hover relative flex h-9 w-full items-center gap-2 rounded-lg px-3 transition-colors"
24
32
  >
25
33
  <IconSearch
26
- class="size-4 shrink-0 stroke-[1.333] text-muted-foreground transition-colors duration-fast group-focus-within:text-foreground {value
34
+ class="text-muted-foreground duration-fast group-focus-within:text-foreground size-4 shrink-0 stroke-[1.333] transition-colors {value
27
35
  ? 'text-foreground'
28
36
  : ''}"
29
37
  aria-hidden="true"
30
38
  />
31
39
  <input
40
+ {...inputAttributes}
32
41
  type="search"
42
+ bind:this={inputElement}
33
43
  {placeholder}
34
44
  bind:value
35
45
  oninput={onInput}
36
46
  onkeydown={(e) => {
37
- if (e.key === "Backspace" && !value) {
47
+ if (e.key === 'Backspace' && !value) {
38
48
  e.preventDefault();
39
49
  return;
40
50
  }
41
51
  onKeydown(e);
42
52
  }}
43
53
  onpaste={onPaste}
44
- class="min-w-0 flex-1 border-0 bg-transparent text-body text-foreground placeholder:text-muted-foreground focus:outline-hidden {inputClass}"
54
+ onfocus={onFocus}
55
+ onblur={onBlur}
56
+ class="text-body text-foreground placeholder:text-muted-foreground min-w-0 flex-1 border-0 bg-transparent focus:outline-hidden [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden {inputClass}"
45
57
  />
46
58
  {#if value}
47
59
  <button
48
60
  type="button"
49
- class="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-colors duration-fast hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
61
+ class="text-muted-foreground duration-fast hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background inline-flex size-4 shrink-0 items-center justify-center transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden"
50
62
  aria-label={clearLabel}
51
63
  onclick={onClear}
52
64
  >
@@ -18,9 +18,13 @@ declare const SearchInput: $$__sveltets_2_IsomorphicComponent<{
18
18
  onKeydown?: (event: KeyboardEvent) => void;
19
19
  onClear?: () => void;
20
20
  onPaste?: (event: ClipboardEvent) => void;
21
+ onFocus?: (event: FocusEvent) => void;
22
+ onBlur?: (event: FocusEvent) => void;
21
23
  containerClass?: string;
22
24
  inputClass?: string;
23
25
  clearLabel?: string;
26
+ /** Bindable ref to the native input (e.g. combobox controllers call focus()). */ inputElement?: HTMLInputElement | null;
27
+ /** Spread first; this component’s `type`, `value`, and `class` are set after so they override clashing keys from the spread. */ inputAttributes?: Record<string, unknown>;
24
28
  }, {
25
29
  [evt: string]: CustomEvent<any>;
26
30
  }, {}, {}, string>;
@@ -1,14 +1,19 @@
1
1
  import { SvelteComponent } from 'svelte';
2
2
 
3
3
  export interface SearchInputProps {
4
- placeholder?: string;
5
- value?: string;
6
- onInput?: (event: Event) => Promise<void> | void;
7
- onKeydown?: (event: KeyboardEvent) => void;
8
- onClear?: () => void;
9
- onPaste?: (event: ClipboardEvent) => void;
10
- containerClass?: string;
11
- inputClass?: string;
4
+ placeholder?: string;
5
+ value?: string;
6
+ onInput?: (event: Event) => Promise<void> | void;
7
+ onKeydown?: (event: KeyboardEvent) => void;
8
+ onClear?: () => void;
9
+ onPaste?: (event: ClipboardEvent) => void;
10
+ onFocus?: (event: FocusEvent) => void;
11
+ onBlur?: (event: FocusEvent) => void;
12
+ containerClass?: string;
13
+ inputClass?: string;
14
+ clearLabel?: string;
15
+ inputElement?: HTMLInputElement | null;
16
+ inputAttributes?: Record<string, unknown>;
12
17
  }
13
18
 
14
- export default class SearchInput extends SvelteComponent<SearchInputProps> {}
19
+ export default class SearchInput extends SvelteComponent<SearchInputProps> {}
@@ -91,7 +91,7 @@
91
91
  <button
92
92
  type="button"
93
93
  class={cn(
94
- 'bg-foreground/50 absolute inset-0 motion-reduce:transition-none',
94
+ 'bg-muted-foreground/80 absolute inset-0 backdrop-blur-[3px] motion-reduce:transition-none',
95
95
  visible ? 'opacity-100' : 'opacity-0'
96
96
  )}
97
97
  style="transition: opacity {transitionMs}ms {transitionEase};"
@@ -8,7 +8,10 @@
8
8
  variants: {
9
9
  active: {
10
10
  true: 'bg-sidebar-accent text-sidebar-accent-foreground text-body-medium',
11
- false: 'text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
11
+ // The inactive row carries its own type style (14/20 regular, Figma Nav Row): without it the label took the size
12
+ // of whatever it sat in (16px in a dialog), bigger than the active row's 14/20 medium.
13
+ false:
14
+ 'text-body text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground'
12
15
  }
13
16
  },
14
17
  defaultVariants: {
@@ -11,10 +11,6 @@
11
11
  groups: import('./settings-nav-types.js').SettingsNavGroup[];
12
12
  onSelect?: (id: string) => void;
13
13
  closeLabel?: string;
14
- messages?: import('./messages.js').SettingsDialogMessages;
15
- privacyHref?: string;
16
- termsHref?: string;
17
- cookieHref?: string;
18
14
  children: import('svelte').Snippet;
19
15
  descriptionSlot?: import('svelte').Snippet;
20
16
  };
@@ -26,9 +22,10 @@
26
22
  import type { Snippet } from 'svelte';
27
23
  import * as Dialog from '../ui/dialog/index.js';
28
24
  import { cn } from '../../utils.js';
25
+ import { modalState } from '../modals/modalStore.js';
29
26
  import SettingsNav from './SettingsNav.svelte';
30
- import { defaultSettingsDialogMessages, type SettingsDialogMessages } from './messages.js';
31
27
  import type { SettingsNavGroup } from './settings-nav-types.js';
28
+ import { createNestedModalTracker } from './nested-modal-guard.js';
32
29
 
33
30
  let {
34
31
  open = $bindable(false),
@@ -39,49 +36,71 @@
39
36
  groups,
40
37
  onSelect,
41
38
  closeLabel = 'Close',
42
- messages = defaultSettingsDialogMessages,
43
- privacyHref = '#',
44
- termsHref = '#',
45
- cookieHref = '#',
46
39
  children,
47
40
  descriptionSlot
48
41
  }: SettingsDialogProps = $props();
42
+
43
+ // bits-ui sets `contain: layout style` inline on every dialog content, and `contain: layout` makes that box the containing
44
+ // block of every `position: fixed` element inside it. A pane can hold an inline overlay of its own (a confirm dialog written
45
+ // as `fixed inset-0`): with containment on it would be drawn, and clipped, inside this box instead of over the viewport.
46
+ // The Content below therefore carries `contain-none!`.
47
+
48
+ // A modal a pane opens (ActionModal, RenameDialog, ...) is drawn outside this dialog: while one is open, a click or
49
+ // Escape in it must not close the settings dialog, and the focus trap must let its fields take the focus.
50
+ const nestedModalTracker = createNestedModalTracker();
51
+ const nestedModalOpen = $derived($modalState.isOpen);
52
+ $effect(() => {
53
+ nestedModalTracker.update(nestedModalOpen);
54
+ });
55
+
56
+ // Every way the dialog closes (X, Escape, a click outside) arrives here. The request is refused while a nested modal
57
+ // holds the dialog; the click itself is left alone so the modal still reads its own click.
58
+ function requestOpenChange(nextOpen: boolean): void {
59
+ if (!nextOpen && nestedModalTracker.holdsDialog()) return;
60
+ open = nextOpen;
61
+ }
62
+
63
+ // bits-ui marks the original Escape key event as handled before BaseModal (a window listener) reads it, and BaseModal
64
+ // leaves a handled Escape alone. While a nested modal is open the key therefore belongs to the modal: this dialog
65
+ // stays open and hands the modal a fresh Escape, so one press closes the modal only.
66
+ function handleEscapeKeydown(event: KeyboardEvent): void {
67
+ if (!nestedModalTracker.open) return;
68
+ event.preventDefault();
69
+ window.dispatchEvent(
70
+ new KeyboardEvent('keydown', {
71
+ key: 'Escape',
72
+ code: 'Escape',
73
+ bubbles: true,
74
+ cancelable: true
75
+ })
76
+ );
77
+ }
49
78
  </script>
50
79
 
51
- <Dialog.Root bind:open>
80
+ <Dialog.Root bind:open={() => open, requestOpenChange}>
52
81
  <Dialog.Portal>
53
- <Dialog.Overlay class="bg-foreground/50 backdrop-blur-sm" />
82
+ <!-- The scrim is the shared one of every overlay (muted foreground at 80%, 3px blur), set by the dialog overlay itself -->
83
+ <Dialog.Overlay />
54
84
  <DialogPrimitive.Content
55
85
  class={cn(
56
86
  'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 border-border bg-background duration-base fixed z-50 flex overflow-hidden border',
57
87
  'inset-0 h-full max-h-none w-full rounded-none',
58
- 'lg:h-settings-modal lg:w-settings-modal lg:rounded-panel lg:start-1/2 lg:top-1/2 lg:max-h-[calc(100vh-2rem)] lg:max-w-[calc(100vw-2rem)] lg:-translate-x-1/2 lg:-translate-y-1/2',
88
+ // Centred with auto margins, not with a translate: a CSS transform would make this box the containing block
89
+ // of every `position: fixed` element inside a pane (a legacy confirm dialog would be drawn, and clipped, inside it).
90
+ 'lg:h-settings-modal lg:w-settings-modal lg:rounded-panel lg:m-auto lg:max-h-[calc(100vh-2rem)] lg:max-w-[calc(100vw-2rem)]',
91
+ // bits-ui's inline `contain: layout style` wins over a `style` prop, so the override has to be `!important`.
92
+ 'contain-none!',
59
93
  className
60
94
  )}
61
95
  aria-describedby={description || descriptionSlot ? 'settings-dialog-description' : undefined}
96
+ trapFocus={!nestedModalOpen}
97
+ onEscapeKeydown={handleEscapeKeydown}
62
98
  >
63
99
  <div class="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
64
100
  <aside
65
101
  class="border-border bg-sidebar flex w-full shrink-0 flex-col border-b p-3 lg:w-64 lg:border-e lg:border-b-0"
66
102
  >
67
103
  <SettingsNav {groups} {activeId} {onSelect} class="flex-1" />
68
- <div class="mt-auto flex flex-col gap-1 px-2 pt-4 pb-1">
69
- <a
70
- href={privacyHref}
71
- class="text-caption text-muted-foreground duration-fast hover:text-foreground w-fit transition-colors"
72
- >{messages.privacyPolicy}</a
73
- >
74
- <a
75
- href={termsHref}
76
- class="text-caption text-muted-foreground duration-fast hover:text-foreground w-fit transition-colors"
77
- >{messages.termsOfService}</a
78
- >
79
- <a
80
- href={cookieHref}
81
- class="text-caption text-muted-foreground duration-fast hover:text-foreground w-fit transition-colors"
82
- >{messages.cookieSettings}</a
83
- >
84
- </div>
85
104
  </aside>
86
105
  <div class="flex min-h-0 min-w-0 flex-1 flex-col gap-6 overflow-y-auto p-8">
87
106
  <header class="flex flex-col gap-1 pe-10">
@@ -7,10 +7,6 @@ export type SettingsDialogProps = {
7
7
  groups: import('./settings-nav-types.js').SettingsNavGroup[];
8
8
  onSelect?: (id: string) => void;
9
9
  closeLabel?: string;
10
- messages?: import('./messages.js').SettingsDialogMessages;
11
- privacyHref?: string;
12
- termsHref?: string;
13
- cookieHref?: string;
14
10
  children: import('svelte').Snippet;
15
11
  descriptionSlot?: import('svelte').Snippet;
16
12
  };
@@ -9,8 +9,8 @@ export declare const widgetRowVariants: import("tailwind-variants").TVReturnType
9
9
  };
10
10
  }, undefined, "group/widget-row flex min-h-16 w-full max-w-content items-center gap-4 rounded-xl py-3 ps-4 pe-4 transition-colors duration-fast focus-within:outline-hidden", {
11
11
  responsiveVariants?: boolean | (string | number | symbol)[] | {
12
- selected?: boolean | (string | number | symbol)[] | undefined;
13
12
  loading?: boolean | (string | number | symbol)[] | undefined;
13
+ selected?: boolean | (string | number | symbol)[] | undefined;
14
14
  } | undefined;
15
15
  } & import("tailwind-variants/dist/config.js").TWMConfig & {
16
16
  twMergeConfig: {
@@ -19,4 +19,3 @@ export { default as Link, type LinkProps, type LinkSize, linkVariants } from './
19
19
  export { type SettingsNavGroup, type SettingsNavItem, settingsNavItemIsActive } from './settings-nav-types.js';
20
20
  export { externalLinkAttributes } from './link-external.js';
21
21
  export { statHintClass, type StatTone } from './stat-tone.js';
22
- export { type SettingsDialogMessages, defaultSettingsDialogMessages } from './messages.js';
@@ -22,4 +22,3 @@ export { default as Link, linkVariants } from './Link.svelte';
22
22
  export { settingsNavItemIsActive } from './settings-nav-types.js';
23
23
  export { externalLinkAttributes } from './link-external.js';
24
24
  export { statHintClass } from './stat-tone.js';
25
- export { defaultSettingsDialogMessages } from './messages.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * A settings pane often opens a modal of its own (rename, confirm, a form): the `BaseModal` family
3
+ * (`ActionModal`, `NewItemDialog`, `RenameDialog`). Those are drawn in a portal on the body, outside the content of the
4
+ * settings dialog, so the dialog's dismissible layer reads a click in them as a click outside (it would close the
5
+ * settings dialog under the modal), its focus trap pulls the focus out of their fields, and one Escape would close both.
6
+ *
7
+ * The dialog asks this tracker, which is told what `modalState` (the stack every `BaseModal` pushes itself on) says,
8
+ * whether a modal of that family is open, so it can refuse a close request and release its focus trap while one is.
9
+ *
10
+ * It refuses the close request itself and never calls `preventDefault()` on the click. The Escape key is the one
11
+ * exception to "leave the event alone": bits-ui marks the original key event as handled before `BaseModal` reads it, and
12
+ * `BaseModal` closes on Escape only when the key event is not default-prevented, so the dialog hands the modal a fresh
13
+ * Escape (see `SettingsDialog`).
14
+ */
15
+ /** How long after the last such modal closed a click or a key still belongs to it (its own click closes it first). */
16
+ export declare const NESTED_MODAL_CLOSE_GRACE_MS = 300;
17
+ export type NestedModalTracker = {
18
+ /** Tell the tracker whether a modal of the family is open right now. */
19
+ update(modalIsOpen: boolean): void;
20
+ /** True while a modal of the family is open. */
21
+ readonly open: boolean;
22
+ /** True while one is open, and for a moment after the last one closed. */
23
+ holdsDialog(): boolean;
24
+ };
25
+ export declare function createNestedModalTracker(now?: () => number): NestedModalTracker;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A settings pane often opens a modal of its own (rename, confirm, a form): the `BaseModal` family
3
+ * (`ActionModal`, `NewItemDialog`, `RenameDialog`). Those are drawn in a portal on the body, outside the content of the
4
+ * settings dialog, so the dialog's dismissible layer reads a click in them as a click outside (it would close the
5
+ * settings dialog under the modal), its focus trap pulls the focus out of their fields, and one Escape would close both.
6
+ *
7
+ * The dialog asks this tracker, which is told what `modalState` (the stack every `BaseModal` pushes itself on) says,
8
+ * whether a modal of that family is open, so it can refuse a close request and release its focus trap while one is.
9
+ *
10
+ * It refuses the close request itself and never calls `preventDefault()` on the click. The Escape key is the one
11
+ * exception to "leave the event alone": bits-ui marks the original key event as handled before `BaseModal` reads it, and
12
+ * `BaseModal` closes on Escape only when the key event is not default-prevented, so the dialog hands the modal a fresh
13
+ * Escape (see `SettingsDialog`).
14
+ */
15
+ /** How long after the last such modal closed a click or a key still belongs to it (its own click closes it first). */
16
+ export const NESTED_MODAL_CLOSE_GRACE_MS = 300;
17
+ export function createNestedModalTracker(now = Date.now) {
18
+ let open = false;
19
+ let closedAtMs = Number.NEGATIVE_INFINITY;
20
+ return {
21
+ update(modalIsOpen) {
22
+ if (open && !modalIsOpen)
23
+ closedAtMs = now();
24
+ open = modalIsOpen;
25
+ },
26
+ get open() {
27
+ return open;
28
+ },
29
+ holdsDialog() {
30
+ return open || now() - closedAtMs < NESTED_MODAL_CLOSE_GRACE_MS;
31
+ }
32
+ };
33
+ }
@@ -7,7 +7,7 @@ export declare const alertVariants: import("tailwind-variants").TVReturnType<{
7
7
  default: string;
8
8
  destructive: string;
9
9
  };
10
- }, undefined, "relative grid w-full grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:col-start-2 [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:col-start-2 [&_[data-slot=alert-description]]:row-start-2", {
10
+ }, undefined, "relative grid w-full grid-cols-1 gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground has-[>svg]:grid-cols-[auto_1fr] [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:row-start-1 has-[[data-slot=alert-title]]:[&_[data-slot=alert-description]]:row-start-2 has-[>svg]:[&_[data-slot=alert-title]]:col-start-2 has-[>svg]:[&_[data-slot=alert-description]]:col-start-2", {
11
11
  responsiveVariants?: boolean | (string | number | symbol)[] | {
12
12
  variant?: boolean | (string | number | symbol)[] | undefined;
13
13
  } | undefined;
@@ -29,7 +29,7 @@ export declare const alertVariants: import("tailwind-variants").TVReturnType<{
29
29
  default: string;
30
30
  destructive: string;
31
31
  };
32
- }, undefined, "relative grid w-full grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:col-start-2 [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:col-start-2 [&_[data-slot=alert-description]]:row-start-2", import("tailwind-variants/dist/config.js").TVConfig<{
32
+ }, undefined, "relative grid w-full grid-cols-1 gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground has-[>svg]:grid-cols-[auto_1fr] [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:row-start-1 has-[[data-slot=alert-title]]:[&_[data-slot=alert-description]]:row-start-2 has-[>svg]:[&_[data-slot=alert-title]]:col-start-2 has-[>svg]:[&_[data-slot=alert-description]]:col-start-2", import("tailwind-variants/dist/config.js").TVConfig<{
33
33
  variant: {
34
34
  default: string;
35
35
  destructive: string;
@@ -3,7 +3,10 @@ import Root from "./alert.svelte";
3
3
  import Description from "./alert-description.svelte";
4
4
  import Title from "./alert-title.svelte";
5
5
  export const alertVariants = tv({
6
- base: "relative grid w-full grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:col-start-2 [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:col-start-2 [&_[data-slot=alert-description]]:row-start-2",
6
+ // The icon column exists only when the alert has an icon (a direct <svg> child): an alert without one starts its text at
7
+ // the left padding instead of leaving the empty icon column and its 12px gap in front of the text. The description sits
8
+ // on the second row only under a title: alone (an icon and a sentence, or a sentence) it shares the first row with the icon.
9
+ base: "relative grid w-full grid-cols-1 gap-x-3 gap-y-0.5 rounded-xl border border-border bg-background p-3 pe-4 ps-4 text-foreground has-[>svg]:grid-cols-[auto_1fr] [&>svg]:col-start-1 [&>svg]:row-start-1 [&>svg]:mt-0.5 [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:stroke-[1.333] [&_[data-slot=alert-title]]:row-start-1 [&_[data-slot=alert-description]]:row-start-1 has-[[data-slot=alert-title]]:[&_[data-slot=alert-description]]:row-start-2 has-[>svg]:[&_[data-slot=alert-title]]:col-start-2 has-[>svg]:[&_[data-slot=alert-description]]:col-start-2",
7
10
  variants: {
8
11
  variant: {
9
12
  default: "text-foreground [&>svg]:text-foreground",