@sveltier/ui 0.1.0-alpha.0 → 0.1.0-alpha.1

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 (71) hide show
  1. package/dist/components/Accordion/Accordion.svelte +74 -0
  2. package/dist/components/Accordion/Accordion.svelte.d.ts +25 -0
  3. package/dist/components/Accordion/accordion.css +88 -0
  4. package/dist/components/Accordion/index.d.ts +2 -0
  5. package/dist/components/Accordion/index.js +1 -0
  6. package/dist/components/Accordion/types.d.ts +64 -0
  7. package/dist/components/Accordion/types.js +1 -0
  8. package/dist/components/Dialog/Dialog.svelte +36 -0
  9. package/dist/components/Dialog/Dialog.svelte.d.ts +4 -0
  10. package/dist/components/Dialog/index.d.ts +2 -0
  11. package/dist/components/Dialog/index.js +1 -0
  12. package/dist/components/Dialog/types.d.ts +50 -0
  13. package/dist/components/Dialog/types.js +1 -0
  14. package/dist/components/Menu/Menu.svelte +195 -0
  15. package/dist/components/Menu/Menu.svelte.d.ts +4 -0
  16. package/dist/components/Menu/index.d.ts +2 -0
  17. package/dist/components/Menu/index.js +1 -0
  18. package/dist/components/Menu/menu.css +103 -0
  19. package/dist/components/Menu/types.d.ts +90 -0
  20. package/dist/components/Menu/types.js +1 -0
  21. package/dist/components/Modal/Modal.svelte +146 -0
  22. package/dist/components/Modal/Modal.svelte.d.ts +4 -0
  23. package/dist/components/Modal/modal.css +150 -0
  24. package/dist/components/Modal/types.d.ts +54 -0
  25. package/dist/components/Modal/types.js +1 -0
  26. package/dist/components/Popover/Popover.svelte +115 -0
  27. package/dist/components/Popover/Popover.svelte.d.ts +4 -0
  28. package/dist/components/Popover/index.d.ts +2 -0
  29. package/dist/components/Popover/index.js +1 -0
  30. package/dist/components/Popover/popover.css +67 -0
  31. package/dist/components/Popover/types.d.ts +83 -0
  32. package/dist/components/Popover/types.js +1 -0
  33. package/dist/components/Sheet/Sheet.svelte +56 -0
  34. package/dist/components/Sheet/Sheet.svelte.d.ts +4 -0
  35. package/dist/components/Sheet/index.d.ts +2 -0
  36. package/dist/components/Sheet/index.js +2 -0
  37. package/dist/components/Sheet/sheet.css +61 -0
  38. package/dist/components/Sheet/types.d.ts +51 -0
  39. package/dist/components/Sheet/types.js +1 -0
  40. package/dist/components/Tooltip/Tooltip.svelte +161 -0
  41. package/dist/components/Tooltip/Tooltip.svelte.d.ts +4 -0
  42. package/dist/components/Tooltip/index.d.ts +2 -0
  43. package/dist/components/Tooltip/index.js +1 -0
  44. package/dist/components/Tooltip/tooltip.css +44 -0
  45. package/dist/components/Tooltip/types.d.ts +55 -0
  46. package/dist/components/Tooltip/types.js +1 -0
  47. package/dist/core/errors/codes.d.ts +15 -3
  48. package/dist/core/errors/codes.js +15 -3
  49. package/dist/core/locale/locale.d.ts +1 -0
  50. package/dist/core/locale/locale.js +2 -0
  51. package/dist/core/overlay/types.d.ts +33 -0
  52. package/dist/core/overlay/types.js +1 -0
  53. package/dist/core/provider/UIProvider.svelte +5 -2
  54. package/dist/core/theme/css.js +6 -2
  55. package/dist/core/toast/ToastOutlet.svelte +192 -0
  56. package/dist/core/toast/ToastOutlet.svelte.d.ts +11 -0
  57. package/dist/core/toast/ToastProvider.svelte +39 -0
  58. package/dist/core/toast/ToastProvider.svelte.d.ts +19 -0
  59. package/dist/core/toast/toast.css +134 -0
  60. package/dist/core/toast/toast.svelte.d.ts +117 -0
  61. package/dist/core/toast/toast.svelte.js +133 -0
  62. package/dist/dev/off.d.ts +1 -0
  63. package/dist/dev/off.js +1 -0
  64. package/dist/dev/on.d.ts +14 -2
  65. package/dist/dev/on.js +22 -6
  66. package/dist/index.d.ts +9 -0
  67. package/dist/index.js +11 -0
  68. package/dist/primitives/layer/dismissable.d.ts +6 -0
  69. package/dist/primitives/layer/dismissable.js +11 -4
  70. package/dist/styles/props.css +5 -2
  71. package/package.json +1 -1
@@ -0,0 +1,90 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { Snippet } from 'svelte';
3
+ import type { CloseReason, TriggerAttributes } from '../../core/overlay/types.js';
4
+ import type { ElementProps } from '../../core/props/element.js';
5
+ import type { Density } from '../../core/props/types.js';
6
+ import type { Align, Side } from '../../primitives/anchored/anchored.js';
7
+ /** The Menu props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
8
+ export interface MenuDefaults {
9
+ /**
10
+ * Multiplies the height and padding of the items, never the text.
11
+ * @default 'default'
12
+ */
13
+ density: Density;
14
+ }
15
+ declare module '../../core/defaults/context.js' {
16
+ interface ComponentDefaults {
17
+ Menu: MenuDefaults;
18
+ }
19
+ }
20
+ /** An item the user can choose. */
21
+ export interface MenuAction {
22
+ /** What the item says; typing its first letters moves to it. */
23
+ label: string;
24
+ /** An identifier for `onselect` to tell the items apart. */
25
+ value?: string;
26
+ /**
27
+ * An icon before the label: an SVG path on a 24×24 grid, as icon sets such as Material Design
28
+ * Icons export them (`mdiPencil`).
29
+ */
30
+ icon?: string;
31
+ /** A keyboard shortcut shown at the end, such as `Ctrl+C`. Showing it does not bind it. */
32
+ shortcut?: string;
33
+ /** Shown but not chosen, and skipped by the arrow keys. */
34
+ disabled?: boolean;
35
+ /** In the error colour, for an action that deletes or cannot be undone. */
36
+ danger?: boolean;
37
+ }
38
+ /** A line between groups of items. */
39
+ export interface MenuSeparator {
40
+ separator: true;
41
+ }
42
+ /** A heading that names the items after it, up to the next heading or separator. */
43
+ export interface MenuHeading {
44
+ heading: string;
45
+ }
46
+ /** An entry of `items`: an action, a separator or a heading. */
47
+ export type MenuItem = MenuAction | MenuSeparator | MenuHeading;
48
+ /**
49
+ * A list of actions that opens from a control, as the APG menu button pattern describes. Arrow
50
+ * keys move through the items and wrap, Home and End jump to the ends, typing moves to the item
51
+ * that starts with it, Enter and Space choose, Escape closes and gives focus back, and Tab closes
52
+ * and moves on. `class`, `style`, `ref` and other attributes go to the menu.
53
+ *
54
+ * @example
55
+ * <Menu
56
+ * items={[
57
+ * { label: 'Rename', value: 'rename', shortcut: 'F2' },
58
+ * { separator: true },
59
+ * { label: 'Delete', value: 'delete', danger: true }
60
+ * ]}
61
+ * onselect={(item) => run(item.value)}
62
+ * >
63
+ * {#snippet trigger(attributes)}<Button {...attributes}>Actions</Button>{/snippet}
64
+ * </Menu>
65
+ */
66
+ export type MenuProps = ElementProps<HTMLDivElement, Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'popover' | 'role'>, Partial<MenuDefaults> & {
67
+ /** The entries, in order. */
68
+ items: readonly MenuItem[];
69
+ /** Called with the item the user chooses, after the menu has closed. */
70
+ onselect?: (item: MenuAction) => void;
71
+ /** The control that opens it. Spread the attributes it receives onto it. */
72
+ trigger: Snippet<[TriggerAttributes]>;
73
+ /**
74
+ * Whether it is open. Bindable; it sets this to `false` when it closes itself.
75
+ * @default false
76
+ */
77
+ open?: boolean;
78
+ /**
79
+ * The side of the control it opens on, moved to the opposite side when there is no room.
80
+ * @default 'bottom'
81
+ */
82
+ side?: Side;
83
+ /**
84
+ * Its alignment along that side.
85
+ * @default 'start'
86
+ */
87
+ align?: Align;
88
+ /** Called when it closes itself, with the reason; `'action'` after a choice. */
89
+ onclose?: (reason: CloseReason) => void;
90
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,146 @@
1
+ <script lang="ts">
2
+ // A modal <dialog> in the top layer with a panel: the shell of Dialog and Sheet. See
3
+ // ModalProps.
4
+ import { createAttachmentKey, type Attachment } from 'svelte/attachments';
5
+ import { DEV, warnUnnamed } from '#dev';
6
+ import { useLocale } from '../../core/locale/locale.js';
7
+ import type { CloseReason, TriggerAttributes } from '../../core/overlay/types.js';
8
+ import { getToasts } from '../../core/toast/toast.svelte.js';
9
+ import ToastOutlet from '../../core/toast/ToastOutlet.svelte';
10
+ import { focusTrap } from '../../primitives/focus/focus-trap.js';
11
+ import { dismissable } from '../../primitives/layer/dismissable.js';
12
+ import { fade } from '../../primitives/motion/motion.js';
13
+ import { scrollLock } from '../../primitives/scroll-lock/scroll-lock.js';
14
+ import './modal.css';
15
+ import type { ModalProps } from './types.js';
16
+
17
+ let {
18
+ component,
19
+ placement,
20
+ panelClass,
21
+ enter,
22
+ open = $bindable(false),
23
+ title,
24
+ children,
25
+ actions,
26
+ trigger,
27
+ persistent = false,
28
+ closable = false,
29
+ closeLabel,
30
+ fullscreen = false,
31
+ onclose,
32
+ id: idProp,
33
+ class: className,
34
+ ref = $bindable(null),
35
+ ...rest
36
+ }: ModalProps = $props();
37
+
38
+ const uid = $props.id();
39
+ const id = $derived(idProp ?? uid);
40
+ const locale = useLocale();
41
+
42
+ const CLOSE =
43
+ 'M6.4 5 12 10.6 17.6 5 19 6.4 13.4 12l5.6 5.6-1.4 1.4-5.6-5.6L6.4 19 5 17.6l5.6-5.6L5 6.4z';
44
+
45
+ // The control that opened it: focus goes back to it, as Safari does not focus a clicked
46
+ // button by itself.
47
+ let opener = $state<HTMLElement | null>(null);
48
+ const capture: Attachment<HTMLElement> = (node) => {
49
+ opener = node;
50
+ return () => {
51
+ if (opener === node) opener = null;
52
+ };
53
+ };
54
+ const OPENER = createAttachmentKey();
55
+ const triggerAttributes: TriggerAttributes = $derived({
56
+ 'aria-haspopup': 'dialog',
57
+ 'aria-controls': open ? id : undefined,
58
+ onclick: () => (open = true),
59
+ [OPENER]: capture
60
+ });
61
+
62
+ function close(reason: CloseReason) {
63
+ if (!open) return;
64
+ open = false;
65
+ onclose?.(reason);
66
+ }
67
+
68
+ // The top layer, with the rest of the page inert.
69
+ const modal: Attachment<HTMLDialogElement> = (dialog) => {
70
+ dialog.showModal();
71
+ if (DEV) warnUnnamed(component, dialog, 'a title');
72
+ };
73
+
74
+ // The rest of the page is inert under a modal dialog, toasts included, so they show in the
75
+ // dialog while it is open, and go back to the page as soon as it starts to close.
76
+ const toasts = getToasts();
77
+ const outlet = {};
78
+ let toastRegion = $state<HTMLElement | null>(null);
79
+ $effect(() => {
80
+ if (open && toasts) return toasts.register(outlet);
81
+ });
82
+
83
+ // Escape is left to the layer stack, so nested layers close one at a time. The browser may
84
+ // still close the dialog itself: Chrome does after repeated presses, and a form with
85
+ // method="dialog" does on submit. Either way the state follows, with the likely reason.
86
+ let escaped = false;
87
+ function cancel(event: Event) {
88
+ event.preventDefault();
89
+ escaped = true;
90
+ }
91
+ </script>
92
+
93
+ {#if trigger}{@render trigger(triggerAttributes)}{/if}
94
+
95
+ {#if open}
96
+ <dialog
97
+ bind:this={ref}
98
+ aria-labelledby={title ? `${id}-title` : undefined}
99
+ {...rest}
100
+ {id}
101
+ class={['sv-modal', `sv-modal--${placement}`, fullscreen && 'sv-modal--fullscreen', className]}
102
+ {@attach modal}
103
+ {@attach scrollLock()}
104
+ {@attach focusTrap({ returnFocus: opener ?? true })}
105
+ oncancel={cancel}
106
+ onclose={() => close(escaped ? 'escape' : 'action')}
107
+ transition:fade
108
+ >
109
+ <!-- The scrim is the dialog itself, so a press beside the panel is a press outside it. -->
110
+ <div
111
+ class={['sv-modal__panel', panelClass]}
112
+ {@attach dismissable({
113
+ onDismiss: close,
114
+ escape: !persistent,
115
+ outside: !persistent,
116
+ inside: () => [toastRegion]
117
+ })}
118
+ transition:enter
119
+ >
120
+ {#if title || closable}
121
+ <header class="sv-modal__header">
122
+ {#if title}<h2 class="sv-modal__title" id="{id}-title">{title}</h2>{/if}
123
+ {#if closable}
124
+ <button
125
+ type="button"
126
+ class="sv-modal__close"
127
+ aria-label={closeLabel ?? locale.t('close')}
128
+ onclick={() => close('action')}
129
+ >
130
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"
131
+ ><path d={CLOSE} /></svg
132
+ >
133
+ </button>
134
+ {/if}
135
+ </header>
136
+ {/if}
137
+ {#if children}<div class="sv-modal__body">{@render children()}</div>{/if}
138
+ {#if actions}
139
+ <footer class="sv-modal__actions">
140
+ {@render actions({ close: () => close('action') })}
141
+ </footer>
142
+ {/if}
143
+ </div>
144
+ {#if toasts}<ToastOutlet {toasts} {outlet} bind:ref={toastRegion} />{/if}
145
+ </dialog>
146
+ {/if}
@@ -0,0 +1,4 @@
1
+ import type { ModalProps } from './types.js';
2
+ declare const Modal: import("svelte").Component<ModalProps, {}, "ref" | "open">;
3
+ type Modal = ReturnType<typeof Modal>;
4
+ export default Modal;
@@ -0,0 +1,150 @@
1
+ @layer sv.tokens, sv.base, sv.components, sv.utilities;
2
+
3
+ @layer sv.components {
4
+ /*
5
+ * The <dialog> is the whole viewport: a scrim with the panel on it, so a press beside the panel
6
+ * is a press outside it. The scrim comes with the theme: its ink over a light theme,
7
+ * its background over a dark one, so custom themes get one in their own tone too.
8
+ */
9
+ .sv-modal {
10
+ position: fixed;
11
+ inset: 0;
12
+ box-sizing: border-box;
13
+ display: grid;
14
+ place-items: center;
15
+ inline-size: 100%;
16
+ max-inline-size: none;
17
+ block-size: 100%;
18
+ max-block-size: none;
19
+ margin: 0;
20
+ padding: var(--sv-space-21);
21
+ overflow: hidden;
22
+ border: 0;
23
+ background: var(--sv-color-scrim);
24
+ color: var(--sv-color-text);
25
+ overscroll-behavior: contain;
26
+ }
27
+
28
+ .sv-modal::backdrop {
29
+ background: transparent;
30
+ }
31
+
32
+ /* The panel: a large surface, as wide as its size allows, as tall as the screen at most. */
33
+ .sv-modal__panel {
34
+ --sv-modal-pad: calc(var(--sv-space-21) * var(--sv-density));
35
+ box-sizing: border-box;
36
+ display: flex;
37
+ flex-direction: column;
38
+ inline-size: 100%;
39
+ max-inline-size: var(--sv-modal-width);
40
+ max-block-size: 100%;
41
+ border-radius: var(--sv-rounded, var(--sv-radius-xl));
42
+ background-color: var(--sv-color-surface);
43
+ box-shadow: var(--sv-shadow);
44
+ font-family: var(--sv-font-ui);
45
+ font-size: var(--sv-text-md);
46
+ line-height: var(--sv-leading-normal);
47
+ }
48
+
49
+ /* Widths on a half step of φ: 21, 27, 34, 44 and 55rem. */
50
+ .sv-modal__panel.sv-size-xs {
51
+ --sv-modal-width: 21rem;
52
+ }
53
+
54
+ .sv-modal__panel.sv-size-sm {
55
+ --sv-modal-width: 27rem;
56
+ }
57
+
58
+ .sv-modal__panel.sv-size-md {
59
+ --sv-modal-width: 34rem;
60
+ }
61
+
62
+ .sv-modal__panel.sv-size-lg {
63
+ --sv-modal-width: 44rem;
64
+ }
65
+
66
+ .sv-modal__panel.sv-size-xl {
67
+ --sv-modal-width: 55rem;
68
+ }
69
+
70
+ .sv-modal__header {
71
+ display: flex;
72
+ flex: none;
73
+ gap: var(--sv-space-13);
74
+ align-items: flex-start;
75
+ padding: var(--sv-modal-pad) var(--sv-modal-pad) 0;
76
+ }
77
+
78
+ /* The heading in the theme's display voice, as a Card title. */
79
+ .sv-modal__title {
80
+ flex: 1;
81
+ min-inline-size: 0;
82
+ margin: 0;
83
+ font: var(--sv-weight-display) var(--sv-text-xl) / var(--sv-leading-snug) var(--sv-font-display);
84
+ letter-spacing: var(--sv-tracking-display);
85
+ }
86
+
87
+ .sv-modal__close {
88
+ display: inline-grid;
89
+ flex: none;
90
+ place-items: center;
91
+ inline-size: 2.058rem;
92
+ block-size: 2.058rem;
93
+ margin-block: -0.236rem;
94
+ margin-inline-end: -0.382rem;
95
+ padding: 0;
96
+ border: 0;
97
+ border-radius: var(--sv-radius-full);
98
+ background: transparent;
99
+ color: var(--sv-color-text-muted);
100
+ cursor: pointer;
101
+ }
102
+
103
+ @media (hover: hover) {
104
+ .sv-modal__close:hover {
105
+ background-color: color-mix(in oklch, currentColor var(--sv-state-hover), transparent);
106
+ }
107
+ }
108
+
109
+ .sv-modal__close svg {
110
+ inline-size: 1.272rem;
111
+ block-size: 1.272rem;
112
+ }
113
+
114
+ /* The body scrolls; the heading and the actions stay. */
115
+ .sv-modal__body {
116
+ flex: 1;
117
+ min-block-size: 0;
118
+ padding: var(--sv-modal-pad);
119
+ overflow-y: auto;
120
+ overscroll-behavior: contain;
121
+ }
122
+
123
+ .sv-modal__header + .sv-modal__body {
124
+ padding-block-start: var(--sv-space-13);
125
+ }
126
+
127
+ .sv-modal__actions {
128
+ display: flex;
129
+ flex: none;
130
+ flex-wrap: wrap;
131
+ gap: var(--sv-space-8);
132
+ justify-content: flex-end;
133
+ padding: 0 var(--sv-modal-pad) var(--sv-modal-pad);
134
+ }
135
+
136
+ .sv-modal__header:last-child,
137
+ .sv-modal__body:last-child {
138
+ padding-block-end: var(--sv-modal-pad);
139
+ }
140
+
141
+ .sv-modal--fullscreen {
142
+ padding: 0;
143
+ }
144
+
145
+ .sv-modal--fullscreen > .sv-modal__panel {
146
+ max-inline-size: none;
147
+ block-size: 100%;
148
+ border-radius: 0;
149
+ }
150
+ }
@@ -0,0 +1,54 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { ClassValue, HTMLDialogAttributes } from 'svelte/elements';
3
+ import type { TransitionConfig } from 'svelte/transition';
4
+ import type { CloseReason, TriggerAttributes } from '../../core/overlay/types.js';
5
+ import type { ElementProps } from '../../core/props/element.js';
6
+ import type { SheetSide } from '../Sheet/types.js';
7
+ /** The props Dialog and Sheet share. */
8
+ export interface ModalContentProps {
9
+ /**
10
+ * Whether it is open. Bindable; it sets this to `false` when it closes itself.
11
+ * @default false
12
+ */
13
+ open?: boolean;
14
+ /** The heading, which also names it. Without it, name it with `aria-label`. */
15
+ title?: string;
16
+ /** The body. */
17
+ children?: Snippet;
18
+ /** Buttons at the end. `close()` closes it with the reason `'action'`. */
19
+ actions?: Snippet<[{
20
+ close: () => void;
21
+ }]>;
22
+ /** The control that opens it. Spread the attributes it receives onto it. */
23
+ trigger?: Snippet<[TriggerAttributes]>;
24
+ /**
25
+ * Escape and a press on the backdrop do not close it: an answer is needed.
26
+ * @default false
27
+ */
28
+ persistent?: boolean;
29
+ /**
30
+ * Adds a close button to the heading.
31
+ * @default false
32
+ */
33
+ closable?: boolean;
34
+ /**
35
+ * The accessible name of the close button.
36
+ * @default 'Close' (from the locale)
37
+ */
38
+ closeLabel?: string;
39
+ /** Called when it closes itself, with the reason. */
40
+ onclose?: (reason: CloseReason) => void;
41
+ }
42
+ /** A modal `<dialog>` with a panel, the shell of Dialog and Sheet. Internal. */
43
+ export type ModalProps = ElementProps<HTMLDialogElement, Omit<HTMLDialogAttributes, 'open' | 'title' | 'children'>, ModalContentProps & {
44
+ /** The component's name, for development warnings. */
45
+ component: string;
46
+ /** Where the panel sits: in the centre (a dialog) or against an edge (a sheet). */
47
+ placement: 'center' | SheetSide;
48
+ /** The shared-prop classes of the panel. */
49
+ panelClass: ClassValue;
50
+ /** How the panel comes in and goes: a dialog pops, a sheet slides. */
51
+ enter: (node: Element) => TransitionConfig;
52
+ /** The panel fills the screen. */
53
+ fullscreen?: boolean;
54
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,115 @@
1
+ <script lang="ts">
2
+ // A non-modal panel next to the control that opens it. See PopoverProps.
3
+ import { createAttachmentKey, type Attachment } from 'svelte/attachments';
4
+ import { checkValue, DEV, warnUnnamed } from '#dev';
5
+ import { useDefaults } from '../../core/defaults/context.js';
6
+ import type { CloseReason, TriggerAttributes } from '../../core/overlay/types.js';
7
+ import { sharedClasses } from '../../core/props/classes.js';
8
+ import { ALIGNS, anchored, SIDES } from '../../primitives/anchored/anchored.js';
9
+ import { tabbable } from '../../primitives/focus/tabbable.js';
10
+ import { dismissable } from '../../primitives/layer/dismissable.js';
11
+ import { pop } from '../../primitives/motion/motion.js';
12
+ import './popover.css';
13
+ import type { PopoverDefaults, PopoverProps } from './types.js';
14
+
15
+ let {
16
+ size,
17
+ density,
18
+ rounded,
19
+ trigger,
20
+ open = $bindable(false),
21
+ title,
22
+ children,
23
+ actions,
24
+ side = 'bottom',
25
+ align = 'start',
26
+ offset = 8,
27
+ persistent = false,
28
+ onclose,
29
+ id: idProp,
30
+ class: className,
31
+ ref = $bindable(null),
32
+ ...rest
33
+ }: PopoverProps = $props();
34
+
35
+ const resolve = useDefaults<PopoverDefaults>('Popover', {
36
+ size: 'md',
37
+ density: 'default',
38
+ rounded: true
39
+ });
40
+ const p = $derived(resolve({ size, density, rounded }));
41
+
42
+ if (DEV) {
43
+ $effect(() => checkValue('Popover', 'side', side, SIDES));
44
+ $effect(() => checkValue('Popover', 'align', align, ALIGNS));
45
+ }
46
+
47
+ const uid = $props.id();
48
+ const id = $derived(idProp ?? uid);
49
+
50
+ // The control that opens it: the panel is placed against it, and focus goes back to it.
51
+ let anchor = $state<HTMLElement | null>(null);
52
+ const capture: Attachment<HTMLElement> = (node) => {
53
+ anchor = node;
54
+ return () => {
55
+ if (anchor === node) anchor = null;
56
+ };
57
+ };
58
+ const ANCHOR = createAttachmentKey();
59
+ const triggerAttributes: TriggerAttributes = $derived({
60
+ 'aria-haspopup': 'dialog',
61
+ 'aria-expanded': open,
62
+ 'aria-controls': open ? id : undefined,
63
+ onclick: () => (open ? close('action') : (open = true)),
64
+ [ANCHOR]: capture
65
+ });
66
+
67
+ function close(reason: CloseReason) {
68
+ if (!open) return;
69
+ // Closed from inside, focus goes back to the control; a press or a Tab elsewhere has
70
+ // already chosen where it goes.
71
+ if (ref?.contains(document.activeElement)) anchor?.focus({ preventScroll: true });
72
+ open = false;
73
+ onclose?.(reason);
74
+ }
75
+
76
+ // In the top layer, with focus on the element marked autofocus, else the first that takes
77
+ // it, else the panel itself.
78
+ const show: Attachment<HTMLElement> = (panel) => {
79
+ panel.showPopover();
80
+ if (DEV) warnUnnamed('Popover', panel, 'a title');
81
+ const target = panel.querySelector<HTMLElement>('[autofocus]') ?? tabbable(panel)[0] ?? panel;
82
+ target.focus({ preventScroll: true });
83
+ };
84
+ </script>
85
+
86
+ {@render trigger(triggerAttributes)}
87
+
88
+ {#if open}
89
+ <div
90
+ bind:this={ref}
91
+ aria-labelledby={title ? `${id}-title` : undefined}
92
+ tabindex="-1"
93
+ {...rest}
94
+ {id}
95
+ role="dialog"
96
+ popover="manual"
97
+ class={['sv-popover', sharedClasses(p, 'Popover'), className]}
98
+ {@attach show}
99
+ {@attach anchored({ anchor, side, align, offset })}
100
+ {@attach dismissable({
101
+ onDismiss: close,
102
+ escape: !persistent,
103
+ outside: !persistent,
104
+ focusOutside: !persistent,
105
+ inside: () => [anchor]
106
+ })}
107
+ transition:pop
108
+ >
109
+ {#if title}<div class="sv-popover__title" id="{id}-title">{title}</div>{/if}
110
+ {#if children}<div class="sv-popover__body">{@render children()}</div>{/if}
111
+ {#if actions}
112
+ <div class="sv-popover__actions">{@render actions({ close: () => close('action') })}</div>
113
+ {/if}
114
+ </div>
115
+ {/if}
@@ -0,0 +1,4 @@
1
+ import type { PopoverProps } from './types.js';
2
+ declare const Popover: import("svelte").Component<PopoverProps, {}, "ref" | "open">;
3
+ type Popover = ReturnType<typeof Popover>;
4
+ export default Popover;
@@ -0,0 +1,2 @@
1
+ export { default as Popover } from './Popover.svelte';
2
+ export type { PopoverDefaults, PopoverProps } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as Popover } from './Popover.svelte';
@@ -0,0 +1,67 @@
1
+ @layer sv.tokens, sv.base, sv.components, sv.utilities;
2
+
3
+ @layer sv.components {
4
+ /*
5
+ * A raised surface beside its control, as wide as its content up to its size and never wider
6
+ * or taller than the screen, scrolling inside when its content is longer.
7
+ */
8
+ .sv-popover {
9
+ --sv-popover-pad: calc(var(--sv-space-13) * var(--sv-density));
10
+ box-sizing: border-box;
11
+ display: grid;
12
+ gap: var(--sv-space-13);
13
+ inline-size: max-content;
14
+ max-inline-size: min(var(--sv-popover-width), 100vw - 2 * var(--sv-space-8));
15
+ max-block-size: calc(100dvh - 2 * var(--sv-space-8));
16
+ padding: var(--sv-popover-pad);
17
+ overflow: auto;
18
+ overscroll-behavior: contain;
19
+ border: var(--sv-hairline) solid var(--sv-color-border-soft);
20
+ border-radius: var(--sv-rounded, var(--sv-radius-lg));
21
+ outline: none;
22
+ background-color: var(--sv-color-surface);
23
+ box-shadow: var(--sv-shadow);
24
+ color: var(--sv-color-text);
25
+ font-family: var(--sv-font-ui);
26
+ font-size: var(--sv-text-sm);
27
+ line-height: var(--sv-leading-normal);
28
+ }
29
+
30
+ /* Widths on the φ series: 13, 17, 21, 27 and 34rem. */
31
+ .sv-popover.sv-size-xs {
32
+ --sv-popover-width: 13rem;
33
+ }
34
+
35
+ .sv-popover.sv-size-sm {
36
+ --sv-popover-width: 17rem;
37
+ }
38
+
39
+ .sv-popover.sv-size-md {
40
+ --sv-popover-width: 21rem;
41
+ }
42
+
43
+ .sv-popover.sv-size-lg {
44
+ --sv-popover-width: 27rem;
45
+ }
46
+
47
+ .sv-popover.sv-size-xl {
48
+ --sv-popover-width: 34rem;
49
+ }
50
+
51
+ /* The heading in the theme's display voice, a step above the body. */
52
+ .sv-popover__title {
53
+ font: var(--sv-weight-display) var(--sv-text-md) / var(--sv-leading-snug) var(--sv-font-display);
54
+ letter-spacing: var(--sv-tracking-display);
55
+ }
56
+
57
+ .sv-popover__body {
58
+ min-inline-size: 0;
59
+ }
60
+
61
+ .sv-popover__actions {
62
+ display: flex;
63
+ flex-wrap: wrap;
64
+ gap: var(--sv-space-8);
65
+ justify-content: flex-end;
66
+ }
67
+ }