@ruckc/truss-ui 0.0.0-stage → 1.0.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.
Files changed (155) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +91 -2
  3. package/dist/color.d.ts +21 -0
  4. package/dist/color.js +32 -0
  5. package/dist/components/Accordion.svelte +70 -0
  6. package/dist/components/Accordion.svelte.d.ts +15 -0
  7. package/dist/components/Alert.svelte +46 -0
  8. package/dist/components/Alert.svelte.d.ts +15 -0
  9. package/dist/components/Avatar.svelte +32 -0
  10. package/dist/components/Avatar.svelte.d.ts +9 -0
  11. package/dist/components/AvatarGroup.svelte +20 -0
  12. package/dist/components/AvatarGroup.svelte.d.ts +11 -0
  13. package/dist/components/Badge.svelte +27 -0
  14. package/dist/components/Badge.svelte.d.ts +9 -0
  15. package/dist/components/Breadcrumbs.svelte +38 -0
  16. package/dist/components/Breadcrumbs.svelte.d.ts +12 -0
  17. package/dist/components/Button.svelte +119 -0
  18. package/dist/components/Button.svelte.d.ts +16 -0
  19. package/dist/components/ButtonGroup.svelte +23 -0
  20. package/dist/components/ButtonGroup.svelte.d.ts +9 -0
  21. package/dist/components/Calendar.svelte +166 -0
  22. package/dist/components/Calendar.svelte.d.ts +19 -0
  23. package/dist/components/Callout.svelte +50 -0
  24. package/dist/components/Callout.svelte.d.ts +14 -0
  25. package/dist/components/Card.svelte +50 -0
  26. package/dist/components/Card.svelte.d.ts +15 -0
  27. package/dist/components/Checkbox.svelte +37 -0
  28. package/dist/components/Checkbox.svelte.d.ts +16 -0
  29. package/dist/components/ColorPicker.svelte +164 -0
  30. package/dist/components/ColorPicker.svelte.d.ts +14 -0
  31. package/dist/components/Combobox.svelte +131 -0
  32. package/dist/components/Combobox.svelte.d.ts +19 -0
  33. package/dist/components/ContextMenu.svelte +51 -0
  34. package/dist/components/ContextMenu.svelte.d.ts +10 -0
  35. package/dist/components/DataTable.svelte +657 -0
  36. package/dist/components/DataTable.svelte.d.ts +76 -0
  37. package/dist/components/DatePicker.svelte +70 -0
  38. package/dist/components/DatePicker.svelte.d.ts +15 -0
  39. package/dist/components/DateRangePicker.svelte +92 -0
  40. package/dist/components/DateRangePicker.svelte.d.ts +15 -0
  41. package/dist/components/DateTimePicker.svelte +62 -0
  42. package/dist/components/DateTimePicker.svelte.d.ts +19 -0
  43. package/dist/components/DateTimeRangePicker.svelte +167 -0
  44. package/dist/components/DateTimeRangePicker.svelte.d.ts +22 -0
  45. package/dist/components/Dialog.svelte +70 -0
  46. package/dist/components/Dialog.svelte.d.ts +14 -0
  47. package/dist/components/Divider.svelte +16 -0
  48. package/dist/components/Divider.svelte.d.ts +8 -0
  49. package/dist/components/Drawer.svelte +64 -0
  50. package/dist/components/Drawer.svelte.d.ts +13 -0
  51. package/dist/components/DurationInput.svelte +112 -0
  52. package/dist/components/DurationInput.svelte.d.ts +19 -0
  53. package/dist/components/EditableText.svelte +73 -0
  54. package/dist/components/EditableText.svelte.d.ts +14 -0
  55. package/dist/components/EmptyState.svelte +30 -0
  56. package/dist/components/EmptyState.svelte.d.ts +12 -0
  57. package/dist/components/FileUpload.svelte +143 -0
  58. package/dist/components/FileUpload.svelte.d.ts +18 -0
  59. package/dist/components/FormGroup.svelte +27 -0
  60. package/dist/components/FormGroup.svelte.d.ts +13 -0
  61. package/dist/components/Input.svelte +62 -0
  62. package/dist/components/Input.svelte.d.ts +15 -0
  63. package/dist/components/InputAddon.svelte +21 -0
  64. package/dist/components/InputAddon.svelte.d.ts +9 -0
  65. package/dist/components/InputGroup.svelte +70 -0
  66. package/dist/components/InputGroup.svelte.d.ts +16 -0
  67. package/dist/components/Menu.svelte +95 -0
  68. package/dist/components/Menu.svelte.d.ts +15 -0
  69. package/dist/components/MenuButton.svelte +45 -0
  70. package/dist/components/MenuButton.svelte.d.ts +16 -0
  71. package/dist/components/MultiSelect.svelte +129 -0
  72. package/dist/components/MultiSelect.svelte.d.ts +18 -0
  73. package/dist/components/Navbar.svelte +17 -0
  74. package/dist/components/Navbar.svelte.d.ts +10 -0
  75. package/dist/components/NumberInput.svelte +66 -0
  76. package/dist/components/NumberInput.svelte.d.ts +16 -0
  77. package/dist/components/OptionList.svelte +74 -0
  78. package/dist/components/OptionList.svelte.d.ts +15 -0
  79. package/dist/components/Popover.svelte +87 -0
  80. package/dist/components/Popover.svelte.d.ts +14 -0
  81. package/dist/components/ProgressBar.svelte +35 -0
  82. package/dist/components/ProgressBar.svelte.d.ts +10 -0
  83. package/dist/components/RadioGroup.svelte +39 -0
  84. package/dist/components/RadioGroup.svelte.d.ts +14 -0
  85. package/dist/components/Section.svelte +56 -0
  86. package/dist/components/Section.svelte.d.ts +15 -0
  87. package/dist/components/SegmentedControl.svelte +56 -0
  88. package/dist/components/SegmentedControl.svelte.d.ts +17 -0
  89. package/dist/components/Select.svelte +204 -0
  90. package/dist/components/Select.svelte.d.ts +41 -0
  91. package/dist/components/Shell.svelte +143 -0
  92. package/dist/components/Shell.svelte.d.ts +36 -0
  93. package/dist/components/Sidebar.svelte +84 -0
  94. package/dist/components/Sidebar.svelte.d.ts +24 -0
  95. package/dist/components/Skeleton.svelte +25 -0
  96. package/dist/components/Skeleton.svelte.d.ts +9 -0
  97. package/dist/components/Slider.svelte +121 -0
  98. package/dist/components/Slider.svelte.d.ts +16 -0
  99. package/dist/components/Spinner.svelte +30 -0
  100. package/dist/components/Spinner.svelte.d.ts +9 -0
  101. package/dist/components/SplitButton.svelte +113 -0
  102. package/dist/components/SplitButton.svelte.d.ts +14 -0
  103. package/dist/components/SplitPane.svelte +106 -0
  104. package/dist/components/SplitPane.svelte.d.ts +16 -0
  105. package/dist/components/Stepper.svelte +73 -0
  106. package/dist/components/Stepper.svelte.d.ts +26 -0
  107. package/dist/components/Switch.svelte +21 -0
  108. package/dist/components/Switch.svelte.d.ts +10 -0
  109. package/dist/components/Tabs.svelte +74 -0
  110. package/dist/components/Tabs.svelte.d.ts +18 -0
  111. package/dist/components/Tag.svelte +49 -0
  112. package/dist/components/Tag.svelte.d.ts +16 -0
  113. package/dist/components/TagInput.svelte +72 -0
  114. package/dist/components/TagInput.svelte.d.ts +17 -0
  115. package/dist/components/Textarea.svelte +37 -0
  116. package/dist/components/Textarea.svelte.d.ts +11 -0
  117. package/dist/components/TimeList.svelte +76 -0
  118. package/dist/components/TimeList.svelte.d.ts +10 -0
  119. package/dist/components/TimePicker.svelte +79 -0
  120. package/dist/components/TimePicker.svelte.d.ts +15 -0
  121. package/dist/components/TimeRangePicker.svelte +44 -0
  122. package/dist/components/TimeRangePicker.svelte.d.ts +17 -0
  123. package/dist/components/Timeline.svelte +38 -0
  124. package/dist/components/Timeline.svelte.d.ts +17 -0
  125. package/dist/components/Toaster.svelte +35 -0
  126. package/dist/components/Toaster.svelte.d.ts +18 -0
  127. package/dist/components/Tooltip.svelte +66 -0
  128. package/dist/components/Tooltip.svelte.d.ts +11 -0
  129. package/dist/components/Tree.svelte +147 -0
  130. package/dist/components/Tree.svelte.d.ts +16 -0
  131. package/dist/date.d.ts +19 -0
  132. package/dist/date.js +43 -0
  133. package/dist/duration.d.ts +11 -0
  134. package/dist/duration.js +44 -0
  135. package/dist/fonts/LICENSE-Inter.txt +93 -0
  136. package/dist/fonts/LICENSE-JetBrainsMono.txt +93 -0
  137. package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
  138. package/dist/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  139. package/dist/icons/Icon.svelte +30 -0
  140. package/dist/icons/Icon.svelte.d.ts +9 -0
  141. package/dist/icons/paths.d.ts +39 -0
  142. package/dist/icons/paths.js +43 -0
  143. package/dist/index.d.ts +69 -0
  144. package/dist/index.js +65 -0
  145. package/dist/styles/base.css +71 -0
  146. package/dist/styles/fonts.css +21 -0
  147. package/dist/styles/index.css +3 -0
  148. package/dist/styles/tokens.css +143 -0
  149. package/dist/time.d.ts +7 -0
  150. package/dist/time.js +30 -0
  151. package/dist/toast.svelte.d.ts +22 -0
  152. package/dist/toast.svelte.js +19 -0
  153. package/dist/types.d.ts +70 -0
  154. package/dist/types.js +1 -0
  155. package/package.json +76 -5
@@ -0,0 +1,70 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+
4
+ /**
5
+ * Joins controls into one seamless unit: shared 1px borders, corner radii only on the outer ends, focus raised above
6
+ * neighbours. Put almost any input inside — Input, NumberInput, Select, Combobox, MultiSelect, TagInput, Textarea,
7
+ * DatePicker, TimePicker, DateRangePicker, DateTimePicker, DurationInput, ColorPicker, SegmentedControl, Button,
8
+ * SplitButton, MenuButton — plus <InputAddon> for static text or icons.
9
+ *
10
+ * Give the *group* the label/hint/error, not the individual controls (their own labels would break the alignment).
11
+ * Inputs share the free space; give a child a fixed width by wrapping it, e.g. `<div style="width: 120px">`.
12
+ */
13
+ let {
14
+ label,
15
+ hint,
16
+ error,
17
+ size = 'md',
18
+ vertical = false,
19
+ disabled = false,
20
+ children
21
+ }: {
22
+ label?: string;
23
+ hint?: string;
24
+ error?: string;
25
+ /** Sets the height of every default-sized control inside. */
26
+ size?: 'sm' | 'md' | 'lg';
27
+ /** Stack the controls instead of lining them up. */
28
+ vertical?: boolean;
29
+ /** Disables every control inside. */
30
+ disabled?: boolean;
31
+ children: Snippet;
32
+ } = $props();
33
+
34
+ const id = $props.id();
35
+ </script>
36
+
37
+ <div class="tr-field ig-wrap">
38
+ {#if label}<span class="tr-label" id="{id}-label">{label}</span>{/if}
39
+ <fieldset class="ig" {disabled} data-size={size} data-vertical={vertical} data-invalid={!!error} data-disabled={disabled} aria-labelledby={label ? `${id}-label` : undefined} aria-describedby={error || hint ? `${id}-msg` : undefined}>
40
+ {@render children()}
41
+ </fieldset>
42
+ {#if error || hint}<div class="tr-hint" id="{id}-msg" data-error={!!error}>{error ?? hint}</div>{/if}
43
+ </div>
44
+
45
+ <style>
46
+ .ig { display: flex; align-items: flex-start; min-width: 0; margin: 0; padding: 0; border: 0; }
47
+ .ig[data-vertical='true'] { flex-direction: column; align-items: stretch; }
48
+ .ig[data-size='sm'] { --tr-control-h: 24px; font-size: var(--tr-text-sm); }
49
+ .ig[data-size='lg'] { --tr-control-h: 40px; font-size: var(--tr-text-lg); }
50
+ .ig[data-disabled='true'] { opacity: 0.55; pointer-events: none; }
51
+
52
+ /* items: inputs share space, everything else is its natural size */
53
+ .ig > :global(*) { flex: 0 0 auto; min-width: 0; position: relative; }
54
+ .ig > :global(.tr-field), .ig > :global(.tr-grow) { flex: 1 1 0; }
55
+ .ig[data-vertical='true'] > :global(*) { flex: none; }
56
+
57
+ /* overlap the shared border by 1px, lift whatever has focus/hover so its ring and popovers sit on top */
58
+ .ig:not([data-vertical='true']) > :global(* + *) { margin-left: -1px; }
59
+ .ig[data-vertical='true'] > :global(* + *) { margin-top: -1px; }
60
+ .ig > :global(*:hover) { z-index: 1; }
61
+ .ig > :global(*:focus-within) { z-index: 3; }
62
+
63
+ /* square the inner corners of the visible boxes: a box is either inside the child (inputs, selects) or the child itself (buttons, addons) */
64
+ .ig:not([data-vertical='true']) > :global(*:not(:first-child) :where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg)), .ig:not([data-vertical='true']) > :global(:where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg):not(:first-child)) { border-top-left-radius: 0; border-bottom-left-radius: 0; }
65
+ .ig:not([data-vertical='true']) > :global(*:not(:last-child) :where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg)), .ig:not([data-vertical='true']) > :global(:where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg):not(:last-child)) { border-top-right-radius: 0; border-bottom-right-radius: 0; }
66
+ .ig[data-vertical='true'] > :global(*:not(:first-child) :where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg)), .ig[data-vertical='true'] > :global(:where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg):not(:first-child)) { border-top-left-radius: 0; border-top-right-radius: 0; }
67
+ .ig[data-vertical='true'] > :global(*:not(:last-child) :where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg)), .ig[data-vertical='true'] > :global(:where(.tr-control, .control, .trigger, .tr-btn, .addon, .seg):not(:last-child)) { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
68
+ /* group-level error colours every box */
69
+ .ig[data-invalid='true'] :global(:where(.tr-control, .control, .trigger, .addon)) { border-color: var(--tr-danger); }
70
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ label?: string;
4
+ hint?: string;
5
+ error?: string;
6
+ /** Sets the height of every default-sized control inside. */
7
+ size?: 'sm' | 'md' | 'lg';
8
+ /** Stack the controls instead of lining them up. */
9
+ vertical?: boolean;
10
+ /** Disables every control inside. */
11
+ disabled?: boolean;
12
+ children: Snippet;
13
+ };
14
+ declare const InputGroup: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type InputGroup = ReturnType<typeof InputGroup>;
16
+ export default InputGroup;
@@ -0,0 +1,95 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import Icon from '../icons/Icon.svelte';
4
+ import Menu from './Menu.svelte';
5
+ import type { MenuEntry, MenuItem } from '../types.js';
6
+
7
+ let {
8
+ items,
9
+ label = 'Menu',
10
+ autofocus = false,
11
+ onclose,
12
+ onback
13
+ }: {
14
+ items: MenuEntry[];
15
+ label?: string;
16
+ /** Focus the first item on mount (set when opened from a trigger). */
17
+ autofocus?: boolean;
18
+ /** Called after an item is chosen, so the owner can close the surface. */
19
+ onclose?: () => void;
20
+ /** Submenus: called on ArrowLeft to return focus to the parent. */
21
+ onback?: () => void;
22
+ } = $props();
23
+
24
+ let root: HTMLUListElement;
25
+ let openSub = $state<number | null>(null);
26
+
27
+ const buttons = () => (root ? [...root.querySelectorAll<HTMLButtonElement>(':scope > li > button:not(:disabled)')] : []);
28
+ $effect(() => { if (autofocus) tick().then(() => buttons()[0]?.focus()); });
29
+
30
+ const isItem = (e: MenuEntry): e is MenuItem => 'label' in e;
31
+
32
+ async function openSubmenu(i: number, focus: boolean) {
33
+ openSub = i;
34
+ if (focus) { await tick(); root?.querySelector<HTMLButtonElement>(`:scope > li:nth-child(${i + 1}) .sub [role^='menuitem']:not(:disabled)`)?.focus(); }
35
+ }
36
+ function choose(it: MenuItem, i: number) {
37
+ if (it.items) return openSubmenu(i, true);
38
+ it.onselect?.();
39
+ onclose?.();
40
+ }
41
+ function onKey(e: KeyboardEvent) {
42
+ const els = buttons();
43
+ const cur = document.activeElement as HTMLButtonElement;
44
+ const i = els.indexOf(cur);
45
+ if (i < 0) return; // key came from a nested submenu
46
+ const idx = Number(cur.dataset.i);
47
+ const to = e.key === 'ArrowDown' ? (i + 1) % els.length : e.key === 'ArrowUp' ? (i - 1 + els.length) % els.length : e.key === 'Home' ? 0 : e.key === 'End' ? els.length - 1 : -1;
48
+ if (to >= 0) { e.preventDefault(); openSub = null; els[to].focus(); }
49
+ else if (e.key === 'ArrowRight' && (items[idx] as MenuItem)?.items) { e.preventDefault(); openSubmenu(idx, true); }
50
+ else if (e.key === 'ArrowLeft' && onback) { e.preventDefault(); onback(); }
51
+ }
52
+ </script>
53
+
54
+ <ul class="menu" role="menu" aria-label={label} bind:this={root} onkeydown={onKey}>
55
+ {#each items as it, i}
56
+ {#if 'divider' in it}
57
+ <li role="separator" class="sep"></li>
58
+ {:else if 'heading' in it}
59
+ <li role="presentation" class="heading">{it.heading}</li>
60
+ {:else if isItem(it)}
61
+ <li role="none" onpointerenter={() => (openSub = it.items && !it.disabled ? i : null)}>
62
+ <button
63
+ type="button" data-i={i} role={it.checked != null ? 'menuitemcheckbox' : 'menuitem'} aria-checked={it.checked}
64
+ aria-haspopup={it.items ? 'menu' : undefined} aria-expanded={it.items ? openSub === i : undefined}
65
+ disabled={it.disabled} data-danger={it.danger} onclick={() => choose(it, i)}
66
+ >
67
+ <span class="lead">{#if it.checked}<Icon name="tick" />{:else if it.icon}<Icon name={it.icon} />{/if}</span>
68
+ <span class="lbl">{it.label}</span>
69
+ {#if it.shortcut}<kbd>{it.shortcut}</kbd>{/if}
70
+ {#if it.items}<Icon name="chevron-right" />{/if}
71
+ </button>
72
+ {#if it.items && openSub === i}
73
+ <div class="sub">
74
+ <Menu items={it.items} label={it.label} {onclose} onback={() => { openSub = null; root?.querySelector<HTMLButtonElement>(`[data-i="${i}"]`)?.focus(); }} />
75
+ </div>
76
+ {/if}
77
+ </li>
78
+ {/if}
79
+ {/each}
80
+ </ul>
81
+
82
+ <style>
83
+ .menu { margin: 0; padding: 4px; list-style: none; min-width: 180px; }
84
+ li { position: relative; }
85
+ button { display: flex; align-items: center; gap: 8px; width: 100%; height: var(--tr-control-h); padding: 0 8px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface); font: inherit; text-align: left; white-space: nowrap; cursor: pointer; }
86
+ button:hover:not(:disabled), button:focus-visible, button[aria-expanded='true'] { outline: none; background: var(--tr-surface-container-highest); }
87
+ button:disabled { opacity: 0.45; cursor: not-allowed; }
88
+ button[data-danger='true'] { color: var(--tr-danger); }
89
+ .lead { display: inline-flex; width: 16px; flex: none; }
90
+ .lbl { flex: 1; }
91
+ kbd { font: var(--tr-text-xs) var(--tr-font-mono); color: var(--tr-on-surface-variant); }
92
+ .sep { height: 1px; margin: 4px 0; background: var(--tr-outline-variant); }
93
+ .heading { padding: 6px 8px 2px; font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
94
+ .sub { position: absolute; top: -5px; left: calc(100% + 2px); z-index: 60; background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-2); }
95
+ </style>
@@ -0,0 +1,15 @@
1
+ import Menu from './Menu.svelte';
2
+ import type { MenuEntry } from '../types.js';
3
+ type $$ComponentProps = {
4
+ items: MenuEntry[];
5
+ label?: string;
6
+ /** Focus the first item on mount (set when opened from a trigger). */
7
+ autofocus?: boolean;
8
+ /** Called after an item is chosen, so the owner can close the surface. */
9
+ onclose?: () => void;
10
+ /** Submenus: called on ArrowLeft to return focus to the parent. */
11
+ onback?: () => void;
12
+ };
13
+ declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type Menu = ReturnType<typeof Menu>;
15
+ export default Menu;
@@ -0,0 +1,45 @@
1
+ <script lang="ts">
2
+ import Button from './Button.svelte';
3
+ import Menu from './Menu.svelte';
4
+ import Popover from './Popover.svelte';
5
+ import type { IconName } from '../icons/paths.js';
6
+ import type { MenuEntry } from '../types.js';
7
+
8
+ let {
9
+ label,
10
+ icon,
11
+ items,
12
+ variant = 'outlined',
13
+ intent = 'neutral',
14
+ size = 'md',
15
+ align = 'start',
16
+ iconOnly = false
17
+ }: {
18
+ label: string;
19
+ icon?: IconName;
20
+ items: MenuEntry[];
21
+ variant?: 'filled' | 'tonal' | 'outlined' | 'minimal';
22
+ intent?: 'primary' | 'neutral' | 'success' | 'warning' | 'danger';
23
+ size?: 'sm' | 'md' | 'lg';
24
+ align?: 'start' | 'end';
25
+ /** Show only the icon (label becomes the accessible name). */
26
+ iconOnly?: boolean;
27
+ } = $props();
28
+
29
+ let open = $state(false);
30
+ let host: HTMLDivElement;
31
+ const trigger = () => host.querySelector<HTMLButtonElement>('button');
32
+ </script>
33
+
34
+ <div class="tr-anchor mb" bind:this={host}>
35
+ {#if iconOnly}
36
+ <Button {variant} {intent} {size} icon={icon ?? 'more'} aria-label={label} aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)} />
37
+ {:else}
38
+ <Button {variant} {intent} {size} {icon} rightIcon="chevron-down" aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)}>{label}</Button>
39
+ {/if}
40
+ <Popover bind:open {align} onclose={(r) => r && trigger()?.focus()}>
41
+ <Menu {items} {label} autofocus onclose={() => { open = false; trigger()?.focus(); }} />
42
+ </Popover>
43
+ </div>
44
+
45
+ <style>.mb { display: inline-block; }</style>
@@ -0,0 +1,16 @@
1
+ import type { IconName } from '../icons/paths.js';
2
+ import type { MenuEntry } from '../types.js';
3
+ type $$ComponentProps = {
4
+ label: string;
5
+ icon?: IconName;
6
+ items: MenuEntry[];
7
+ variant?: 'filled' | 'tonal' | 'outlined' | 'minimal';
8
+ intent?: 'primary' | 'neutral' | 'success' | 'warning' | 'danger';
9
+ size?: 'sm' | 'md' | 'lg';
10
+ align?: 'start' | 'end';
11
+ /** Show only the icon (label becomes the accessible name). */
12
+ iconOnly?: boolean;
13
+ };
14
+ declare const MenuButton: import("svelte").Component<$$ComponentProps, {}, "">;
15
+ type MenuButton = ReturnType<typeof MenuButton>;
16
+ export default MenuButton;
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ import { SvelteMap } from 'svelte/reactivity';
3
+ import Icon from '../icons/Icon.svelte';
4
+ import Popover from './Popover.svelte';
5
+ import OptionList from './OptionList.svelte';
6
+ import type { Option } from '../types.js';
7
+
8
+ let {
9
+ options = [],
10
+ loadOptions,
11
+ value = $bindable([]),
12
+ label,
13
+ ariaLabel,
14
+ placeholder = 'Add…',
15
+ allowCreate = false,
16
+ disabled = false,
17
+ error,
18
+ debounce = 200
19
+ }: {
20
+ options?: Option[];
21
+ loadOptions?: (query: string, signal: AbortSignal) => Promise<Option[]>;
22
+ value?: string[];
23
+ label?: string;
24
+ /** Accessible name when no visible `label` is shown. */
25
+ ariaLabel?: string;
26
+ placeholder?: string;
27
+ /** Offer `Create "text"`; Enter or comma commits free text as a new tag. */
28
+ allowCreate?: boolean;
29
+ disabled?: boolean;
30
+ error?: string;
31
+ debounce?: number;
32
+ } = $props();
33
+
34
+ const id = $props.id();
35
+ let open = $state(false);
36
+ let text = $state('');
37
+ let active = $state(0);
38
+ let items = $state<Option[]>([]);
39
+ let loading = $state(false);
40
+ let input: HTMLInputElement;
41
+ const known = new SvelteMap<string, string>();
42
+ $effect(() => { for (const o of options) known.set(String(o.value), o.label); });
43
+
44
+ const q = $derived(text.trim());
45
+ const base = $derived(loadOptions ? items : options.filter((o) => o.label.toLowerCase().includes(q.toLowerCase())));
46
+ const canCreate = $derived(
47
+ allowCreate && !!q && !value.includes(q) && !base.some((o) => o.label.toLowerCase() === q.toLowerCase() || String(o.value) === q)
48
+ );
49
+ const shown = $derived<Option[]>(canCreate ? [...base, { value: q, label: `Create “${q}”`, icon: 'plus' }] : base);
50
+
51
+ let timer: ReturnType<typeof setTimeout>;
52
+ let ctrl: AbortController | undefined;
53
+ /** A typed query has not produced results yet: the list on screen is stale. */
54
+ let pending = $state(false);
55
+ $effect(() => () => { clearTimeout(timer); ctrl?.abort(); });
56
+ async function run(term: string) {
57
+ ctrl?.abort();
58
+ const c = (ctrl = new AbortController());
59
+ loading = true;
60
+ try {
61
+ const r = await loadOptions!(term, c.signal);
62
+ if (c.signal.aborted) return;
63
+ items = r;
64
+ for (const o of r) known.set(String(o.value), o.label);
65
+ } catch { if (!c.signal.aborted) items = []; }
66
+ finally { if (!c.signal.aborted) { loading = false; pending = false; } }
67
+ }
68
+
69
+ const toggle = (v: string) => (value = value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
70
+ function choose(o: Option) {
71
+ if (o.label.startsWith('Create “')) known.set(String(o.value), String(o.value));
72
+ toggle(String(o.value));
73
+ text = ''; active = 0;
74
+ if (loadOptions) run('');
75
+ input.focus();
76
+ }
77
+ function onInput() {
78
+ open = true; active = 0;
79
+ if (text.endsWith(',') && allowCreate) { text = text.slice(0, -1); if (q && !value.includes(q)) { value = [...value, q]; text = ''; } return; }
80
+ if (loadOptions) { pending = true; ctrl?.abort(); clearTimeout(timer); timer = setTimeout(() => run(q), debounce); }
81
+ }
82
+ function openList() { if (disabled) return; open = true; if (loadOptions && !q) run(''); }
83
+ function onKey(e: KeyboardEvent) {
84
+ const n = shown.length;
85
+ if (e.key === 'ArrowDown') { e.preventDefault(); open ? (active = (active + 1) % Math.max(n, 1)) : openList(); }
86
+ else if (e.key === 'ArrowUp') { e.preventDefault(); open ? (active = (active - 1 + n) % Math.max(n, 1)) : openList(); }
87
+ else if (e.key === 'Enter' && pending) {
88
+ e.preventDefault(); // results for what was typed haven't arrived — don't pick from the old list
89
+ if (allowCreate && q && !value.includes(q)) { known.set(q, q); value = [...value, q]; text = ''; }
90
+ }
91
+ else if (e.key === 'Enter' && open && shown[active]) { e.preventDefault(); choose(shown[active]); }
92
+ else if (e.key === 'Backspace' && !text && value.length) value = value.slice(0, -1);
93
+ }
94
+ </script>
95
+
96
+ <div class="tr-field">
97
+ {#if label}<label class="tr-label" for={id}>{label}</label>{/if}
98
+ <div class="tr-anchor">
99
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
100
+ <div class="tr-control multi" data-invalid={!!error} data-disabled={disabled} onclick={() => input.focus()}>
101
+ {#each value as v (v)}
102
+ <span class="chip">
103
+ {known.get(v) ?? v}
104
+ <button type="button" aria-label="Remove {known.get(v) ?? v}" {disabled} onclick={(e) => { e.stopPropagation(); toggle(v); }}><Icon name="cross" /></button>
105
+ </span>
106
+ {/each}
107
+ <input
108
+ class="tr-bare" {id} aria-label={label ? undefined : ariaLabel} bind:this={input} bind:value={text} placeholder={value.length ? '' : placeholder} {disabled} autocomplete="off"
109
+ role="combobox" aria-autocomplete="list" aria-expanded={open} aria-controls="{id}-list"
110
+ aria-activedescendant={open && shown.length ? `${id}-list-opt-${active}` : undefined}
111
+ onfocus={openList} onclick={openList} oninput={onInput} onkeydown={onKey}
112
+ />
113
+ </div>
114
+ <Popover bind:open>
115
+ <OptionList id="{id}-list" options={shown} {active} selected={value} query={q} {loading}
116
+ emptyText={allowCreate ? 'Type to create a tag' : 'No matches'} onchoose={choose} onhover={(i) => (active = i)} />
117
+ </Popover>
118
+ </div>
119
+ {#if error}<div class="tr-hint" data-error="true">{error}</div>{/if}
120
+ </div>
121
+
122
+ <style>
123
+ .multi { flex-wrap: wrap; padding: 3px 6px; gap: 4px; cursor: text; }
124
+ .multi .tr-bare { flex: 1 1 80px; height: 24px; }
125
+ .chip { display: inline-flex; align-items: center; gap: 2px; height: 24px; padding: 0 2px 0 8px; border-radius: var(--tr-radius-pill); background: var(--tr-primary-container); color: var(--tr-on-primary-container); font-size: var(--tr-text-sm); font-weight: 600; }
126
+ .chip button { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; }
127
+ .chip button:hover { background: color-mix(in srgb, currentColor 15%, transparent); }
128
+ .chip button:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
129
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Option } from '../types.js';
2
+ type $$ComponentProps = {
3
+ options?: Option[];
4
+ loadOptions?: (query: string, signal: AbortSignal) => Promise<Option[]>;
5
+ value?: string[];
6
+ label?: string;
7
+ /** Accessible name when no visible `label` is shown. */
8
+ ariaLabel?: string;
9
+ placeholder?: string;
10
+ /** Offer `Create "text"`; Enter or comma commits free text as a new tag. */
11
+ allowCreate?: boolean;
12
+ disabled?: boolean;
13
+ error?: string;
14
+ debounce?: number;
15
+ };
16
+ declare const MultiSelect: import("svelte").Component<$$ComponentProps, {}, "value">;
17
+ type MultiSelect = ReturnType<typeof MultiSelect>;
18
+ export default MultiSelect;
@@ -0,0 +1,17 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ let { start, center, end, sticky = true }: { start?: Snippet; center?: Snippet; end?: Snippet; sticky?: boolean } = $props();
4
+ </script>
5
+
6
+ <header class="nb" data-sticky={sticky}>
7
+ <div class="s">{#if start}{@render start()}{/if}</div>
8
+ <div class="c">{#if center}{@render center()}{/if}</div>
9
+ <div class="e">{#if end}{@render end()}{/if}</div>
10
+ </header>
11
+
12
+ <style>
13
+ .nb { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; height: 48px; padding: 0 12px; background: var(--tr-surface-container-low); border-bottom: 1px solid var(--tr-outline-variant); box-shadow: var(--tr-elevation-1); z-index: 20; }
14
+ .nb[data-sticky='true'] { position: sticky; top: 0; }
15
+ .s, .c, .e { display: flex; align-items: center; gap: 8px; min-width: 0; }
16
+ .e { justify-content: flex-end; }
17
+ </style>
@@ -0,0 +1,10 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ start?: Snippet;
4
+ center?: Snippet;
5
+ end?: Snippet;
6
+ sticky?: boolean;
7
+ };
8
+ declare const Navbar: import("svelte").Component<$$ComponentProps, {}, "">;
9
+ type Navbar = ReturnType<typeof Navbar>;
10
+ export default Navbar;
@@ -0,0 +1,66 @@
1
+ <script lang="ts">
2
+ import Button from './Button.svelte';
3
+
4
+ let {
5
+ value = $bindable(null),
6
+ min,
7
+ max,
8
+ step = 1,
9
+ label,
10
+ ariaLabel,
11
+ hint,
12
+ error,
13
+ placeholder,
14
+ disabled = false
15
+ }: {
16
+ value?: number | null;
17
+ min?: number;
18
+ max?: number;
19
+ step?: number;
20
+ label?: string;
21
+ /** Accessible name when no visible `label` is shown. */
22
+ ariaLabel?: string;
23
+ hint?: string;
24
+ error?: string;
25
+ placeholder?: string;
26
+ disabled?: boolean;
27
+ } = $props();
28
+
29
+ const id = $props.id();
30
+ const decimals = $derived(String(step).split('.')[1]?.length ?? 0);
31
+ const norm = (n: number) => {
32
+ const c = Math.min(max ?? Infinity, Math.max(min ?? -Infinity, n));
33
+ return Number(c.toFixed(decimals));
34
+ };
35
+ let text = $state('');
36
+ $effect(() => { text = value == null ? '' : String(value); });
37
+
38
+ function commit() {
39
+ const t = text.trim().replace(/,/g, '');
40
+ if (!t) { value = null; return; }
41
+ const n = Number(t);
42
+ if (Number.isNaN(n)) { text = value == null ? '' : String(value); return; }
43
+ value = norm(n);
44
+ text = String(value);
45
+ }
46
+ const bump = (dir: 1 | -1) => (value = norm((value ?? 0) + dir * step));
47
+ </script>
48
+
49
+ <div class="tr-field">
50
+ {#if label}<label class="tr-label" for={id}>{label}</label>{/if}
51
+ <div class="tr-control" data-trail="true" data-invalid={!!error} data-disabled={disabled}>
52
+ <input
53
+ class="tr-bare" {id} aria-label={label ? undefined : ariaLabel} bind:value={text} {placeholder} {disabled} inputmode="decimal" autocomplete="off"
54
+ role="spinbutton" aria-valuenow={value ?? undefined} aria-valuemin={min} aria-valuemax={max}
55
+ onblur={commit}
56
+ onkeydown={(e) => {
57
+ if (e.key === 'ArrowUp') { e.preventDefault(); bump(1); }
58
+ else if (e.key === 'ArrowDown') { e.preventDefault(); bump(-1); }
59
+ else if (e.key === 'Enter') commit();
60
+ }}
61
+ />
62
+ <Button variant="minimal" intent="neutral" size="sm" icon="minus" aria-label="Decrease" tabindex={-1} disabled={disabled || (min != null && (value ?? 0) <= min)} onclick={() => bump(-1)} />
63
+ <Button variant="minimal" intent="neutral" size="sm" icon="plus" aria-label="Increase" tabindex={-1} disabled={disabled || (max != null && (value ?? 0) >= max)} onclick={() => bump(1)} />
64
+ </div>
65
+ {#if error || hint}<div class="tr-hint" data-error={!!error}>{error ?? hint}</div>{/if}
66
+ </div>
@@ -0,0 +1,16 @@
1
+ type $$ComponentProps = {
2
+ value?: number | null;
3
+ min?: number;
4
+ max?: number;
5
+ step?: number;
6
+ label?: string;
7
+ /** Accessible name when no visible `label` is shown. */
8
+ ariaLabel?: string;
9
+ hint?: string;
10
+ error?: string;
11
+ placeholder?: string;
12
+ disabled?: boolean;
13
+ };
14
+ declare const NumberInput: import("svelte").Component<$$ComponentProps, {}, "value">;
15
+ type NumberInput = ReturnType<typeof NumberInput>;
16
+ export default NumberInput;
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import Icon from '../icons/Icon.svelte';
3
+ import type { Option } from '../types.js';
4
+
5
+ let {
6
+ id,
7
+ options,
8
+ active = 0,
9
+ selected = [],
10
+ query = '',
11
+ loading = false,
12
+ emptyText = 'No matches',
13
+ onchoose,
14
+ onhover
15
+ }: {
16
+ id: string;
17
+ options: Option[];
18
+ active?: number;
19
+ selected?: string[];
20
+ query?: string;
21
+ loading?: boolean;
22
+ emptyText?: string;
23
+ onchoose: (o: Option) => void;
24
+ onhover?: (i: number) => void;
25
+ } = $props();
26
+
27
+ function parts(label: string, q: string): [string, string, string] {
28
+ const i = q ? label.toLowerCase().indexOf(q.toLowerCase()) : -1;
29
+ return i < 0 ? [label, '', ''] : [label.slice(0, i), label.slice(i, i + q.length), label.slice(i + q.length)];
30
+ }
31
+
32
+ $effect(() => {
33
+ options; // re-run when the list changes
34
+ document.getElementById(`${id}-opt-${active}`)?.scrollIntoView({ block: 'nearest' });
35
+ });
36
+ </script>
37
+
38
+ <ul {id} role="listbox" aria-busy={loading || undefined}>
39
+ {#each options as o, i (o.value)}
40
+ {@const [pre, hit, post] = parts(o.label, query)}
41
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
42
+ <li
43
+ id="{id}-opt-{i}"
44
+ role="option"
45
+ aria-selected={selected.includes(String(o.value))}
46
+ aria-disabled={o.disabled}
47
+ data-active={i === active}
48
+ onpointerdown={(e) => e.preventDefault()}
49
+ onpointermove={() => onhover?.(i)}
50
+ onclick={() => !o.disabled && onchoose(o)}
51
+ >
52
+ {#if o.icon}<Icon name={o.icon} />{/if}
53
+ <span class="text">
54
+ <span>{pre}{#if hit}<mark>{hit}</mark>{/if}{post}</span>
55
+ {#if o.description}<small>{o.description}</small>{/if}
56
+ </span>
57
+ {#if selected.includes(String(o.value))}<Icon name="tick" />{/if}
58
+ </li>
59
+ {:else}
60
+ <li class="empty" role="presentation">{loading ? 'Loading…' : emptyText}</li>
61
+ {/each}
62
+ </ul>
63
+
64
+ <style>
65
+ ul { margin: 0; padding: 4px; list-style: none; max-height: 240px; overflow-y: auto; }
66
+ li { display: flex; align-items: center; gap: 8px; min-height: var(--tr-control-h); padding: 0 8px; border-radius: var(--tr-radius-sm); cursor: pointer; }
67
+ .text { flex: 1; display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
68
+ small { color: var(--tr-on-surface-variant); font-size: var(--tr-text-xs); }
69
+ mark { background: none; color: var(--tr-primary); font-weight: 700; }
70
+ li[data-active='true'] { background: var(--tr-surface-container-highest); }
71
+ li[aria-selected='true'] { font-weight: 600; }
72
+ li[aria-disabled='true'] { opacity: 0.45; cursor: not-allowed; }
73
+ li.empty { color: var(--tr-on-surface-variant); cursor: default; }
74
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Option } from '../types.js';
2
+ type $$ComponentProps = {
3
+ id: string;
4
+ options: Option[];
5
+ active?: number;
6
+ selected?: string[];
7
+ query?: string;
8
+ loading?: boolean;
9
+ emptyText?: string;
10
+ onchoose: (o: Option) => void;
11
+ onhover?: (i: number) => void;
12
+ };
13
+ declare const OptionList: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type OptionList = ReturnType<typeof OptionList>;
15
+ export default OptionList;