@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.
- package/dist/components/CopyableHeader.svelte +5 -3
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/modals/BaseModal.svelte +257 -166
- package/dist/components/modals/BaseModal.svelte.d.ts +3 -0
- package/dist/components/modals/NewItemDialog.svelte +5 -7
- package/dist/components/modals/Portal.svelte +9 -7
- package/dist/components/modals/RenameDialog.svelte +5 -7
- package/dist/components/modals/modalStore.d.ts +9 -1
- package/dist/components/modals/modalStore.js +44 -11
- package/dist/components/navigation/Breadcrumbs.svelte +10 -4
- package/dist/components/navigation/breadcrumbsAvailableWidth.d.ts +10 -0
- package/dist/components/navigation/breadcrumbsAvailableWidth.js +10 -0
- package/dist/components/search-input/SearchInput.svelte +25 -13
- package/dist/components/search-input/SearchInput.svelte.d.ts +4 -0
- package/dist/components/search-input/index2.d.ts +14 -9
- package/dist/components/shell/NavDrawer.svelte +1 -1
- package/dist/components/shell/NavRow.svelte +4 -1
- package/dist/components/shell/SettingsDialog.svelte +48 -29
- package/dist/components/shell/SettingsDialog.svelte.d.ts +0 -4
- package/dist/components/shell/WidgetRow.svelte.d.ts +1 -1
- package/dist/components/shell/index.d.ts +0 -1
- package/dist/components/shell/index.js +0 -1
- package/dist/components/shell/nested-modal-guard.d.ts +25 -0
- package/dist/components/shell/nested-modal-guard.js +33 -0
- package/dist/components/ui/alert/index.d.ts +2 -2
- package/dist/components/ui/alert/index.js +4 -1
- package/dist/components/ui/avatar/avatar.svelte +22 -3
- package/dist/components/ui/button/button.svelte +1 -1
- package/dist/components/ui/button/button.svelte.d.ts +2 -2
- package/dist/components/ui/calendar/calendar.svelte.d.ts +1 -1
- package/dist/components/ui/command/command-item.svelte +1 -1
- package/dist/components/ui/command/command-link-item.svelte +1 -1
- package/dist/components/ui/dialog/dialog-content.svelte +8 -1
- package/dist/components/ui/dialog/dialog-overlay.svelte +8 -1
- package/dist/components/ui/drawer/drawer-overlay.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-item.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +1 -1
- package/dist/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +1 -1
- package/dist/components/ui/icon-button/icon-button.svelte +1 -1
- package/dist/components/ui/icon-button/icon-button.svelte.d.ts +2 -2
- package/dist/components/ui/range-calendar/range-calendar.svelte.d.ts +1 -1
- package/dist/components/ui/select/select-content.svelte +5 -0
- package/dist/components/ui/select/select-item.svelte +8 -1
- package/dist/components/ui/select/select-trigger.svelte +3 -2
- package/dist/components/ui/stepper-button/stepper-button.svelte +1 -1
- package/dist/components/ui/switch/switch.svelte +4 -1
- package/dist/styles/global.css +3 -2
- package/dist/styles/tokens.css +21 -1
- package/dist/utils/inputModality.d.ts +16 -0
- package/dist/utils/inputModality.js +55 -0
- package/dist/utils/overlayMotion.d.ts +49 -0
- package/dist/utils/overlayMotion.js +71 -0
- package/package.json +1 -1
- package/dist/components/shell/messages.d.ts +0 -7
- 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
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
|
213
|
-
handleClose();
|
|
214
|
-
} else {
|
|
212
|
+
if (e.key !== 'Escape') {
|
|
215
213
|
e.stopPropagation();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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):
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
const containerElement =
|
|
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 =
|
|
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
|
-
//
|
|
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
|
|
6
|
-
import IconX from
|
|
7
|
+
import IconSearch from '@tabler/icons-svelte/icons/search';
|
|
8
|
+
import IconX from '@tabler/icons-svelte/icons/x';
|
|
7
9
|
|
|
8
|
-
export let placeholder =
|
|
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
|
|
17
|
-
export let
|
|
18
|
-
export let
|
|
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
|
|
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]
|
|
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 ===
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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",
|