terminoir 0.4.0 → 0.5.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 (98) hide show
  1. package/README.md +38 -28
  2. package/css/components.css +62 -0
  3. package/css/shell.css +32 -1
  4. package/dist/components/AccentSwatches.svelte +1 -1
  5. package/dist/components/AppShell.svelte +33 -5
  6. package/dist/components/AppShell.svelte.d.ts +10 -1
  7. package/dist/components/AppShellNavLink.svelte +2 -4
  8. package/dist/components/Button.svelte +32 -14
  9. package/dist/components/Button.svelte.d.ts +2 -0
  10. package/dist/components/CalendarGrid.svelte +245 -0
  11. package/dist/components/CalendarGrid.svelte.d.ts +12 -0
  12. package/dist/components/CanvasStage.svelte +81 -0
  13. package/dist/components/CanvasStage.svelte.d.ts +19 -0
  14. package/dist/components/Centered.svelte +36 -0
  15. package/dist/components/Centered.svelte.d.ts +10 -0
  16. package/dist/components/Checkbox.svelte +64 -12
  17. package/dist/components/Checkbox.svelte.d.ts +5 -0
  18. package/dist/components/Chip.svelte +90 -2
  19. package/dist/components/Chip.svelte.d.ts +8 -0
  20. package/dist/components/CommandPalette.svelte +178 -97
  21. package/dist/components/CommandPalette.svelte.d.ts +15 -8
  22. package/dist/components/ContextMenu.svelte +68 -0
  23. package/dist/components/ContextMenu.svelte.d.ts +12 -0
  24. package/dist/components/DataTable.svelte +139 -0
  25. package/dist/components/DataTable.svelte.d.ts +39 -0
  26. package/dist/components/DescriptionList.svelte +41 -0
  27. package/dist/components/DescriptionList.svelte.d.ts +8 -0
  28. package/dist/components/Dialog.svelte +2 -1
  29. package/dist/components/Disclosure.svelte +55 -0
  30. package/dist/components/Disclosure.svelte.d.ts +10 -0
  31. package/dist/components/DocumentViewer.svelte +105 -0
  32. package/dist/components/DocumentViewer.svelte.d.ts +23 -0
  33. package/dist/components/Drawer.svelte +119 -0
  34. package/dist/components/Drawer.svelte.d.ts +16 -0
  35. package/dist/components/DropdownMenu.svelte +46 -62
  36. package/dist/components/DropdownMenu.svelte.d.ts +7 -6
  37. package/dist/components/FieldMessage.svelte +9 -0
  38. package/dist/components/FieldMessage.svelte.d.ts +8 -0
  39. package/dist/components/FileButton.svelte +44 -0
  40. package/dist/components/FileButton.svelte.d.ts +16 -0
  41. package/dist/components/FormRow.svelte +62 -0
  42. package/dist/components/FormRow.svelte.d.ts +13 -0
  43. package/dist/components/IconField.svelte +80 -5
  44. package/dist/components/IconField.svelte.d.ts +2 -0
  45. package/dist/components/Inline.svelte +52 -0
  46. package/dist/components/Inline.svelte.d.ts +16 -0
  47. package/dist/components/Input.svelte +23 -3
  48. package/dist/components/Input.svelte.d.ts +9 -0
  49. package/dist/components/List.svelte +42 -0
  50. package/dist/components/List.svelte.d.ts +13 -0
  51. package/dist/components/ListItem.svelte +114 -0
  52. package/dist/components/ListItem.svelte.d.ts +17 -0
  53. package/dist/components/MenuItems.svelte +44 -0
  54. package/dist/components/MenuItems.svelte.d.ts +8 -0
  55. package/dist/components/NavSection.svelte +45 -0
  56. package/dist/components/NavSection.svelte.d.ts +12 -0
  57. package/dist/components/Page.svelte +53 -0
  58. package/dist/components/Page.svelte.d.ts +12 -0
  59. package/dist/components/PageHeader.svelte +91 -0
  60. package/dist/components/PageHeader.svelte.d.ts +15 -0
  61. package/dist/components/Prose.svelte +33 -0
  62. package/dist/components/Prose.svelte.d.ts +8 -0
  63. package/dist/components/RadioCards.svelte +68 -0
  64. package/dist/components/RadioCards.svelte.d.ts +11 -0
  65. package/dist/components/SearchPicker.svelte +171 -0
  66. package/dist/components/SearchPicker.svelte.d.ts +19 -0
  67. package/dist/components/Section.svelte +71 -0
  68. package/dist/components/Section.svelte.d.ts +12 -0
  69. package/dist/components/SegmentedControl.svelte +63 -0
  70. package/dist/components/SegmentedControl.svelte.d.ts +12 -0
  71. package/dist/components/Select.svelte +22 -4
  72. package/dist/components/Select.svelte.d.ts +9 -1
  73. package/dist/components/Spacer.svelte +6 -0
  74. package/dist/components/Spacer.svelte.d.ts +26 -0
  75. package/dist/components/Stack.svelte +37 -0
  76. package/dist/components/Stack.svelte.d.ts +12 -0
  77. package/dist/components/Switch.svelte +6 -2
  78. package/dist/components/Switch.svelte.d.ts +2 -0
  79. package/dist/components/Tabs.svelte +6 -4
  80. package/dist/components/Text.svelte +68 -0
  81. package/dist/components/Text.svelte.d.ts +15 -0
  82. package/dist/components/Textarea.svelte +19 -3
  83. package/dist/components/Textarea.svelte.d.ts +6 -0
  84. package/dist/components/TitleInput.svelte +62 -0
  85. package/dist/components/TitleInput.svelte.d.ts +10 -0
  86. package/dist/index.d.ts +35 -2
  87. package/dist/index.js +31 -0
  88. package/dist/utils/calendar.d.ts +35 -0
  89. package/dist/utils/calendar.js +61 -0
  90. package/dist/utils/commands.d.ts +22 -0
  91. package/dist/utils/commands.js +17 -0
  92. package/dist/utils/componentTypes.d.ts +41 -0
  93. package/dist/utils/componentTypes.js +1 -0
  94. package/dist/utils/listNav.d.ts +2 -0
  95. package/dist/utils/listNav.js +8 -0
  96. package/dist/utils/menu.d.ts +10 -0
  97. package/dist/utils/menu.js +1 -0
  98. package/package.json +5 -3
@@ -0,0 +1,91 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+
5
+ let {
6
+ title,
7
+ description,
8
+ class: className = '',
9
+ leading,
10
+ meta,
11
+ actions,
12
+ }: {
13
+ title: string;
14
+ description?: string;
15
+ class?: string;
16
+ /** Icon or avatar before the title. */
17
+ leading?: Snippet;
18
+ /** Small line under the title (counts, badges, breadcrumbs). */
19
+ meta?: Snippet;
20
+ /** Buttons aligned to the right. */
21
+ actions?: Snippet;
22
+ } = $props();
23
+ </script>
24
+
25
+ <header class={cn('terminoir-page-header', className)}>
26
+ {#if leading}
27
+ <div class="terminoir-page-header__leading">{@render leading()}</div>
28
+ {/if}
29
+ <div class="terminoir-page-header__text">
30
+ <h1 class="terminoir-page-header__title">{title}</h1>
31
+ {#if meta}<div class="terminoir-page-header__meta">{@render meta()}</div>{/if}
32
+ {#if description}<p class="terminoir-page-header__description">{description}</p>{/if}
33
+ </div>
34
+ {#if actions}
35
+ <div class="terminoir-page-header__actions">{@render actions()}</div>
36
+ {/if}
37
+ </header>
38
+
39
+ <style>
40
+ .terminoir-page-header {
41
+ display: flex;
42
+ align-items: flex-start;
43
+ gap: var(--space-3);
44
+ min-width: 0;
45
+ }
46
+ .terminoir-page-header__leading {
47
+ display: flex;
48
+ align-items: center;
49
+ min-height: 2rem;
50
+ font-size: 1.25rem;
51
+ }
52
+ .terminoir-page-header__text {
53
+ flex: 1;
54
+ min-width: 0;
55
+ display: flex;
56
+ flex-direction: column;
57
+ gap: var(--space-1);
58
+ }
59
+ .terminoir-page-header__title {
60
+ margin: 0;
61
+ font-size: 1.5rem;
62
+ font-weight: 600;
63
+ line-height: 1.3;
64
+ letter-spacing: -0.01em;
65
+ overflow-wrap: anywhere;
66
+ }
67
+ .terminoir-page-header__meta {
68
+ display: flex;
69
+ flex-wrap: wrap;
70
+ align-items: center;
71
+ gap: var(--space-2);
72
+ color: var(--muted-foreground);
73
+ font-size: 0.8125rem;
74
+ }
75
+ .terminoir-page-header__description {
76
+ margin: 0;
77
+ color: var(--muted-foreground);
78
+ font-size: 0.875rem;
79
+ max-width: 60ch;
80
+ }
81
+ .terminoir-page-header__actions {
82
+ display: flex;
83
+ flex-wrap: wrap;
84
+ gap: var(--space-2);
85
+ flex-shrink: 0;
86
+ }
87
+ @media (max-width: 700px) {
88
+ .terminoir-page-header { flex-wrap: wrap; }
89
+ .terminoir-page-header__title { font-size: 1.25rem; }
90
+ }
91
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ title: string;
4
+ description?: string;
5
+ class?: string;
6
+ /** Icon or avatar before the title. */
7
+ leading?: Snippet;
8
+ /** Small line under the title (counts, badges, breadcrumbs). */
9
+ meta?: Snippet;
10
+ /** Buttons aligned to the right. */
11
+ actions?: Snippet;
12
+ };
13
+ declare const PageHeader: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type PageHeader = ReturnType<typeof PageHeader>;
15
+ export default PageHeader;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+
5
+ let { class: className = '', children }: { class?: string; children?: Snippet } = $props();
6
+ </script>
7
+
8
+ <!-- Readable long-form text (policies, help pages, rendered markdown). -->
9
+ <div class={cn('terminoir-prose', className)}>{@render children?.()}</div>
10
+
11
+ <style>
12
+ .terminoir-prose {
13
+ max-width: 44rem;
14
+ line-height: 1.65;
15
+ color: var(--foreground);
16
+ font-size: 0.9375rem;
17
+ }
18
+ .terminoir-prose :global(h1) { font-size: 1.5rem; font-weight: 600; margin: 0 0 var(--space-4); line-height: 1.3; }
19
+ .terminoir-prose :global(h2) { font-size: 1.125rem; font-weight: 600; margin: var(--space-8) 0 var(--space-3); }
20
+ .terminoir-prose :global(h3) { font-size: 1rem; font-weight: 600; margin: var(--space-6) 0 var(--space-2); }
21
+ .terminoir-prose :global(p),
22
+ .terminoir-prose :global(ul),
23
+ .terminoir-prose :global(ol) { margin: 0 0 var(--space-4); }
24
+ .terminoir-prose :global(li + li) { margin-top: var(--space-1); }
25
+ .terminoir-prose :global(a) { color: var(--primary); text-underline-offset: 2px; }
26
+ .terminoir-prose :global(code) {
27
+ font-family: var(--font-mono);
28
+ font-size: 0.875em;
29
+ padding: 0.1em 0.3em;
30
+ border-radius: 0.3em;
31
+ background: color-mix(in srgb, var(--foreground) 8%, transparent);
32
+ }
33
+ </style>
@@ -0,0 +1,8 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ class?: string;
4
+ children?: Snippet;
5
+ };
6
+ declare const Prose: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Prose = ReturnType<typeof Prose>;
8
+ export default Prose;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+
4
+ import type { RadioCardOption } from '../utils/componentTypes.js';
5
+
6
+ let {
7
+ label,
8
+ options,
9
+ value = $bindable(''),
10
+ class: className = '',
11
+ }: {
12
+ /** Group label (legend). */
13
+ label: string;
14
+ options: RadioCardOption[];
15
+ value?: string;
16
+ class?: string;
17
+ } = $props();
18
+
19
+ const name = $props.id();
20
+ </script>
21
+
22
+ <fieldset class={cn('terminoir-radio-cards', className)}>
23
+ <legend class="terminoir-field__label">{label}</legend>
24
+ <div class="terminoir-radio-cards__grid">
25
+ {#each options as option (option.value)}
26
+ <label class="terminoir-radio-cards__card" class:terminoir-radio-cards__card--selected={value === option.value}>
27
+ <input class="terminoir-sr-only" type="radio" {name} value={option.value} bind:group={value} />
28
+ <span class="terminoir-radio-cards__title">{option.label}</span>
29
+ {#if option.description}<span class="terminoir-radio-cards__description">{option.description}</span>{/if}
30
+ </label>
31
+ {/each}
32
+ </div>
33
+ </fieldset>
34
+
35
+ <style>
36
+ .terminoir-radio-cards {
37
+ margin: 0;
38
+ padding: 0;
39
+ border: 0;
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: var(--space-2);
43
+ min-width: 0;
44
+ }
45
+ .terminoir-radio-cards__grid {
46
+ display: grid;
47
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
48
+ gap: var(--space-2);
49
+ }
50
+ .terminoir-radio-cards__card {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: var(--space-1);
54
+ padding: var(--space-3);
55
+ border: 1px solid var(--border);
56
+ border-radius: var(--radius-control);
57
+ cursor: pointer;
58
+ transition: border-color var(--duration-fast) ease, background var(--duration-fast) ease;
59
+ }
60
+ .terminoir-radio-cards__card:hover { border-color: color-mix(in srgb, var(--foreground) 30%, var(--border)); }
61
+ .terminoir-radio-cards__card:has(input:focus-visible) { box-shadow: var(--focus-ring); }
62
+ .terminoir-radio-cards__card--selected {
63
+ border-color: var(--primary);
64
+ background: color-mix(in srgb, var(--primary) 10%, transparent);
65
+ }
66
+ .terminoir-radio-cards__title { font-size: 0.875rem; font-weight: 600; }
67
+ .terminoir-radio-cards__description { font-size: 0.75rem; color: var(--muted-foreground); line-height: 1.4; }
68
+ </style>
@@ -0,0 +1,11 @@
1
+ import type { RadioCardOption } from '../utils/componentTypes.js';
2
+ type $$ComponentProps = {
3
+ /** Group label (legend). */
4
+ label: string;
5
+ options: RadioCardOption[];
6
+ value?: string;
7
+ class?: string;
8
+ };
9
+ declare const RadioCards: import("svelte").Component<$$ComponentProps, {}, "value">;
10
+ type RadioCards = ReturnType<typeof RadioCards>;
11
+ export default RadioCards;
@@ -0,0 +1,171 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+ import { moveIndex } from '../utils/listNav.js';
4
+ import NavIcon from './NavIcon.svelte';
5
+
6
+ import type { PickerOption } from '../utils/componentTypes.js';
7
+
8
+ let {
9
+ label,
10
+ hideLabel = false,
11
+ quiet = false,
12
+ placeholder = 'Search…',
13
+ search,
14
+ onselect,
15
+ debounceMs = 150,
16
+ emptyText = 'No matches',
17
+ disabled = false,
18
+ class: className = '',
19
+ }: {
20
+ label: string;
21
+ /** Keep the label for screen readers only. */
22
+ hideLabel?: boolean;
23
+ /** Borderless until hovered or focused, for inline editing (property rows). */
24
+ quiet?: boolean;
25
+ placeholder?: string;
26
+ /** Returns options for a non-empty query. Stale responses are ignored. */
27
+ search: (query: string) => Promise<PickerOption[]>;
28
+ onselect: (option: PickerOption) => void;
29
+ debounceMs?: number;
30
+ emptyText?: string;
31
+ disabled?: boolean;
32
+ class?: string;
33
+ } = $props();
34
+
35
+ const uid = $props.id();
36
+ const listId = `${uid}-list`;
37
+
38
+ let query = $state('');
39
+ let options = $state<PickerOption[]>([]);
40
+ let open = $state(false);
41
+ let loading = $state(false);
42
+ let active = $state(-1);
43
+ let requestSeq = 0;
44
+
45
+ $effect(() => {
46
+ const q = query.trim();
47
+ if (!q) {
48
+ options = [];
49
+ open = false;
50
+ return;
51
+ }
52
+ const seq = ++requestSeq;
53
+ const handle = setTimeout(async () => {
54
+ loading = true;
55
+ try {
56
+ const found = await search(q);
57
+ if (seq !== requestSeq) return;
58
+ options = found;
59
+ active = found.length ? 0 : -1;
60
+ open = true;
61
+ } catch {
62
+ if (seq === requestSeq) options = [];
63
+ } finally {
64
+ if (seq === requestSeq) loading = false;
65
+ }
66
+ }, debounceMs);
67
+ return () => clearTimeout(handle);
68
+ });
69
+
70
+ function choose(option: PickerOption | undefined) {
71
+ if (!option) return;
72
+ onselect(option);
73
+ query = '';
74
+ open = false;
75
+ }
76
+
77
+ function onKeydown(event: KeyboardEvent) {
78
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
79
+ event.preventDefault();
80
+ open = true;
81
+ active = moveIndex(active, event.key === 'ArrowDown' ? 1 : -1, options.length);
82
+ } else if (event.key === 'Enter' && open) {
83
+ event.preventDefault();
84
+ choose(options[active]);
85
+ } else if (event.key === 'Escape') {
86
+ open = false;
87
+ }
88
+ }
89
+ </script>
90
+
91
+ <div class={cn('terminoir-field terminoir-picker', quiet && 'terminoir-field--quiet', className)}>
92
+ <label class={cn('terminoir-field__label', hideLabel && 'terminoir-sr-only')} for={`${uid}-input`}>{label}</label>
93
+ <div class="terminoir-picker__anchor">
94
+ <input
95
+ id={`${uid}-input`}
96
+ class="terminoir-input terminoir-focusable"
97
+ type="search"
98
+ role="combobox"
99
+ autocomplete="off"
100
+ aria-expanded={open}
101
+ aria-controls={listId}
102
+ aria-autocomplete="list"
103
+ aria-activedescendant={open && active >= 0 ? `${uid}-opt-${active}` : undefined}
104
+ {placeholder}
105
+ {disabled}
106
+ bind:value={query}
107
+ onkeydown={onKeydown}
108
+ onblur={() => setTimeout(() => (open = false), 120)}
109
+ onfocus={() => (open = options.length > 0)}
110
+ />
111
+ {#if open}
112
+ <ul id={listId} class="terminoir-picker__list glass-popover" role="listbox" aria-label={label}>
113
+ {#each options as option, index (option.id)}
114
+ <!-- Combobox pattern: focus stays in the input, which handles arrow keys and Enter. -->
115
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
116
+ <li
117
+ id={`${uid}-opt-${index}`}
118
+ role="option"
119
+ aria-selected={index === active}
120
+ class="terminoir-picker__option"
121
+ class:terminoir-picker__option--active={index === active}
122
+ onpointerdown={(event) => event.preventDefault()}
123
+ onclick={() => choose(option)}
124
+ onpointerenter={() => (active = index)}
125
+ >
126
+ {#if option.icon}<NavIcon name={option.icon} color={option.iconColor} />{/if}
127
+ <span class="terminoir-picker__label">{option.label}</span>
128
+ {#if option.description}<span class="terminoir-picker__description">{option.description}</span>{/if}
129
+ </li>
130
+ {:else}
131
+ <li class="terminoir-picker__empty" role="presentation">{loading ? 'Searching…' : emptyText}</li>
132
+ {/each}
133
+ </ul>
134
+ {/if}
135
+ </div>
136
+ </div>
137
+
138
+ <style>
139
+ .terminoir-picker__anchor { position: relative; }
140
+ .terminoir-picker__list {
141
+ position: absolute;
142
+ top: calc(100% + var(--space-1));
143
+ left: 0;
144
+ right: 0;
145
+ z-index: 40;
146
+ max-height: 16rem;
147
+ overflow-y: auto;
148
+ margin: 0;
149
+ padding: var(--space-1);
150
+ list-style: none;
151
+ }
152
+ .terminoir-picker__option {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: var(--space-2);
156
+ padding: 0.45rem 0.6rem;
157
+ border-radius: calc(var(--radius-control) - 0.2rem);
158
+ font-size: 0.875rem;
159
+ cursor: pointer;
160
+ }
161
+ .terminoir-picker__option--active { background: color-mix(in srgb, var(--foreground) 8%, transparent); }
162
+ .terminoir-picker__label {
163
+ flex: 1;
164
+ min-width: 0;
165
+ overflow: hidden;
166
+ text-overflow: ellipsis;
167
+ white-space: nowrap;
168
+ }
169
+ .terminoir-picker__description { font-size: 0.75rem; color: var(--muted-foreground); flex-shrink: 0; }
170
+ .terminoir-picker__empty { padding: 0.5rem 0.6rem; font-size: 0.8125rem; color: var(--muted-foreground); }
171
+ </style>
@@ -0,0 +1,19 @@
1
+ import type { PickerOption } from '../utils/componentTypes.js';
2
+ type $$ComponentProps = {
3
+ label: string;
4
+ /** Keep the label for screen readers only. */
5
+ hideLabel?: boolean;
6
+ /** Borderless until hovered or focused, for inline editing (property rows). */
7
+ quiet?: boolean;
8
+ placeholder?: string;
9
+ /** Returns options for a non-empty query. Stale responses are ignored. */
10
+ search: (query: string) => Promise<PickerOption[]>;
11
+ onselect: (option: PickerOption) => void;
12
+ debounceMs?: number;
13
+ emptyText?: string;
14
+ disabled?: boolean;
15
+ class?: string;
16
+ };
17
+ declare const SearchPicker: import("svelte").Component<$$ComponentProps, {}, "">;
18
+ type SearchPicker = ReturnType<typeof SearchPicker>;
19
+ export default SearchPicker;
@@ -0,0 +1,71 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+
5
+ let {
6
+ title,
7
+ description,
8
+ class: className = '',
9
+ actions,
10
+ children,
11
+ ...rest
12
+ }: {
13
+ title?: string;
14
+ description?: string;
15
+ class?: string;
16
+ /** Small buttons beside the heading (e.g. "Add"). */
17
+ actions?: Snippet;
18
+ children?: Snippet;
19
+ } & Record<string, unknown> = $props();
20
+ </script>
21
+
22
+ <section class={cn('terminoir-section', className)} {...rest}>
23
+ {#if title || actions}
24
+ <div class="terminoir-section__head">
25
+ <div class="terminoir-section__text">
26
+ {#if title}<h2 class="terminoir-section__title">{title}</h2>{/if}
27
+ {#if description}<p class="terminoir-section__description">{description}</p>{/if}
28
+ </div>
29
+ {#if actions}<div class="terminoir-section__actions">{@render actions()}</div>{/if}
30
+ </div>
31
+ {/if}
32
+ {@render children?.()}
33
+ </section>
34
+
35
+ <style>
36
+ .terminoir-section {
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: var(--space-3);
40
+ min-width: 0;
41
+ }
42
+ .terminoir-section__head {
43
+ display: flex;
44
+ align-items: flex-end;
45
+ justify-content: space-between;
46
+ gap: var(--space-3);
47
+ }
48
+ .terminoir-section__text {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: var(--space-1);
52
+ min-width: 0;
53
+ }
54
+ .terminoir-section__title {
55
+ margin: 0;
56
+ font-size: 0.75rem;
57
+ font-weight: 600;
58
+ letter-spacing: 0.06em;
59
+ text-transform: uppercase;
60
+ color: var(--muted-foreground);
61
+ }
62
+ .terminoir-section__description {
63
+ margin: 0;
64
+ font-size: 0.8125rem;
65
+ color: var(--muted-foreground);
66
+ }
67
+ .terminoir-section__actions {
68
+ display: flex;
69
+ gap: var(--space-2);
70
+ }
71
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ title?: string;
4
+ description?: string;
5
+ class?: string;
6
+ /** Small buttons beside the heading (e.g. "Add"). */
7
+ actions?: Snippet;
8
+ children?: Snippet;
9
+ } & Record<string, unknown>;
10
+ declare const Section: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type Section = ReturnType<typeof Section>;
12
+ export default Section;
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+
4
+ import type { SegmentOption } from '../utils/componentTypes.js';
5
+
6
+ let {
7
+ label,
8
+ options,
9
+ value = $bindable(''),
10
+ size = 'md',
11
+ class: className = '',
12
+ }: {
13
+ /** Accessible group name; not shown. */
14
+ label: string;
15
+ options: SegmentOption[];
16
+ value?: string;
17
+ size?: 'sm' | 'md';
18
+ class?: string;
19
+ } = $props();
20
+ </script>
21
+
22
+ <div class={cn('terminoir-segmented', `terminoir-segmented--${size}`, className)} role="radiogroup" aria-label={label}>
23
+ {#each options as option (option.value)}
24
+ <button
25
+ type="button"
26
+ role="radio"
27
+ aria-checked={value === option.value}
28
+ class="terminoir-segmented__option terminoir-focusable"
29
+ class:terminoir-segmented__option--active={value === option.value}
30
+ onclick={() => (value = option.value)}
31
+ >
32
+ {option.label}
33
+ </button>
34
+ {/each}
35
+ </div>
36
+
37
+ <style>
38
+ .terminoir-segmented {
39
+ display: inline-flex;
40
+ padding: 0.2rem;
41
+ gap: 0.15rem;
42
+ border: 1px solid var(--border);
43
+ border-radius: var(--radius-control);
44
+ background: color-mix(in srgb, var(--card) 40%, var(--background));
45
+ }
46
+ .terminoir-segmented__option {
47
+ border: 0;
48
+ background: transparent;
49
+ color: var(--muted-foreground);
50
+ font: inherit;
51
+ font-size: 0.8125rem;
52
+ font-weight: 500;
53
+ padding: 0.3rem 0.7rem;
54
+ border-radius: calc(var(--radius-control) - 0.2rem);
55
+ cursor: pointer;
56
+ }
57
+ .terminoir-segmented--sm .terminoir-segmented__option { font-size: 0.75rem; padding: 0.2rem 0.55rem; }
58
+ .terminoir-segmented__option:hover { color: var(--foreground); }
59
+ .terminoir-segmented__option--active {
60
+ color: var(--foreground);
61
+ background: color-mix(in srgb, var(--foreground) 10%, transparent);
62
+ }
63
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { SegmentOption } from '../utils/componentTypes.js';
2
+ type $$ComponentProps = {
3
+ /** Accessible group name; not shown. */
4
+ label: string;
5
+ options: SegmentOption[];
6
+ value?: string;
7
+ size?: 'sm' | 'md';
8
+ class?: string;
9
+ };
10
+ declare const SegmentedControl: import("svelte").Component<$$ComponentProps, {}, "value">;
11
+ type SegmentedControl = ReturnType<typeof SegmentedControl>;
12
+ export default SegmentedControl;
@@ -1,8 +1,14 @@
1
1
  <script lang="ts">
2
+ import type { Snippet } from 'svelte';
2
3
  import { cn } from '../utils/cn.js';
4
+ import FieldMessage from './FieldMessage.svelte';
3
5
 
4
6
  let {
5
7
  label,
8
+ hideLabel = false,
9
+ quiet = false,
10
+ hint,
11
+ error,
6
12
  id,
7
13
  value = $bindable(''),
8
14
  disabled = false,
@@ -11,25 +17,37 @@
11
17
  ...rest
12
18
  }: {
13
19
  label: string;
20
+ /** Keep the label for screen readers only (compact rows, toolbars). */
21
+ hideLabel?: boolean;
22
+ /** Borderless until hovered or focused, for inline editing (property rows). */
23
+ quiet?: boolean;
24
+ hint?: string;
25
+ error?: string;
14
26
  id?: string;
15
27
  value?: string;
16
28
  disabled?: boolean;
17
29
  class?: string;
18
- children?: import('svelte').Snippet;
30
+ /** `<option>` elements. */
31
+ children?: Snippet;
19
32
  } & Record<string, unknown> = $props();
20
33
 
21
- const selectId = $derived(id ?? `terminoir-select-${Math.random().toString(36).slice(2, 8)}`);
34
+ const fallbackId = $props.id();
35
+ const selectId = $derived(id ?? fallbackId);
36
+ const messageId = $derived(`${selectId}-message`);
22
37
  </script>
23
38
 
24
- <div class={cn('terminoir-field', className)}>
25
- <label class="terminoir-field__label" for={selectId}>{label}</label>
39
+ <div class={cn('terminoir-field', quiet && 'terminoir-field--quiet', className)}>
40
+ <label class={cn('terminoir-field__label', hideLabel && 'terminoir-sr-only')} for={selectId}>{label}</label>
26
41
  <select
27
42
  id={selectId}
28
43
  class="terminoir-select terminoir-focusable"
29
44
  bind:value
30
45
  {disabled}
46
+ aria-invalid={error ? true : undefined}
47
+ aria-describedby={error || hint ? messageId : undefined}
31
48
  {...rest}
32
49
  >
33
50
  {@render children?.()}
34
51
  </select>
52
+ <FieldMessage id={messageId} {hint} {error} />
35
53
  </div>
@@ -1,10 +1,18 @@
1
+ import type { Snippet } from 'svelte';
1
2
  type $$ComponentProps = {
2
3
  label: string;
4
+ /** Keep the label for screen readers only (compact rows, toolbars). */
5
+ hideLabel?: boolean;
6
+ /** Borderless until hovered or focused, for inline editing (property rows). */
7
+ quiet?: boolean;
8
+ hint?: string;
9
+ error?: string;
3
10
  id?: string;
4
11
  value?: string;
5
12
  disabled?: boolean;
6
13
  class?: string;
7
- children?: import('svelte').Snippet;
14
+ /** `<option>` elements. */
15
+ children?: Snippet;
8
16
  } & Record<string, unknown>;
9
17
  declare const Select: import("svelte").Component<$$ComponentProps, {}, "value">;
10
18
  type Select = ReturnType<typeof Select>;
@@ -0,0 +1,6 @@
1
+ <!-- Fills free space in a flex column or row, e.g. to pin a footer link to the bottom of a sidebar. -->
2
+ <div class="terminoir-spacer" aria-hidden="true"></div>
3
+
4
+ <style>
5
+ .terminoir-spacer { flex: 1 1 auto; }
6
+ </style>