@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,74 @@
1
+ <script lang="ts" generics="T extends string | number">
2
+ // Sections that open and close, on the platform's <details>. See AccordionProps.
3
+ import { useDefaults } from '../../core/defaults/context.js';
4
+ import { sharedClasses } from '../../core/props/classes.js';
5
+ import './accordion.css';
6
+ import type { AccordionDefaults, AccordionItem, AccordionProps } from './types.js';
7
+
8
+ let {
9
+ density,
10
+ items,
11
+ panel,
12
+ multiple = false,
13
+ value = $bindable(),
14
+ onchange,
15
+ class: className,
16
+ ref = $bindable(null),
17
+ ...rest
18
+ }: AccordionProps<T> = $props();
19
+
20
+ const resolve = useDefaults<AccordionDefaults>('Accordion', { density: 'default' });
21
+ const p = $derived(resolve({ density }));
22
+
23
+ // The sections of one accordion share a name, so the browser keeps at most one open.
24
+ const name = $props.id();
25
+
26
+ const CHEVRON = 'M5.6 8.6 12 15l6.4-6.4L20 10.2l-8 8-8-8z';
27
+
28
+ const list = $derived(Array.isArray(value) ? value : []);
29
+ const isOpen = (item: AccordionItem<T>) =>
30
+ multiple ? list.includes(item.value) : value === item.value;
31
+
32
+ function change(next: T | null | T[]) {
33
+ value = next;
34
+ (onchange as ((value: T | null | T[]) => void) | undefined)?.(next);
35
+ }
36
+
37
+ // The browser has opened or closed a section: its header, finding text in it, or `open` set
38
+ // from `value`, in which case the state already agrees.
39
+ function toggle(item: AccordionItem<T>, details: HTMLDetailsElement) {
40
+ if (details.open === isOpen(item)) return;
41
+ if (multiple) {
42
+ change(details.open ? [...list, item.value] : list.filter((other) => other !== item.value));
43
+ } else if (details.open) {
44
+ change(item.value);
45
+ } else if (!ref?.querySelector(':scope > details[open]')) {
46
+ // Closed because another opened: that one's own event sets the value.
47
+ change(null);
48
+ }
49
+ }
50
+ </script>
51
+
52
+ <div bind:this={ref} {...rest} class={['sv-accordion', sharedClasses(p, 'Accordion'), className]}>
53
+ {#each items as item (item.value)}
54
+ <details
55
+ class="sv-accordion__item"
56
+ name={multiple ? undefined : name}
57
+ open={isOpen(item)}
58
+ ontoggle={(event) => toggle(item, event.currentTarget)}
59
+ >
60
+ <!-- A disabled header takes focus and is announced, but does not toggle. -->
61
+ <summary
62
+ class="sv-accordion__header"
63
+ aria-disabled={item.disabled || undefined}
64
+ onclick={(event) => item.disabled && event.preventDefault()}
65
+ >
66
+ <span class="sv-accordion__title">{item.title}</span>
67
+ <svg class="sv-accordion__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
68
+ <path d={CHEVRON} />
69
+ </svg>
70
+ </summary>
71
+ <div class="sv-accordion__panel">{@render panel(item)}</div>
72
+ </details>
73
+ {/each}
74
+ </div>
@@ -0,0 +1,25 @@
1
+ import type { AccordionProps } from './types.js';
2
+ declare function $$render<T extends string | number>(): {
3
+ props: AccordionProps<T>;
4
+ exports: {};
5
+ bindings: "ref" | "value";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<T extends string | number> {
10
+ props(): ReturnType<typeof $$render<T>>['props'];
11
+ events(): ReturnType<typeof $$render<T>>['events'];
12
+ slots(): ReturnType<typeof $$render<T>>['slots'];
13
+ bindings(): "ref" | "value";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <T extends string | number>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<T>['exports']>;
20
+ <T extends string | number>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const Accordion: $$IsomorphicComponent;
24
+ type Accordion<T extends string | number> = InstanceType<typeof Accordion<T>>;
25
+ export default Accordion;
@@ -0,0 +1,88 @@
1
+ @layer sv.tokens, sv.base, sv.components, sv.utilities;
2
+
3
+ @layer sv.components {
4
+ /* Sections divided by hairlines, the headers as wide as the accordion. */
5
+ .sv-accordion {
6
+ border-block-start: var(--sv-hairline) solid var(--sv-color-border);
7
+ }
8
+
9
+ .sv-accordion__item {
10
+ border-block-end: var(--sv-hairline) solid var(--sv-color-border);
11
+ }
12
+
13
+ .sv-accordion__header {
14
+ display: flex;
15
+ gap: var(--sv-space-13);
16
+ align-items: center;
17
+ min-block-size: calc(var(--sv-control-lg) * var(--sv-density));
18
+ padding-block: calc(var(--sv-space-8) * var(--sv-density));
19
+ padding-inline: var(--sv-space-8);
20
+ border-radius: var(--sv-radius-sm);
21
+ box-sizing: border-box;
22
+ font-weight: var(--sv-weight-medium);
23
+ line-height: var(--sv-leading-snug);
24
+ list-style: none;
25
+ cursor: pointer;
26
+ -webkit-tap-highlight-color: transparent;
27
+ }
28
+
29
+ .sv-accordion__header::-webkit-details-marker {
30
+ display: none;
31
+ }
32
+
33
+ @media (hover: hover) {
34
+ .sv-accordion__header:not([aria-disabled='true']):hover {
35
+ background-color: color-mix(in oklch, currentColor var(--sv-state-hover), transparent);
36
+ }
37
+ }
38
+
39
+ .sv-accordion__header[aria-disabled='true'] {
40
+ color: var(--sv-color-text-muted);
41
+ cursor: not-allowed;
42
+ }
43
+
44
+ .sv-accordion__title {
45
+ flex: 1;
46
+ min-inline-size: 0;
47
+ }
48
+
49
+ /* The chevron turns over as the section opens. */
50
+ .sv-accordion__icon {
51
+ flex: none;
52
+ inline-size: 1.272em;
53
+ block-size: 1.272em;
54
+ color: var(--sv-color-text-muted);
55
+ transition: transform var(--sv-duration-normal) var(--sv-ease);
56
+ }
57
+
58
+ .sv-accordion__item[open] > .sv-accordion__header .sv-accordion__icon {
59
+ transform: rotate(180deg);
60
+ }
61
+
62
+ .sv-accordion__panel {
63
+ padding-block: calc(var(--sv-space-5) * var(--sv-density))
64
+ calc(var(--sv-space-21) * var(--sv-density));
65
+ padding-inline: var(--sv-space-8);
66
+ color: var(--sv-color-text-muted);
67
+ line-height: var(--sv-leading-normal);
68
+ }
69
+
70
+ /*
71
+ * The content fades in as it opens and out before it closes. The height itself snaps, as
72
+ * only opacity and transform are animated; where ::details-content is not supported, the
73
+ * section simply opens.
74
+ */
75
+ .sv-accordion__item::details-content {
76
+ opacity: 0;
77
+ transform: translateY(calc(var(--sv-space-5) * -1));
78
+ transition-property: opacity, transform, content-visibility;
79
+ transition-duration: var(--sv-duration-normal);
80
+ transition-timing-function: var(--sv-ease);
81
+ transition-behavior: allow-discrete;
82
+ }
83
+
84
+ .sv-accordion__item[open]::details-content {
85
+ opacity: 1;
86
+ transform: none;
87
+ }
88
+ }
@@ -0,0 +1,2 @@
1
+ export { default as Accordion } from './Accordion.svelte';
2
+ export type { AccordionDefaults, AccordionItem, AccordionProps } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as Accordion } from './Accordion.svelte';
@@ -0,0 +1,64 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ElementProps } from '../../core/props/element.js';
4
+ import type { Density } from '../../core/props/types.js';
5
+ /** The Accordion props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
6
+ export interface AccordionDefaults {
7
+ /**
8
+ * Multiplies the padding of the headers and panels, never the text.
9
+ * @default 'default'
10
+ */
11
+ density: Density;
12
+ }
13
+ declare module '../../core/defaults/context.js' {
14
+ interface ComponentDefaults {
15
+ Accordion: AccordionDefaults;
16
+ }
17
+ }
18
+ /** One section: a header that opens and closes it, and the panel the `panel` snippet renders. */
19
+ export interface AccordionItem<T extends string | number = string> {
20
+ /** Identifies the section in `value`. */
21
+ value: T;
22
+ /** The header's text. */
23
+ title: string;
24
+ /** Its header no longer opens or closes it; it keeps its state. */
25
+ disabled?: boolean;
26
+ }
27
+ /** One section open at a time: opening one closes the others. */
28
+ interface Single<T> {
29
+ /**
30
+ * Lets several sections be open at once; `value` is then a list.
31
+ * @default false
32
+ */
33
+ multiple?: false;
34
+ /** The open section, or `null` when all are closed. Bindable. */
35
+ value?: T | null;
36
+ /** Called with the new value when the user opens or closes a section. */
37
+ onchange?: (value: T | null) => void;
38
+ }
39
+ /** Any number of sections open. */
40
+ interface Multiple<T> {
41
+ multiple: true;
42
+ /** The open sections. Bindable. */
43
+ value?: T[];
44
+ /** Called with the new value when the user opens or closes a section. */
45
+ onchange?: (value: T[]) => void;
46
+ }
47
+ /**
48
+ * Sections that open and close under their headers, built on the platform's `<details>`: they
49
+ * work before scripts load, and finding text in the page opens the section it is in. One opens
50
+ * at a time unless `multiple` is set. `class`, `style`, `ref` and other attributes go to the
51
+ * root element.
52
+ *
53
+ * @example
54
+ * <Accordion items={[{ value: 'fees', title: 'Fees' }, { value: 'limits', title: 'Limits' }]} bind:value={open}>
55
+ * {#snippet panel(item)}{answers[item.value]}{/snippet}
56
+ * </Accordion>
57
+ */
58
+ export type AccordionProps<T extends string | number = string> = ElementProps<HTMLDivElement, HTMLAttributes<HTMLDivElement>, Partial<AccordionDefaults> & {
59
+ /** The sections, in order. */
60
+ items: readonly AccordionItem<T>[];
61
+ /** The content of a section, under its header. */
62
+ panel: Snippet<[AccordionItem<T>]>;
63
+ } & (Single<T> | Multiple<T>)>;
64
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ <script lang="ts">
2
+ // A modal dialog in the middle of the screen, on the shared modal shell. See DialogProps.
3
+ import { useDefaults } from '../../core/defaults/context.js';
4
+ import { sharedClasses } from '../../core/props/classes.js';
5
+ import { pop } from '../../primitives/motion/motion.js';
6
+ import Modal from '../Modal/Modal.svelte';
7
+ import type { DialogDefaults, DialogProps } from './types.js';
8
+
9
+ let {
10
+ size,
11
+ density,
12
+ rounded,
13
+ open = $bindable(false),
14
+ class: className,
15
+ ref = $bindable(null),
16
+ ...rest
17
+ }: DialogProps = $props();
18
+
19
+ const resolve = useDefaults<DialogDefaults>('Dialog', {
20
+ size: 'md',
21
+ density: 'default',
22
+ rounded: true
23
+ });
24
+ const p = $derived(resolve({ size, density, rounded }));
25
+ </script>
26
+
27
+ <Modal
28
+ {...rest}
29
+ component="Dialog"
30
+ placement="center"
31
+ panelClass={sharedClasses(p, 'Dialog')}
32
+ enter={pop}
33
+ class={['sv-dialog', className]}
34
+ bind:open
35
+ bind:ref
36
+ />
@@ -0,0 +1,4 @@
1
+ import type { DialogProps } from './types.js';
2
+ declare const Dialog: import("svelte").Component<DialogProps, {}, "ref" | "open">;
3
+ type Dialog = ReturnType<typeof Dialog>;
4
+ export default Dialog;
@@ -0,0 +1,2 @@
1
+ export { default as Dialog } from './Dialog.svelte';
2
+ export type { DialogDefaults, DialogProps } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as Dialog } from './Dialog.svelte';
@@ -0,0 +1,50 @@
1
+ import type { HTMLDialogAttributes } from 'svelte/elements';
2
+ import type { ElementProps } from '../../core/props/element.js';
3
+ import type { Density, Rounded, Size } from '../../core/props/types.js';
4
+ import type { ModalContentProps } from '../Modal/types.js';
5
+ /** The Dialog props that `DefaultsProvider` and `UIProvider` can set for a subtree. */
6
+ export interface DialogDefaults {
7
+ /**
8
+ * The width of the panel, from `xs` (21rem) to `xl` (55rem).
9
+ * @default 'md'
10
+ */
11
+ size: Size;
12
+ /**
13
+ * Multiplies the padding, never the text.
14
+ * @default 'default'
15
+ */
16
+ density: Density;
17
+ /**
18
+ * Corner rounding. `true` keeps the theme's radius for large surfaces.
19
+ * @default true
20
+ */
21
+ rounded: Rounded;
22
+ }
23
+ declare module '../../core/defaults/context.js' {
24
+ interface ComponentDefaults {
25
+ Dialog: DialogDefaults;
26
+ }
27
+ }
28
+ /**
29
+ * A modal dialog: the platform's own `<dialog>`, in the top layer, with the rest of the page
30
+ * inert and still. Focus moves into it and returns to the control that opened it; Escape and a
31
+ * press on the backdrop close it unless it is `persistent`. `class`, `style`, `ref` and other
32
+ * attributes go to the `<dialog>`.
33
+ *
34
+ * @example
35
+ * <Dialog title="Delete this card?" bind:open>
36
+ * {#snippet trigger(attributes)}<Button {...attributes} color="error">Delete</Button>{/snippet}
37
+ * The card stops working at once. Payments already made are kept.
38
+ * {#snippet actions({ close })}
39
+ * <Button variant="text" onclick={close}>Keep it</Button>
40
+ * <Button color="error" onclick={remove}>Delete</Button>
41
+ * {/snippet}
42
+ * </Dialog>
43
+ */
44
+ export type DialogProps = ElementProps<HTMLDialogElement, Omit<HTMLDialogAttributes, 'open' | 'title' | 'children'>, Partial<DialogDefaults> & ModalContentProps & {
45
+ /**
46
+ * The panel fills the screen, as on a phone.
47
+ * @default false
48
+ */
49
+ fullscreen?: boolean;
50
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,195 @@
1
+ <script lang="ts">
2
+ // A list of actions that opens from a control. See MenuProps.
3
+ import { createAttachmentKey, type Attachment } from 'svelte/attachments';
4
+ import { checkValue, DEV } 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 { rovingFocus } from '../../primitives/focus/roving-focus.js';
10
+ import { typeahead } from '../../primitives/keyboard/typeahead.js';
11
+ import { dismissable } from '../../primitives/layer/dismissable.js';
12
+ import { pop } from '../../primitives/motion/motion.js';
13
+ import './menu.css';
14
+ import type { MenuAction, MenuDefaults, MenuProps } from './types.js';
15
+
16
+ let {
17
+ density,
18
+ items,
19
+ onselect,
20
+ trigger,
21
+ open = $bindable(false),
22
+ side = 'bottom',
23
+ align = 'start',
24
+ onclose,
25
+ id: idProp,
26
+ class: className,
27
+ ref = $bindable(null),
28
+ ...rest
29
+ }: MenuProps = $props();
30
+
31
+ const resolve = useDefaults<MenuDefaults>('Menu', { density: 'default' });
32
+ const p = $derived(resolve({ density }));
33
+
34
+ if (DEV) {
35
+ $effect(() => checkValue('Menu', 'side', side, SIDES));
36
+ $effect(() => checkValue('Menu', 'align', align, ALIGNS));
37
+ }
38
+
39
+ const uid = $props.id();
40
+ const id = $derived(idProp ?? uid);
41
+
42
+ // Runs of actions. A heading starts a named group, which a separator or the next heading ends.
43
+ interface Group {
44
+ heading?: string;
45
+ actions: MenuAction[];
46
+ }
47
+ const blocks = $derived.by(() => {
48
+ const list: Array<Group | 'separator'> = [];
49
+ let group: Group | undefined;
50
+ for (const item of items) {
51
+ if ('separator' in item) {
52
+ list.push('separator');
53
+ group = undefined;
54
+ } else if ('heading' in item) {
55
+ group = { heading: item.heading, actions: [] };
56
+ list.push(group);
57
+ } else {
58
+ if (!group) list.push((group = { actions: [] }));
59
+ group.actions.push(item);
60
+ }
61
+ }
62
+ return list;
63
+ });
64
+
65
+ // The control that opens it: the menu is placed against it, named by it, and gives focus back.
66
+ let anchor = $state<HTMLElement | null>(null);
67
+ const capture: Attachment<HTMLElement> = (node) => {
68
+ node.id ||= `${uid}-trigger`;
69
+ anchor = node;
70
+ return () => {
71
+ if (anchor === node) anchor = null;
72
+ };
73
+ };
74
+
75
+ // Which item takes focus as the menu opens: the last after ArrowUp, else the first.
76
+ let from: 'first' | 'last' = 'first';
77
+ function show(start: 'first' | 'last') {
78
+ from = start;
79
+ if (open && ref) focusEnd(ref);
80
+ open = true;
81
+ }
82
+
83
+ const ANCHOR = createAttachmentKey();
84
+ const triggerAttributes: TriggerAttributes = $derived({
85
+ 'aria-haspopup': 'menu',
86
+ 'aria-expanded': open,
87
+ 'aria-controls': open ? id : undefined,
88
+ onclick: () => (open ? close('action') : show('first')),
89
+ onkeydown: (event) => {
90
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
91
+ event.preventDefault();
92
+ show(event.key === 'ArrowUp' ? 'last' : 'first');
93
+ },
94
+ [ANCHOR]: capture
95
+ });
96
+
97
+ function close(reason: CloseReason) {
98
+ if (!open) return;
99
+ // Escape and a choice give focus back to the control; Tab, a press or focus elsewhere
100
+ // have already chosen where it goes.
101
+ if ((reason === 'escape' || reason === 'action') && ref?.contains(document.activeElement)) {
102
+ anchor?.focus({ preventScroll: true });
103
+ }
104
+ open = false;
105
+ onclose?.(reason);
106
+ }
107
+
108
+ function choose(item: MenuAction) {
109
+ close('action');
110
+ onselect?.(item);
111
+ }
112
+
113
+ function focusEnd(menu: HTMLElement) {
114
+ const enabled = menu.querySelectorAll<HTMLElement>('[data-sv-item]:not(:disabled)');
115
+ (from === 'last' ? enabled[enabled.length - 1] : enabled[0])?.focus({ preventScroll: true });
116
+ }
117
+
118
+ const enter: Attachment<HTMLElement> = (menu) => {
119
+ menu.showPopover();
120
+ focusEnd(menu);
121
+ };
122
+
123
+ // Tab and Shift+Tab close the menu once focus has moved on: the menu turns inert as it fades,
124
+ // and the move must not start from there.
125
+ function tab(event: KeyboardEvent) {
126
+ if (event.key === 'Tab') setTimeout(() => close('focus'));
127
+ }
128
+
129
+ // The pointer and the keyboard share one highlight: the item under the pointer takes focus.
130
+ function follow(event: PointerEvent & { currentTarget: HTMLButtonElement }) {
131
+ if (document.activeElement !== event.currentTarget) {
132
+ event.currentTarget.focus({ preventScroll: true });
133
+ }
134
+ }
135
+ </script>
136
+
137
+ {#snippet action(item: MenuAction)}
138
+ <button
139
+ type="button"
140
+ role="menuitem"
141
+ tabindex="-1"
142
+ disabled={item.disabled}
143
+ data-sv-item
144
+ data-sv-label={item.label}
145
+ class={['sv-menu__item', item.danger && 'sv-menu__item--danger']}
146
+ onclick={() => choose(item)}
147
+ onpointermove={follow}
148
+ >
149
+ {#if item.icon}
150
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d={item.icon} /></svg>
151
+ {/if}
152
+ <span class="sv-menu__label">{item.label}</span>
153
+ {#if item.shortcut}<kbd class="sv-menu__shortcut">{item.shortcut}</kbd>{/if}
154
+ </button>
155
+ {/snippet}
156
+
157
+ {@render trigger(triggerAttributes)}
158
+
159
+ {#if open}
160
+ <div
161
+ bind:this={ref}
162
+ aria-labelledby={anchor?.id}
163
+ tabindex="-1"
164
+ {...rest}
165
+ {id}
166
+ role="menu"
167
+ popover="manual"
168
+ class={['sv-menu', sharedClasses(p, 'Menu'), className]}
169
+ {@attach anchored({ anchor, side, align })}
170
+ {@attach dismissable({ onDismiss: close, inside: () => [anchor], focusOutside: true })}
171
+ {@attach rovingFocus({ orientation: 'vertical' })}
172
+ {@attach typeahead()}
173
+ {@attach enter}
174
+ onkeydown={tab}
175
+ transition:pop
176
+ >
177
+ {#each blocks as block, index (index)}
178
+ {#if block === 'separator'}
179
+ <div role="separator" class="sv-menu__separator"></div>
180
+ {:else}
181
+ <div
182
+ role={block.heading ? 'group' : 'none'}
183
+ aria-labelledby={block.heading ? `${id}-${index}` : undefined}
184
+ >
185
+ {#if block.heading}
186
+ <div role="presentation" id="{id}-{index}" class="sv-menu__heading">
187
+ {block.heading}
188
+ </div>
189
+ {/if}
190
+ {#each block.actions as item, i (i)}{@render action(item)}{/each}
191
+ </div>
192
+ {/if}
193
+ {/each}
194
+ </div>
195
+ {/if}
@@ -0,0 +1,4 @@
1
+ import type { MenuProps } from './types.js';
2
+ declare const Menu: import("svelte").Component<MenuProps, {}, "ref" | "open">;
3
+ type Menu = ReturnType<typeof Menu>;
4
+ export default Menu;
@@ -0,0 +1,2 @@
1
+ export { default as Menu } from './Menu.svelte';
2
+ export type { MenuAction, MenuDefaults, MenuHeading, MenuItem, MenuProps, MenuSeparator } from './types.js';
@@ -0,0 +1 @@
1
+ export { default as Menu } from './Menu.svelte';
@@ -0,0 +1,103 @@
1
+ @layer sv.tokens, sv.base, sv.components, sv.utilities;
2
+
3
+ @layer sv.components {
4
+ /* A raised surface of rows, as wide as its longest item within a short line. */
5
+ .sv-menu {
6
+ --sv-menu-pad: var(--sv-space-5);
7
+ box-sizing: border-box;
8
+ display: flex;
9
+ flex-direction: column;
10
+ min-inline-size: 13rem;
11
+ max-inline-size: min(21rem, 100vw - 2 * var(--sv-space-8));
12
+ max-block-size: calc(100dvh - 2 * var(--sv-space-8));
13
+ padding: var(--sv-menu-pad);
14
+ overflow: auto;
15
+ overscroll-behavior: contain;
16
+ border: var(--sv-hairline) solid var(--sv-color-border-soft);
17
+ border-radius: var(--sv-radius-lg);
18
+ outline: none;
19
+ background-color: var(--sv-color-surface);
20
+ box-shadow: var(--sv-shadow);
21
+ color: var(--sv-color-text);
22
+ font-family: var(--sv-font-ui);
23
+ font-size: var(--sv-text-sm);
24
+ line-height: var(--sv-leading-snug);
25
+ }
26
+
27
+ /* A row a small control tall, rounded to sit inside the menu's corner. */
28
+ .sv-menu__item {
29
+ display: flex;
30
+ gap: var(--sv-space-13);
31
+ align-items: center;
32
+ box-sizing: border-box;
33
+ inline-size: 100%;
34
+ min-block-size: calc(var(--sv-control-sm) * var(--sv-density));
35
+ margin: 0;
36
+ padding: calc(var(--sv-space-5) * var(--sv-density)) var(--sv-space-8);
37
+ border: 0;
38
+ border-radius: max(0px, var(--sv-radius-lg) - var(--sv-menu-pad));
39
+ outline: none;
40
+ background: transparent;
41
+ color: inherit;
42
+ font: inherit;
43
+ text-align: start;
44
+ cursor: pointer;
45
+ -webkit-tap-highlight-color: transparent;
46
+ }
47
+
48
+ /* The pointer focuses the item under it, so one highlight serves both. */
49
+ .sv-menu__item:focus {
50
+ background-color: color-mix(in oklch, currentColor var(--sv-state-press), transparent);
51
+ }
52
+
53
+ .sv-menu__item:disabled {
54
+ opacity: 0.382;
55
+ cursor: not-allowed;
56
+ }
57
+
58
+ .sv-menu__item--danger {
59
+ color: var(--sv-color-error);
60
+ }
61
+
62
+ .sv-menu__item svg {
63
+ flex: none;
64
+ inline-size: 1.272em;
65
+ block-size: 1.272em;
66
+ }
67
+
68
+ .sv-menu__label {
69
+ flex: 1;
70
+ min-inline-size: 0;
71
+ overflow-wrap: anywhere;
72
+ }
73
+
74
+ .sv-menu__shortcut {
75
+ margin-inline-start: auto;
76
+ color: var(--sv-color-text-muted);
77
+ font: inherit;
78
+ font-size: var(--sv-text-xs);
79
+ font-variant-numeric: tabular-nums;
80
+ }
81
+
82
+ .sv-menu__item--danger .sv-menu__shortcut {
83
+ color: inherit;
84
+ }
85
+
86
+ .sv-menu__separator {
87
+ flex: none;
88
+ block-size: var(--sv-hairline);
89
+ margin-block: var(--sv-menu-pad);
90
+ margin-inline: calc(-1 * var(--sv-menu-pad));
91
+ background-color: var(--sv-color-border-soft);
92
+ }
93
+
94
+ /* The theme's label voice, muted, above the items it names. */
95
+ .sv-menu__heading {
96
+ padding: var(--sv-space-8) var(--sv-space-8) var(--sv-space-3);
97
+ color: var(--sv-color-text-muted);
98
+ font-size: var(--sv-label-size);
99
+ font-weight: var(--sv-weight-medium);
100
+ letter-spacing: var(--sv-label-tracking);
101
+ text-transform: var(--sv-label-transform);
102
+ }
103
+ }