@widgetic/design-system 0.5.23 → 0.5.24

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 (34) hide show
  1. package/dist/components/modals/BaseModal.svelte +257 -166
  2. package/dist/components/modals/BaseModal.svelte.d.ts +3 -0
  3. package/dist/components/modals/NewItemDialog.svelte +5 -7
  4. package/dist/components/modals/Portal.svelte +9 -7
  5. package/dist/components/modals/RenameDialog.svelte +5 -7
  6. package/dist/components/modals/modalStore.d.ts +9 -1
  7. package/dist/components/modals/modalStore.js +44 -11
  8. package/dist/components/navigation/Breadcrumbs.svelte +10 -4
  9. package/dist/components/navigation/breadcrumbsAvailableWidth.d.ts +10 -0
  10. package/dist/components/navigation/breadcrumbsAvailableWidth.js +10 -0
  11. package/dist/components/search-input/SearchInput.svelte +25 -13
  12. package/dist/components/search-input/SearchInput.svelte.d.ts +4 -0
  13. package/dist/components/search-input/index2.d.ts +14 -9
  14. package/dist/components/shell/NavDrawer.svelte +1 -1
  15. package/dist/components/shell/SettingsDialog.svelte +48 -3
  16. package/dist/components/shell/WidgetRow.svelte.d.ts +1 -1
  17. package/dist/components/shell/nested-modal-guard.d.ts +25 -0
  18. package/dist/components/shell/nested-modal-guard.js +33 -0
  19. package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
  20. package/dist/components/ui/command/command-item.svelte +1 -1
  21. package/dist/components/ui/command/command-link-item.svelte +1 -1
  22. package/dist/components/ui/dialog/dialog-content.svelte +8 -1
  23. package/dist/components/ui/dialog/dialog-overlay.svelte +8 -1
  24. package/dist/components/ui/drawer/drawer-overlay.svelte +1 -1
  25. package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +1 -1
  26. package/dist/components/ui/dropdown-menu/dropdown-menu-item.svelte +1 -1
  27. package/dist/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +1 -1
  28. package/dist/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
  29. package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
  30. package/dist/components/ui/select/select-item.svelte +1 -1
  31. package/dist/styles/global.css +3 -2
  32. package/dist/utils/overlayMotion.d.ts +49 -0
  33. package/dist/utils/overlayMotion.js +71 -0
  34. package/package.json +1 -1
@@ -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};"
@@ -26,9 +26,11 @@
26
26
  import type { Snippet } from 'svelte';
27
27
  import * as Dialog from '../ui/dialog/index.js';
28
28
  import { cn } from '../../utils.js';
29
+ import { modalState } from '../modals/modalStore.js';
29
30
  import SettingsNav from './SettingsNav.svelte';
30
31
  import { defaultSettingsDialogMessages, type SettingsDialogMessages } from './messages.js';
31
32
  import type { SettingsNavGroup } from './settings-nav-types.js';
33
+ import { createNestedModalTracker } from './nested-modal-guard.js';
32
34
 
33
35
  let {
34
36
  open = $bindable(false),
@@ -46,19 +48,62 @@
46
48
  children,
47
49
  descriptionSlot
48
50
  }: SettingsDialogProps = $props();
51
+
52
+ // bits-ui sets `contain: layout style` inline on every dialog content, and `contain: layout` makes that box the containing
53
+ // block of every `position: fixed` element inside it. A pane can hold an inline overlay of its own (a confirm dialog written
54
+ // as `fixed inset-0`): with containment on it would be drawn, and clipped, inside this box instead of over the viewport.
55
+ // The Content below therefore carries `contain-none!`.
56
+
57
+ // A modal a pane opens (ActionModal, RenameDialog, ...) is drawn outside this dialog: while one is open, a click or
58
+ // Escape in it must not close the settings dialog, and the focus trap must let its fields take the focus.
59
+ const nestedModalTracker = createNestedModalTracker();
60
+ const nestedModalOpen = $derived($modalState.isOpen);
61
+ $effect(() => {
62
+ nestedModalTracker.update(nestedModalOpen);
63
+ });
64
+
65
+ // Every way the dialog closes (X, Escape, a click outside) arrives here. The request is refused while a nested modal
66
+ // holds the dialog; the click itself is left alone so the modal still reads its own click.
67
+ function requestOpenChange(nextOpen: boolean): void {
68
+ if (!nextOpen && nestedModalTracker.holdsDialog()) return;
69
+ open = nextOpen;
70
+ }
71
+
72
+ // bits-ui marks the original Escape key event as handled before BaseModal (a window listener) reads it, and BaseModal
73
+ // leaves a handled Escape alone. While a nested modal is open the key therefore belongs to the modal: this dialog
74
+ // stays open and hands the modal a fresh Escape, so one press closes the modal only.
75
+ function handleEscapeKeydown(event: KeyboardEvent): void {
76
+ if (!nestedModalTracker.open) return;
77
+ event.preventDefault();
78
+ window.dispatchEvent(
79
+ new KeyboardEvent('keydown', {
80
+ key: 'Escape',
81
+ code: 'Escape',
82
+ bubbles: true,
83
+ cancelable: true
84
+ })
85
+ );
86
+ }
49
87
  </script>
50
88
 
51
- <Dialog.Root bind:open>
89
+ <Dialog.Root bind:open={() => open, requestOpenChange}>
52
90
  <Dialog.Portal>
53
- <Dialog.Overlay class="bg-foreground/50 backdrop-blur-sm" />
91
+ <!-- The scrim is the shared one of every overlay (muted foreground at 80%, 3px blur), set by the dialog overlay itself -->
92
+ <Dialog.Overlay />
54
93
  <DialogPrimitive.Content
55
94
  class={cn(
56
95
  '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
96
  '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',
97
+ // Centred with auto margins, not with a translate: a CSS transform would make this box the containing block
98
+ // of every `position: fixed` element inside a pane (a legacy confirm dialog would be drawn, and clipped, inside it).
99
+ '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)]',
100
+ // bits-ui's inline `contain: layout style` wins over a `style` prop, so the override has to be `!important`.
101
+ 'contain-none!',
59
102
  className
60
103
  )}
61
104
  aria-describedby={description || descriptionSlot ? 'settings-dialog-description' : undefined}
105
+ trapFocus={!nestedModalOpen}
106
+ onEscapeKeydown={handleEscapeKeydown}
62
107
  >
63
108
  <div class="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
64
109
  <aside
@@ -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: {
@@ -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
+ }
@@ -6,6 +6,6 @@ export type CalendarProps = WithoutChildrenOrChild<CalendarPrimitive.RootProps>
6
6
  nextLabel?: string;
7
7
  };
8
8
  import * as Calendar from "./index.js";
9
- declare const Calendar: import("svelte").Component<CalendarProps, {}, "placeholder" | "ref" | "value">;
9
+ declare const Calendar: import("svelte").Component<CalendarProps, {}, "ref" | "value" | "placeholder">;
10
10
  type Calendar = ReturnType<typeof Calendar>;
11
11
  export default Calendar;
@@ -15,7 +15,7 @@
15
15
 
16
16
  <CommandPrimitive.Item
17
17
  class={cn(
18
- "text-body aria-selected:bg-accent aria-selected:text-foreground relative flex h-8 cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333]",
18
+ "text-body aria-selected:bg-accent aria-selected:text-foreground relative flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333]",
19
19
  className
20
20
  )}
21
21
  bind:ref
@@ -15,7 +15,7 @@
15
15
 
16
16
  <CommandPrimitive.LinkItem
17
17
  class={cn(
18
- "aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50",
18
+ "aria-selected:bg-accent aria-selected:text-accent-foreground relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
19
19
  className
20
20
  )}
21
21
  bind:ref
@@ -4,6 +4,10 @@
4
4
  import type { Snippet } from "svelte";
5
5
  import * as Dialog from "./index.js";
6
6
  import { cn } from "../../../utils.js";
7
+ import {
8
+ overlayDialogContentClasses,
9
+ overlayReducedMotionClasses
10
+ } from "../../../utils/overlayMotion.js";
7
11
 
8
12
  let {
9
13
  ref = $bindable(null),
@@ -24,8 +28,11 @@
24
28
  <Dialog.Overlay />
25
29
  <DialogPrimitive.Content
26
30
  bind:ref
31
+ data-widgetic-overlay="dialog-panel"
27
32
  class={cn(
28
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 bg-background border-border fixed start-1/2 top-1/2 z-50 grid w-dialog max-w-[calc(100vw-3rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-panel border p-6 duration-base",
33
+ "bg-background border-border fixed start-1/2 top-1/2 z-50 grid w-dialog max-w-[calc(100vw-3rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-panel border p-6",
34
+ overlayDialogContentClasses,
35
+ overlayReducedMotionClasses,
29
36
  className
30
37
  )}
31
38
  {...restProps}
@@ -5,6 +5,10 @@
5
5
 
6
6
  <script lang="ts">
7
7
  import { cn } from "../../../utils.js";
8
+ import {
9
+ overlayDialogOverlayClasses,
10
+ overlayReducedMotionClasses
11
+ } from "../../../utils/overlayMotion.js";
8
12
 
9
13
  let {
10
14
  ref = $bindable(null),
@@ -15,8 +19,11 @@
15
19
 
16
20
  <DialogPrimitive.Overlay
17
21
  bind:ref
22
+ data-widgetic-overlay="dialog-backdrop"
18
23
  class={cn(
19
- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm",
24
+ "fixed inset-0 z-50",
25
+ overlayDialogOverlayClasses,
26
+ overlayReducedMotionClasses,
20
27
  className
21
28
  )}
22
29
  {...restProps}
@@ -15,6 +15,6 @@
15
15
 
16
16
  <DrawerPrimitive.Overlay
17
17
  bind:ref
18
- class={cn("fixed inset-0 z-50 bg-foreground/40 backdrop-blur-sm", className)}
18
+ class={cn("fixed inset-0 z-50 bg-muted-foreground/80 backdrop-blur-[3px]", className)}
19
19
  {...restProps}
20
20
  />
@@ -22,7 +22,7 @@
22
22
  bind:checked
23
23
  bind:indeterminate
24
24
  class={cn(
25
- "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex h-8 cursor-default select-none items-center rounded-lg py-1.5 ps-8 pe-2 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:opacity-50",
25
+ "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex h-8 cursor-pointer select-none items-center rounded-lg py-1.5 ps-8 pe-2 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
26
26
  className
27
27
  )}
28
28
  {...restProps}
@@ -22,7 +22,7 @@
22
22
  <DropdownMenuPrimitive.Item
23
23
  bind:ref
24
24
  class={cn(
25
- "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333] data-[variant=destructive]:text-destructive data-highlighted:data-[variant=destructive]:text-destructive",
25
+ "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333] data-[variant=destructive]:text-destructive data-highlighted:data-[variant=destructive]:text-destructive",
26
26
  description ? "min-h-12" : "min-h-8",
27
27
  inset && "ps-8",
28
28
  className
@@ -18,7 +18,7 @@
18
18
  <DropdownMenuPrimitive.RadioItem
19
19
  bind:ref
20
20
  class={cn(
21
- "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex h-8 cursor-default select-none items-center rounded-lg py-1.5 ps-8 pe-2 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:opacity-50",
21
+ "text-body data-highlighted:bg-accent data-highlighted:text-foreground relative flex h-8 cursor-pointer select-none items-center rounded-lg py-1.5 ps-8 pe-2 outline-hidden transition-colors duration-fast data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50",
22
22
  className
23
23
  )}
24
24
  {...restProps}
@@ -17,7 +17,7 @@
17
17
  <DropdownMenuPrimitive.SubTrigger
18
18
  bind:ref
19
19
  class={cn(
20
- "text-body data-highlighted:bg-accent data-[state=open]:bg-accent flex h-8 cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333]",
20
+ "text-body data-highlighted:bg-accent data-[state=open]:bg-accent flex h-8 cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden transition-colors duration-fast [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:stroke-[1.333]",
21
21
  inset && "ps-8",
22
22
  className
23
23
  )}
@@ -6,6 +6,6 @@ export type RangeCalendarProps = WithoutChildrenOrChild<RangeCalendarPrimitive.R
6
6
  nextLabel?: string;
7
7
  };
8
8
  import * as RangeCalendar from "./index.js";
9
- declare const RangeCalendar: import("svelte").Component<RangeCalendarProps, {}, "placeholder" | "ref" | "value">;
9
+ declare const RangeCalendar: import("svelte").Component<RangeCalendarProps, {}, "ref" | "value" | "placeholder">;
10
10
  type RangeCalendar = ReturnType<typeof RangeCalendar>;
11
11
  export default RangeCalendar;
@@ -22,7 +22,7 @@
22
22
  bind:ref
23
23
  {value}
24
24
  class={cn(
25
- "relative flex h-8 w-full cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 ps-8 text-body outline-hidden data-[highlighted]:bg-accent data-[selected]:bg-brand-subtle data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
25
+ "relative flex h-8 w-full cursor-pointer select-none items-center gap-2 rounded-lg px-2 py-1.5 ps-8 text-body outline-hidden data-[highlighted]:bg-accent data-[selected]:bg-brand-subtle data-[disabled]:pointer-events-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
26
26
  className
27
27
  )}
28
28
  {...restProps}
@@ -609,10 +609,11 @@ go 3 levels up in development mode, 2 levels up in production mode*/
609
609
  }
610
610
 
611
611
  /* Modal System */
612
+ /* The scrim of every overlay: the muted foreground token at 80%, set on the colour, with a 3px blur (Figma `Overlay/Scrim blur`) */
612
613
  .modal-overlay {
613
614
  @apply fixed inset-0 z-50;
614
- backdrop-filter: blur(30px);
615
- background-color: rgba(255, 255, 255, 0.5);
615
+ backdrop-filter: blur(3px);
616
+ background-color: color-mix(in srgb, var(--muted-foreground) 80%, transparent);
616
617
  }
617
618
 
618
619
  .modal-shadow {