@fuzdev/fuz_ui 0.200.0 → 0.202.0

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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Fuz
1
+ # fuz_ui
2
2
 
3
3
  [<img src="static/logo.svg" alt="a friendly brown spider facing you" align="right" width="192" height="192">](https://ui.fuz.dev/)
4
4
 
@@ -6,7 +6,7 @@
6
6
 
7
7
  fuz_ui is a Svelte UI library with components and helpers for making zippy
8
8
  websites. It's built on [fuz_css](https://css.fuz.dev/)
9
- and provides includes a documentation system built on
9
+ and provides a documentation system built on
10
10
  [svelte-docinfo](https://svelte-docinfo.fuz.dev/).
11
11
  It's in early alpha with breaking changes ahead.
12
12
 
@@ -24,9 +24,9 @@
24
24
  {:else}
25
25
  {#each declarations as declaration (`${declaration.module_path}:${declaration.name}`)}
26
26
  <TomeSection>
27
- <!-- `text` drives the slug/anchor (bare name); display shows generics via `display_name` -->
27
+ <!-- Show the bare name; generic parameters are documented in the detail below (generics/type signature). -->
28
28
  <TomeSectionHeader text={declaration.name}>
29
- <div class="word-break:break-all">{declaration.display_name}</div>
29
+ <div class="word-break:break-all">{declaration.name}</div>
30
30
  </TomeSectionHeader>
31
31
  <article id={declaration.name}>
32
32
  <DeclarationDetail {declaration} />
@@ -4,6 +4,9 @@
4
4
  import type {SvelteHTMLElements} from 'svelte/elements';
5
5
  import {scale, slide} from 'svelte/transition';
6
6
 
7
+ import {icon_checkmark, icon_copy} from './icons.js';
8
+ import Svg from './Svg.svelte';
9
+
7
10
  // TODO @many should this have the Button suffix?
8
11
 
9
12
  // TODO add docs entry, see also PasteFromClipboard.svelte
@@ -73,8 +76,8 @@
73
76
  {#if children}
74
77
  {@render children(copied, failed)}
75
78
  {:else if copied}
76
- <div in:scale={{duration: 200}}>✓</div>
79
+ <div in:scale={{duration: 200}}><Svg data={icon_checkmark} /></div>
77
80
  {:else}
78
- <div in:slide>⧉</div>
81
+ <div in:slide><Svg data={icon_copy} /></div>
79
82
  {/if}
80
83
  </button>
@@ -1 +1 @@
1
- {"version":3,"file":"CopyToClipboard.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CopyToClipboard.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAGvD,KAAK,gBAAgB,GAAI,UAAU,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,GAAG;IAC/E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC;;OAEG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD,CAAC;AA+DH,QAAA,MAAM,eAAe,sDAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"CopyToClipboard.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/CopyToClipboard.svelte"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAC,UAAU,EAAC,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAC,OAAO,EAAC,MAAM,QAAQ,CAAC;AACpC,OAAO,KAAK,EAAC,kBAAkB,EAAC,MAAM,iBAAiB,CAAC;AAMvD,KAAK,gBAAgB,GAAI,UAAU,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,GAAG;IAC/E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,0BAA0B,CAAC,EAAE,OAAO,CAAC;IACrC;;OAEG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,CAAC,EAAE,UAAU,KAAK,IAAI,CAAC;IACtD,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;CACvD,CAAC;AAkEH,QAAA,MAAM,eAAe,sDAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -62,6 +62,16 @@ including parameters, props, members, overloads, intersects, and more.
62
62
  </section>
63
63
  {/snippet}
64
64
 
65
+ <!-- A compact parameter row: name (with optional marker) and type, for snippet params and overloads. -->
66
+ {#snippet param_row(param: ParameterJsonInput)}
67
+ <div class="row gap_md">
68
+ <code
69
+ >{param.name}{#if param.optional}?{/if}</code
70
+ >
71
+ <TypeLink type={param.type} />
72
+ </div>
73
+ {/snippet}
74
+
65
75
  <!-- Compact prose metadata for props and members (deprecated, since, examples, see also, throws). -->
66
76
  {#snippet doc_extras(item: DocExtras)}
67
77
  <!-- eslint-disable-next-line @typescript-eslint/no-deprecated -->
@@ -104,6 +114,12 @@ including parameters, props, members, overloads, intersects, and more.
104
114
  {/if}
105
115
  {/snippet}
106
116
 
117
+ <!-- A comma-separated inline list of type links (extends, implements, intersects are rarely more than one). -->
118
+ {#snippet type_list(types: Array<string>)}
119
+ {#each types as type, i (type)}{#if i > 0},
120
+ {/if}<TypeLink {type} />{/each}
121
+ {/snippet}
122
+
107
123
  <!-- Metadata -->
108
124
  <p class="row justify-content:space-between">
109
125
  <ModuleLink module_path={declaration.module_path} />
@@ -211,12 +227,7 @@ including parameters, props, members, overloads, intersects, and more.
211
227
  <section>
212
228
  <h5>snippet parameters</h5>
213
229
  {#each prop.parameters as param (param)}
214
- <div class="row gap_md">
215
- <code
216
- >{param.name}{#if param.optional}?{/if}</code
217
- >
218
- <TypeLink type={param.type} />
219
- </div>
230
+ {@render param_row(param)}
220
231
  {/each}
221
232
  </section>
222
233
  {/if}
@@ -238,12 +249,7 @@ including parameters, props, members, overloads, intersects, and more.
238
249
  {/if}
239
250
  {#if overload.parameters?.length}
240
251
  {#each overload.parameters as param (param)}
241
- <div class="row gap_md">
242
- <code
243
- >{param.name}{#if param.optional}?{/if}</code
244
- >
245
- <TypeLink type={param.type} />
246
- </div>
252
+ {@render param_row(param)}
247
253
  {/each}
248
254
  {/if}
249
255
  {#if overload.returnType}
@@ -264,11 +270,9 @@ including parameters, props, members, overloads, intersects, and more.
264
270
  {#if declaration.intersects?.length}
265
271
  <section>
266
272
  <h4>intersects</h4>
267
- <ul>
268
- {#each declaration.intersects as type (type)}
269
- <li><TypeLink {type} /></li>
270
- {/each}
271
- </ul>
273
+ <div class="row gap_md flex-wrap:wrap">
274
+ {@render type_list(declaration.intersects)}
275
+ </div>
272
276
  </section>
273
277
  {/if}
274
278
 
@@ -286,10 +290,13 @@ including parameters, props, members, overloads, intersects, and more.
286
290
  <!-- generics -->
287
291
  {#if declaration.generic_params.length}
288
292
  <section>
289
- <h4>generics</h4>
293
+ <div class="row gap_md">
294
+ <h4>generics</h4>
295
+ <TypeLink type={declaration.display_name} />
296
+ </div>
290
297
  {#each declaration.generic_params as generic (generic)}
291
298
  <section>
292
- <h4><code>{generic.name}</code></h4>
299
+ <h5><code>{generic.name}</code></h5>
293
300
  {#if generic.constraint}
294
301
  <div class="row gap_md">
295
302
  <strong>constraint</strong>
@@ -312,27 +319,19 @@ including parameters, props, members, overloads, intersects, and more.
312
319
  <section>
313
320
  <h4>inheritance</h4>
314
321
  {#if declaration.extends_type}
315
- <div>
322
+ <div class="row gap_md flex-wrap:wrap">
316
323
  <strong>extends:</strong>
317
- {#if Array.isArray(declaration.extends_type)}
318
- <ul>
319
- {#each declaration.extends_type as ext (ext)}
320
- <li><TypeLink type={ext} /></li>
321
- {/each}
322
- </ul>
323
- {:else}
324
- <TypeLink type={declaration.extends_type} />
325
- {/if}
324
+ {@render type_list(
325
+ Array.isArray(declaration.extends_type)
326
+ ? declaration.extends_type
327
+ : [declaration.extends_type],
328
+ )}
326
329
  </div>
327
330
  {/if}
328
331
  {#if declaration.implements_types?.length}
329
- <div>
332
+ <div class="row gap_md flex-wrap:wrap">
330
333
  <strong>implements:</strong>
331
- <ul>
332
- {#each declaration.implements_types as impl (impl)}
333
- <li><TypeLink type={impl} /></li>
334
- {/each}
335
- </ul>
334
+ {@render type_list(declaration.implements_types)}
336
335
  </div>
337
336
  {/if}
338
337
  </section>
@@ -465,3 +464,9 @@ including parameters, props, members, overloads, intersects, and more.
465
464
  {/each}
466
465
  </section>
467
466
  {/if}
467
+
468
+ <style>
469
+ section section:not(:last-child) {
470
+ margin-bottom: var(--space_xl4);
471
+ }
472
+ </style>
@@ -1 +1 @@
1
- {"version":3,"file":"DeclarationDetail.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/DeclarationDetail.svelte"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,yBAAyB,CAAC;AAiBxD,KAAK,gBAAgB,GAAI;IAAC,WAAW,EAAE,WAAW,CAAA;CAAC,CAAC;AAmbrD;;;;;;;;GAQG;AACH,QAAA,MAAM,iBAAiB,sDAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"DeclarationDetail.svelte.d.ts","sourceRoot":"../src/lib/","sources":["../src/lib/DeclarationDetail.svelte"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAC,WAAW,EAAC,MAAM,yBAAyB,CAAC;AAiBxD,KAAK,gBAAgB,GAAI;IAAC,WAAW,EAAE,WAAW,CAAA;CAAC,CAAC;AAqbrD;;;;;;;;GAQG;AACH,QAAA,MAAM,iBAAiB,sDAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
@@ -1,167 +1,177 @@
1
1
  <script lang="ts">
2
2
  import type {Snippet} from 'svelte';
3
- import {is_editable, swallow} from '@fuzdev/fuz_util/dom.js';
4
- import {wait} from '@fuzdev/fuz_util/async.js';
5
-
6
- import Teleport from './Teleport.svelte';
7
- import type {DialogLayout} from './dialog.js';
8
-
9
- // TODO use `<dialog>` here instead of `Teleport`
10
- // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
11
-
12
- /*
13
-
14
- This component is a singleton that mounts to a #dialog container element added to the body,
15
- using a `Teleport` component to avoid ancestor-caused style issues like overflow and z-index.
16
- It adds `.dialog` to the body when mounted to fix scrolling behavior,
17
- and adds padding to the body to adjust for any scrollbars.
18
- It uses a CSS custom property for this to avoid the high complexity of trying to
19
- correctly revert any preexisting values for overflow and padding on the body.
20
- We don't want to add restrictions to what users can do to the body on their own!
21
-
22
- */
3
+ import type {SvelteHTMLElements} from 'svelte/elements';
4
+ import {SvelteSet} from 'svelte/reactivity';
5
+ import {swallow} from '@fuzdev/fuz_util/dom.js';
6
+
7
+ import {dialog_context, type DialogContext, type DialogAlign} from './dialog.js';
8
+
9
+ /**
10
+ * This component renders a native `<dialog>` opened with `showModal()`, which
11
+ * puts it in the top layer. The top layer escapes ancestor stacking/overflow
12
+ * contexts (so no `Teleport` is needed), traps focus, makes the rest of the page
13
+ * inert, closes on Escape, and restores focus to the previously focused element
14
+ * on close -- all natively. The dim background is the native `::backdrop`.
15
+ *
16
+ * We render a full-viewport overlay inside the dialog rather than a content-sized
17
+ * box, to preserve the scrolling and `align="top"` behaviors. The content
18
+ * surface itself is the consumer's -- pair this with `DialogContent` for the
19
+ * default `.pane` card and gutter, or render your own surface in `children`.
20
+ *
21
+ * @module
22
+ */
23
23
 
24
24
  const {
25
- container,
26
- layout = 'centered',
27
- index = 0,
28
- active = true,
25
+ show = true,
26
+ align = 'center',
27
+ dismissable = true,
29
28
  content_selector = '.pane',
29
+ onbeforeclose,
30
30
  onclose,
31
31
  children,
32
- }: {
33
- // TODO maybe change this API away from an element to a selector? or remove the API completely?
34
- container?: HTMLElement;
32
+ ...rest
33
+ }: Omit<SvelteHTMLElements['dialog'], 'children' | 'onclose'> & {
35
34
  /**
36
- * @default 'centered'
35
+ * Whether the dialog is shown. When the `<dialog>` mounts it opens via
36
+ * `showModal()`; when it unmounts it closes.
37
+ * Defaults to `true` so the `{#if opened}<Dialog>...</Dialog>{/if}` pattern
38
+ * works without passing `show` -- mounting the component opens the dialog.
39
+ * Pass `show={opened}` to skip the outer `{#if}` and let the component manage
40
+ * its own conditional rendering.
41
+ * @default true
37
42
  */
38
- layout?: DialogLayout;
43
+ show?: boolean;
39
44
  /**
40
- * index 0 is under 1 is under 2 etc -- the topmost dialog is the last in the array
41
- * @default 0
45
+ * How the content is aligned in the viewport. `center` vertically centers it;
46
+ * `top` aligns it to the top and grows downward, which avoids jank when the
47
+ * content's height changes.
48
+ * @default 'center'
42
49
  */
43
- index?: number;
50
+ align?: DialogAlign;
44
51
  /**
52
+ * Whether clicking outside the content (see `content_selector`) closes the
53
+ * dialog. `Escape` closes it regardless of this.
45
54
  * @default true
46
55
  */
47
- active?: boolean;
56
+ dismissable?: boolean;
48
57
  /**
49
- * If provided, prevents clicks that would close the dialog
50
- * from bubbling past any elements matching this selector.
58
+ * Fallback selector for a content surface you render in `children` yourself
59
+ * (rather than via `DialogContent`, which self-registers).
60
+ * When `dismissable`, a press that isn't inside a registered surface (e.g.
61
+ * `DialogContent`, which self-registers) and doesn't match this selector
62
+ * closes the dialog. Defaults to the fuz_css `.pane` card; set it to match
63
+ * your surface's outermost element -- with no registered surface and no
64
+ * match, presses anywhere close the dialog.
51
65
  * @default '.pane'
52
66
  */
53
- content_selector?: string | null;
67
+ content_selector?: string;
68
+ /**
69
+ * Called before a user-initiated close (`Escape`, click-outside, or `close`).
70
+ * Return `false` to veto and keep the dialog open -- e.g. to confirm
71
+ * discarding unsaved changes. Programmatic close via `show={false}` bypasses
72
+ * this.
73
+ */
74
+ onbeforeclose?: () => boolean | void;
75
+ /**
76
+ * Called when the dialog closes -- via `Escape`, click-outside, or `close`.
77
+ * Use it to sync your own open state, e.g. `onclose={() => (opened = false)}`.
78
+ * Like `onbeforeclose`, programmatic close via `show={false}` bypasses this.
79
+ */
54
80
  onclose?: () => void;
55
- children: Snippet<[close: (e?: Event) => void]>;
81
+ /**
82
+ * Rendered inside the dialog overlay. Receives the `DialogContext` (e.g.
83
+ * `{close}`); pair with `DialogContent` or render your own surface.
84
+ */
85
+ children: Snippet<[dialog: DialogContext]>;
56
86
  } = $props();
57
87
 
58
- const ROOT_SELECTOR = 'body'; // TODO make configurable
59
- const CONTAINER_ID = 'fuz_dialog';
60
-
61
- let container_el: HTMLElement | undefined = $state.raw();
62
- $effect(() => {
63
- update_container_el(container);
64
- });
65
-
66
- const update_container_el = (container: HTMLElement | undefined): void => {
67
- if (container) {
68
- container_el = container;
69
- } else {
70
- const found = document.getElementById(CONTAINER_ID);
71
- if (found) {
72
- container_el = found;
73
- } else {
74
- const root_el = document.querySelector(ROOT_SELECTOR);
75
- if (!root_el) {
76
- throw Error(`Cannot find dialog root element with selector '${ROOT_SELECTOR}'`);
77
- }
78
- container_el = document.createElement('div');
79
- container_el.id = CONTAINER_ID;
80
- container_el.style.display = 'contents';
81
- root_el.appendChild(container_el);
82
- }
83
- }
88
+ let dialog_el: HTMLDialogElement | undefined;
89
+
90
+ // Guards against a single dismissal firing `onclose` twice. Reset on mount.
91
+ let closing = false;
92
+
93
+ // Closes the native dialog (which restores focus to the trigger) and notifies
94
+ // the consumer to unmount us. Closing here, while still mounted and connected,
95
+ // is what makes focus restoration work -- closing on unmount would run on a
96
+ // detached node, which doesn't restore focus.
97
+ const request_close = () => {
98
+ if (closing) return;
99
+ // let the consumer veto a user-initiated dismissal (e.g. confirm unsaved changes)
100
+ if (onbeforeclose?.() === false) return;
101
+ closing = true;
102
+ dialog_el?.close();
103
+ onclose?.();
84
104
  };
85
105
 
86
- let dialog_el: HTMLElement | undefined = $state.raw();
87
- let content_el: HTMLElement | undefined = $state.raw();
88
-
106
+ // The `close` passed to children also swallows the triggering event.
89
107
  const close = (e?: Event) => {
90
108
  if (e) swallow(e);
91
- onclose?.();
109
+ request_close();
92
110
  };
93
111
 
94
- // TODO hook into a ui input system
95
- const on_window_keydown = (e: KeyboardEvent) => {
96
- if (e.key === 'Escape' && !is_editable(e.target)) {
97
- // apply hotkey only for the top-most dialog
98
- const parent_el = dialog_el?.parentElement;
99
- const parents = parent_el?.parentElement?.children;
100
- const index = Array.prototype.indexOf.call(parents, parent_el);
101
- if (!parents || index === parents.length - 1 || index === -1) {
102
- close(e);
103
- }
104
- }
112
+ // Content surfaces (e.g. `DialogContent`) register here so a press inside one
113
+ // isn't an outside-dismiss. Identity-based, so it works regardless of classes;
114
+ // `content_selector` stays the fallback for surfaces rendered in `children`.
115
+ const surfaces = new SvelteSet<Element>();
116
+ const register_surface = (element: Element): (() => void) => {
117
+ surfaces.add(element);
118
+ return () => {
119
+ surfaces.delete(element);
120
+ };
105
121
  };
106
122
 
107
- // The dialog isn't "ready" until the teleport moves it.
108
- // Rendering the the dialog's children only once it's ready fixes things like `autofocus`.
109
- let ready = $state.raw(false);
123
+ // The context is both passed to `children` and set for descendants (e.g.
124
+ // `DialogContent`), so either path can close the dialog.
125
+ const context: DialogContext = {close, register_surface};
126
+ dialog_context.set(context);
127
+
128
+ const setup_dialog = (el: HTMLDialogElement) => {
129
+ dialog_el = el;
130
+ closing = false;
131
+ // Esc (and any platform close request) fires `cancel`. We swallow it and
132
+ // close the dialog ourselves (above, while connected) so focus is restored,
133
+ // and so the consumer's state stays in sync and we unmount.
134
+ const oncancel = (e: Event) => {
135
+ swallow(e);
136
+ request_close();
137
+ };
138
+ el.addEventListener('cancel', oncancel);
139
+ if (!el.open) el.showModal();
140
+ return () => {
141
+ el.removeEventListener('cancel', oncancel);
142
+ dialog_el = undefined;
143
+ // fallback for programmatic unmount (e.g. `show` set false directly)
144
+ if (el.open) el.close();
145
+ };
146
+ };
110
147
  </script>
111
148
 
112
- <svelte:window onkeydown={active ? on_window_keydown : undefined} />
113
-
114
- <!--
115
- The `tabindex` and `el.focus()` fix scrolling with the keyboard,
116
- needed because SvelteKit puts `tabindex` on the body,
117
- but there's more that needs to be done for accessibility, like focus capture.
118
- For more see: https://www.w3.org/TR/wai-aria-practices-1.1/#dialog_modal
119
- and https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
120
- -->
121
- <Teleport
122
- to={container_el}
123
- onmove={async () => {
124
- await wait(); // TODO this is a hack to get animations working, `Teleport` now mounts synchronously?!
125
- ready = true;
126
- dialog_el?.focus(); // TODO make this more declarative? probably want to focus only after moving though, not on mount, which makes an action trickier
127
- }}
128
- >
129
- <div
130
- class="dialog"
131
- class:ready
132
- class:layout-page={layout === 'page'}
133
- role="dialog"
134
- aria-modal="true"
135
- bind:this={dialog_el}
136
- tabindex="-1"
137
- style:z-index={100 + index}
149
+ {#if show}
150
+ <dialog
151
+ {...rest}
152
+ class="dialog {rest.class}"
153
+ class:align-top={align === 'top'}
154
+ {@attach setup_dialog}
138
155
  >
139
- <div class="dialog-layout">
140
- <div
141
- class="dialog-wrapper"
142
- role="none"
143
- onmousedown={(e) => {
144
- // Close if clicking outside `content_el` but inside the wrapper
145
- const target = e.target as Element;
146
- if (
147
- content_el &&
148
- (content_el === target ||
149
- !content_el.contains(target) ||
150
- (content_selector && !target.closest(content_selector)))
151
- ) {
152
- close(e);
153
- }
154
- }}
155
- >
156
- <div class="dialog-bg" aria-hidden="true"></div>
157
- <div class="dialog-content" bind:this={content_el}>
158
- <!-- mount the content only after teleporting to avoid issues -->
159
- {#if ready}{@render children(close)}{/if}
160
- </div>
161
- </div>
156
+ <div
157
+ class="dialog-wrapper"
158
+ role="none"
159
+ onmousedown={(e) => {
160
+ if (!dismissable) return;
161
+ const target = e.target as Element;
162
+ // inside a registered surface (e.g. DialogContent) -> not an outside dismiss
163
+ for (const surface of surfaces) {
164
+ if (surface.contains(target)) return;
165
+ }
166
+ // fallback for surfaces rendered in `children`: match `content_selector`
167
+ if (content_selector && target.closest(content_selector)) return;
168
+ close(e);
169
+ }}
170
+ >
171
+ {@render children(context)}
162
172
  </div>
163
- </div>
164
- </Teleport>
173
+ </dialog>
174
+ {/if}
165
175
 
166
176
  <style>
167
177
  .dialog {
@@ -171,51 +181,50 @@
171
181
  var(--shadow_color, var(--shadow_color_umbra)) var(--shadow_alpha_70),
172
182
  transparent
173
183
  );
184
+ /* reset the user-agent dialog styles; we render a full-viewport overlay */
185
+ max-width: none;
186
+ max-height: none;
187
+ /* width/height are needed despite `inset: 0` to make the dialog element fill the viewport */
188
+ width: 100%;
189
+ height: 100%;
190
+ margin: 0;
191
+ padding: 0;
192
+ border: none;
193
+ background: transparent;
194
+ color: inherit;
174
195
  position: fixed;
175
196
  inset: 0;
176
197
  overflow: auto;
177
- /* this simplifies the code a lot but doesn't prevent scrolling
178
- the underlying content when the dialog doesn't overflow, even when `overflow: scroll`
179
- TODO check if this behaves as desired after switching to use the `dialog` element */
180
198
  overscroll-behavior: contain;
181
199
  }
200
+ /* the native backdrop is the dim background; fade it in on open */
201
+ .dialog::backdrop {
202
+ background-color: var(--dialog_bg, var(--darken_60));
203
+ transition: background-color var(--duration_2) ease;
204
+ }
205
+ @starting-style {
206
+ .dialog::backdrop {
207
+ background-color: transparent;
208
+ }
209
+ }
182
210
 
211
+ /* Lock background scroll while a modal dialog is open. `scrollbar-gutter`
212
+ reserves the scrollbar space so toggling the lock doesn't shift the layout. */
213
+ :global(html:has(dialog.dialog[open])) {
214
+ overflow: hidden;
215
+ scrollbar-gutter: stable;
216
+ }
217
+
218
+ /* `min-height: 100%` (not `height`) makes tall content overflow downward only: the
219
+ wrapper grows to its content, so the centered top stays reachable in the dialog's
220
+ scroll. A fixed-height centering box would strand the top above the scroll origin. */
183
221
  .dialog-wrapper {
184
- position: relative; /* for the surface */
185
222
  min-height: 100%;
186
223
  display: flex;
187
224
  align-items: center;
188
225
  justify-content: center;
189
226
  }
190
- .layout-page .dialog-wrapper {
227
+ .align-top .dialog-wrapper {
191
228
  align-items: start;
192
229
  }
193
-
194
- .dialog-bg {
195
- position: absolute;
196
- inset: 0;
197
- z-index: 0;
198
- opacity: 0;
199
- transition: opacity var(--duration_3) ease;
200
- background-color: var(--dialog_bg, var(--darken_60));
201
- }
202
- .ready .dialog-bg {
203
- opacity: 1;
204
- }
205
-
206
- .dialog-layout {
207
- height: 100%;
208
- /* makes the content overflow downwards instead of upwards+downwards because it's centered */
209
- max-height: 100%;
210
- }
211
-
212
- .dialog-content {
213
- width: 100%;
214
- transform: scale(0.99);
215
- transition: transform var(--duration_1) ease;
216
- padding: 40px;
217
- }
218
- .ready .dialog-content {
219
- transform: scale(1);
220
- }
221
230
  </style>