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
@@ -1,72 +1,90 @@
1
1
  <script lang="ts">
2
- import Dialog from './Dialog.svelte';
3
- import Input from './Input.svelte';
2
+ import type { Snippet } from 'svelte';
3
+ import { filterCommands, groupCommands, type CommandItem } from '../utils/commands.js';
4
+ import { moveIndex } from '../utils/listNav.js';
4
5
  import Kbd from './Kbd.svelte';
5
-
6
- export type CommandItem = {
7
- id: string;
8
- label: string;
9
- hint?: string;
10
- keywords?: string;
11
- onSelect?: () => void;
12
- };
6
+ import NavIcon from './NavIcon.svelte';
13
7
 
14
8
  let {
15
9
  open = $bindable(false),
16
10
  items = [],
11
+ query = $bindable(''),
12
+ filter = true,
13
+ loading = false,
17
14
  placeholder = 'Search commands…',
15
+ emptyText = 'No matches',
16
+ hotkey = true,
17
+ onclose,
18
+ footer,
18
19
  }: {
19
20
  open?: boolean;
20
21
  items?: CommandItem[];
22
+ /** Current search text; bind it to drive async results. */
23
+ query?: string;
24
+ /** Filter `items` by `query` locally. Turn off when the caller supplies already-matched results. */
25
+ filter?: boolean;
26
+ /** Shows a searching state while async results load. */
27
+ loading?: boolean;
21
28
  placeholder?: string;
29
+ emptyText?: string;
30
+ /** Toggle with ⌘K / Ctrl+K. Turn off when the app owns that shortcut. */
31
+ hotkey?: boolean;
32
+ onclose?: () => void;
33
+ /** Extra content under the results (e.g. an answer panel). */
34
+ footer?: Snippet;
22
35
  } = $props();
23
36
 
24
- let query = $state('');
37
+ const uid = $props.id();
38
+ let inputEl: HTMLInputElement | undefined = $state();
25
39
  let activeIndex = $state(0);
26
40
 
27
- const filtered = $derived(
28
- items.filter((item) => {
29
- const hay = `${item.label} ${item.keywords ?? ''}`.toLowerCase();
30
- return hay.includes(query.toLowerCase().trim());
31
- }),
32
- );
41
+ const visible = $derived(filter ? filterCommands(items, query) : items);
42
+ const groups = $derived(groupCommands(visible));
43
+ /** Flat order of rows as rendered, so arrow keys follow the grouped layout. */
44
+ const ordered = $derived(groups.flatMap((g) => g.items));
33
45
 
34
46
  $effect(() => {
35
- if (open) {
36
- query = '';
37
- activeIndex = 0;
38
- }
47
+ if (!open) return;
48
+ query = '';
49
+ activeIndex = 0;
50
+ queueMicrotask(() => inputEl?.focus());
39
51
  });
40
52
 
41
53
  $effect(() => {
42
- if (activeIndex >= filtered.length) activeIndex = Math.max(0, filtered.length - 1);
54
+ if (activeIndex >= ordered.length) activeIndex = Math.max(0, ordered.length - 1);
43
55
  });
44
56
 
57
+ function close() {
58
+ open = false;
59
+ onclose?.();
60
+ }
61
+
45
62
  function run(item: CommandItem | undefined) {
46
63
  if (!item) return;
47
64
  item.onSelect?.();
48
- open = false;
65
+ close();
49
66
  }
50
67
 
51
- function onListKeydown(event: KeyboardEvent) {
52
- if (event.key === 'ArrowDown') {
53
- event.preventDefault();
54
- activeIndex = Math.min(filtered.length - 1, activeIndex + 1);
55
- } else if (event.key === 'ArrowUp') {
68
+ function onKeydown(event: KeyboardEvent) {
69
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
56
70
  event.preventDefault();
57
- activeIndex = Math.max(0, activeIndex - 1);
71
+ activeIndex = Math.max(0, moveIndex(activeIndex, event.key === 'ArrowDown' ? 1 : -1, ordered.length));
58
72
  } else if (event.key === 'Enter') {
59
73
  event.preventDefault();
60
- run(filtered[activeIndex]);
74
+ run(ordered[activeIndex]);
75
+ } else if (event.key === 'Escape') {
76
+ event.preventDefault();
77
+ close();
61
78
  }
62
79
  }
63
80
 
64
81
  $effect(() => {
65
- if (typeof window === 'undefined') return;
82
+ if (!hotkey || typeof window === 'undefined') return;
66
83
  const onGlobal = (event: KeyboardEvent) => {
67
84
  if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
68
85
  event.preventDefault();
69
- open = !open;
86
+ if (open) close();
87
+ else open = true;
70
88
  }
71
89
  };
72
90
  window.addEventListener('keydown', onGlobal);
@@ -74,89 +92,152 @@
74
92
  });
75
93
  </script>
76
94
 
77
- <Dialog bind:open title="Command palette">
78
- <div class="terminoir-command">
79
- <Input label="Search" bind:value={query} {placeholder} />
80
- <p class="terminoir-command__hint">
81
- Navigate with <Kbd>↑</Kbd> <Kbd>↓</Kbd>, select with <Kbd>Enter</Kbd>, toggle with
82
- <Kbd>⌘</Kbd><Kbd>K</Kbd>
83
- </p>
84
- <ul class="terminoir-command__list" role="listbox" tabindex="0" onkeydown={onListKeydown}>
85
- {#each filtered as item, index (item.id)}
86
- <li>
87
- <button
88
- type="button"
89
- role="option"
90
- aria-selected={index === activeIndex}
91
- class="terminoir-command__item terminoir-focusable"
92
- class:terminoir-command__item--active={index === activeIndex}
93
- onclick={() => run(item)}
94
- >
95
- <span>{item.label}</span>
96
- {#if item.hint}<span class="terminoir-command__hint-text">{item.hint}</span>{/if}
97
- </button>
98
- </li>
99
- {/each}
100
- {#if filtered.length === 0}
101
- <li class="terminoir-command__empty">No matches</li>
102
- {/if}
103
- </ul>
95
+ {#if open}
96
+ <div class="terminoir-command-backdrop" role="presentation" onclick={(e) => e.target === e.currentTarget && close()}>
97
+ <div class="terminoir-command glass-modal" role="dialog" aria-modal="true" aria-label="Command palette">
98
+ <div class="terminoir-command__search">
99
+ <NavIcon name="magnifying-glass" />
100
+ <input
101
+ bind:this={inputEl}
102
+ bind:value={query}
103
+ class="terminoir-command__input"
104
+ type="text"
105
+ role="combobox"
106
+ aria-expanded="true"
107
+ aria-controls={`${uid}-list`}
108
+ aria-activedescendant={ordered.length ? `${uid}-row-${activeIndex}` : undefined}
109
+ aria-label="Search"
110
+ autocomplete="off"
111
+ spellcheck="false"
112
+ {placeholder}
113
+ onkeydown={onKeydown}
114
+ />
115
+ {#if loading}<span class="terminoir-command__loading" aria-live="polite">Searching…</span>{/if}
116
+ </div>
117
+ <div id={`${uid}-list`} class="terminoir-command__list" role="listbox" aria-label="Results">
118
+ {#each groups as group (group.group ?? '')}
119
+ {#if group.group}<div class="terminoir-command__group" role="presentation">{group.group}</div>{/if}
120
+ {#each group.items as item (item.id)}
121
+ {@const index = ordered.indexOf(item)}
122
+ <!-- Combobox pattern: focus stays in the input, which handles arrow keys and Enter. -->
123
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_interactive_supports_focus -->
124
+ <div
125
+ id={`${uid}-row-${index}`}
126
+ role="option"
127
+ aria-selected={index === activeIndex}
128
+ class="terminoir-command__item"
129
+ class:terminoir-command__item--active={index === activeIndex}
130
+ onclick={() => run(item)}
131
+ onpointermove={() => (activeIndex = index)}
132
+ >
133
+ {#if item.icon}<span class="terminoir-command__icon"><NavIcon name={item.icon} color={item.iconColor} /></span>{/if}
134
+ <span class="terminoir-command__label">{item.label}</span>
135
+ {#if item.hint}<span class="terminoir-command__hint-text">{item.hint}</span>{/if}
136
+ </div>
137
+ {/each}
138
+ {/each}
139
+ {#if ordered.length === 0 && !loading && query.trim()}
140
+ <div class="terminoir-command__empty">{emptyText}</div>
141
+ {/if}
142
+ </div>
143
+ {#if footer}<div class="terminoir-command__footer">{@render footer()}</div>{/if}
144
+ <div class="terminoir-command__keys" aria-hidden="true">
145
+ <span><Kbd>↑</Kbd><Kbd>↓</Kbd> move</span>
146
+ <span><Kbd>↵</Kbd> open</span>
147
+ <span><Kbd>esc</Kbd> close</span>
148
+ </div>
149
+ </div>
104
150
  </div>
105
- </Dialog>
151
+ {/if}
106
152
 
107
153
  <style>
154
+ .terminoir-command-backdrop {
155
+ position: fixed;
156
+ inset: 0;
157
+ z-index: 70;
158
+ display: flex;
159
+ justify-content: center;
160
+ align-items: flex-start;
161
+ padding: 12vh var(--space-4) var(--space-4);
162
+ background: rgba(0, 0, 0, 0.35);
163
+ backdrop-filter: blur(4px);
164
+ }
108
165
  .terminoir-command {
166
+ width: min(100%, 38rem);
167
+ max-height: 70vh;
109
168
  display: flex;
110
169
  flex-direction: column;
111
- gap: var(--space-3);
170
+ overflow: hidden;
112
171
  }
113
-
114
- .terminoir-command__hint {
115
- margin: 0;
116
- font-size: 0.75rem;
117
- color: var(--muted-foreground);
172
+ .terminoir-command__search {
118
173
  display: flex;
119
174
  align-items: center;
120
- gap: 0.25rem;
121
- flex-wrap: wrap;
175
+ gap: var(--space-3);
176
+ padding: 0 var(--space-4);
177
+ border-bottom: 1px solid var(--border);
178
+ color: var(--muted-foreground);
122
179
  }
123
-
180
+ .terminoir-command__input {
181
+ flex: 1;
182
+ min-width: 0;
183
+ padding: 0.95rem 0;
184
+ border: 0;
185
+ outline: none;
186
+ background: transparent;
187
+ color: var(--foreground);
188
+ font: inherit;
189
+ font-size: 1rem;
190
+ }
191
+ .terminoir-command__loading { font-size: 0.75rem; }
124
192
  .terminoir-command__list {
125
- margin: 0;
126
- padding: 0;
127
- list-style: none;
128
- max-height: 16rem;
129
- overflow: auto;
130
- border: 1px solid var(--border);
131
- border-radius: calc(var(--radius) - 2px);
193
+ flex: 1;
194
+ overflow-y: auto;
195
+ padding: var(--space-2);
196
+ }
197
+ .terminoir-command__group {
198
+ padding: var(--space-2) var(--space-2) var(--space-1);
199
+ font-size: 0.6875rem;
200
+ font-weight: 600;
201
+ letter-spacing: 0.06em;
202
+ text-transform: uppercase;
203
+ color: var(--muted-foreground);
132
204
  }
133
-
134
205
  .terminoir-command__item {
135
- width: 100%;
136
206
  display: flex;
137
- justify-content: space-between;
138
- gap: var(--space-2);
139
- padding: 0.55rem 0.75rem;
140
- border: 0;
141
- background: transparent;
142
- text-align: left;
143
- font: inherit;
207
+ align-items: center;
208
+ gap: var(--space-3);
209
+ padding: 0.5rem 0.6rem;
210
+ border-radius: calc(var(--radius-control) - 0.2rem);
211
+ font-size: 0.875rem;
144
212
  cursor: pointer;
145
213
  }
146
-
147
- .terminoir-command__item--active,
148
- .terminoir-command__item:hover {
149
- background: color-mix(in srgb, var(--accent) 75%, transparent);
214
+ .terminoir-command__item--active { background: color-mix(in srgb, var(--foreground) 8%, transparent); }
215
+ .terminoir-command__icon { display: inline-flex; width: 1.1rem; justify-content: center; flex-shrink: 0; }
216
+ .terminoir-command__label {
217
+ flex: 1;
218
+ min-width: 0;
219
+ overflow: hidden;
220
+ text-overflow: ellipsis;
221
+ white-space: nowrap;
150
222
  }
151
-
152
- .terminoir-command__hint-text {
153
- color: var(--muted-foreground);
154
- font-size: 0.8125rem;
223
+ .terminoir-command__hint-text { color: var(--muted-foreground); font-size: 0.75rem; flex-shrink: 0; }
224
+ .terminoir-command__empty { padding: var(--space-6); color: var(--muted-foreground); text-align: center; font-size: 0.875rem; }
225
+ .terminoir-command__footer {
226
+ padding: var(--space-3) var(--space-4);
227
+ border-top: 1px solid var(--border);
228
+ font-size: 0.875rem;
155
229
  }
156
-
157
- .terminoir-command__empty {
158
- padding: var(--space-4);
230
+ .terminoir-command__keys {
231
+ display: flex;
232
+ gap: var(--space-4);
233
+ padding: var(--space-2) var(--space-4);
234
+ border-top: 1px solid var(--border);
235
+ font-size: 0.75rem;
159
236
  color: var(--muted-foreground);
160
- text-align: center;
237
+ }
238
+ .terminoir-command__keys span { display: inline-flex; align-items: center; gap: 0.2rem; }
239
+ @media (max-width: 700px) {
240
+ .terminoir-command-backdrop { padding-top: var(--space-4); }
241
+ .terminoir-command__keys { display: none; }
161
242
  }
162
243
  </style>
@@ -1,15 +1,22 @@
1
- export type CommandItem = {
2
- id: string;
3
- label: string;
4
- hint?: string;
5
- keywords?: string;
6
- onSelect?: () => void;
7
- };
1
+ import type { Snippet } from 'svelte';
2
+ import { type CommandItem } from '../utils/commands.js';
8
3
  type $$ComponentProps = {
9
4
  open?: boolean;
10
5
  items?: CommandItem[];
6
+ /** Current search text; bind it to drive async results. */
7
+ query?: string;
8
+ /** Filter `items` by `query` locally. Turn off when the caller supplies already-matched results. */
9
+ filter?: boolean;
10
+ /** Shows a searching state while async results load. */
11
+ loading?: boolean;
11
12
  placeholder?: string;
13
+ emptyText?: string;
14
+ /** Toggle with ⌘K / Ctrl+K. Turn off when the app owns that shortcut. */
15
+ hotkey?: boolean;
16
+ onclose?: () => void;
17
+ /** Extra content under the results (e.g. an answer panel). */
18
+ footer?: Snippet;
12
19
  };
13
- declare const CommandPalette: import("svelte").Component<$$ComponentProps, {}, "open">;
20
+ declare const CommandPalette: import("svelte").Component<$$ComponentProps, {}, "open" | "query">;
14
21
  type CommandPalette = ReturnType<typeof CommandPalette>;
15
22
  export default CommandPalette;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { MenuItem } from '../utils/menu.js';
3
+ import MenuItems from './MenuItems.svelte';
4
+
5
+ let {
6
+ open = $bindable(false),
7
+ x = 0,
8
+ y = 0,
9
+ label = 'Actions',
10
+ items,
11
+ }: {
12
+ open?: boolean;
13
+ /** Viewport coordinates, usually from the contextmenu event. */
14
+ x?: number;
15
+ y?: number;
16
+ label?: string;
17
+ items: MenuItem[];
18
+ } = $props();
19
+
20
+ let menuEl: HTMLUListElement | undefined = $state();
21
+
22
+ function select(item: MenuItem) {
23
+ if (item.disabled) return;
24
+ item.onSelect?.();
25
+ open = false;
26
+ }
27
+
28
+ $effect(() => {
29
+ if (!open) return;
30
+ queueMicrotask(() => menuEl?.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus());
31
+ const onPointer = (event: PointerEvent) => {
32
+ if (menuEl && !menuEl.contains(event.target as Node)) open = false;
33
+ };
34
+ const onKey = (event: KeyboardEvent) => {
35
+ if (event.key === 'Escape') open = false;
36
+ };
37
+ document.addEventListener('pointerdown', onPointer);
38
+ document.addEventListener('keydown', onKey);
39
+ return () => {
40
+ document.removeEventListener('pointerdown', onPointer);
41
+ document.removeEventListener('keydown', onKey);
42
+ };
43
+ });
44
+ </script>
45
+
46
+ {#if open}
47
+ <ul
48
+ bind:this={menuEl}
49
+ class="terminoir-context-menu glass-popover"
50
+ role="menu"
51
+ aria-label={label}
52
+ style:left={`min(${x}px, calc(100vw - 13rem))`}
53
+ style:top={`min(${y}px, calc(100vh - ${items.length * 2.5 + 1}rem))`}
54
+ >
55
+ <MenuItems {items} onselect={select} />
56
+ </ul>
57
+ {/if}
58
+
59
+ <style>
60
+ .terminoir-context-menu {
61
+ position: fixed;
62
+ z-index: 80;
63
+ min-width: 12rem;
64
+ margin: 0;
65
+ padding: var(--space-1);
66
+ list-style: none;
67
+ }
68
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { MenuItem } from '../utils/menu.js';
2
+ type $$ComponentProps = {
3
+ open?: boolean;
4
+ /** Viewport coordinates, usually from the contextmenu event. */
5
+ x?: number;
6
+ y?: number;
7
+ label?: string;
8
+ items: MenuItem[];
9
+ };
10
+ declare const ContextMenu: import("svelte").Component<$$ComponentProps, {}, "open">;
11
+ type ContextMenu = ReturnType<typeof ContextMenu>;
12
+ export default ContextMenu;
@@ -0,0 +1,139 @@
1
+ <script lang="ts" generics="Row">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+
5
+ import type { DataColumn } from '../utils/componentTypes.js';
6
+
7
+ let {
8
+ columns,
9
+ rows,
10
+ rowKey,
11
+ label,
12
+ cell,
13
+ onRowClick,
14
+ empty,
15
+ class: className = '',
16
+ }: {
17
+ columns: DataColumn[];
18
+ rows: Row[];
19
+ rowKey: (row: Row) => string;
20
+ /** Accessible table name. */
21
+ label: string;
22
+ /** Renders one cell; defaults to `String(row[column.key])`. */
23
+ cell?: Snippet<[Row, DataColumn]>;
24
+ /** Makes rows clickable and keyboard-activatable. */
25
+ onRowClick?: (row: Row) => void;
26
+ /** Shown in place of the table body when there are no rows. */
27
+ empty?: Snippet;
28
+ class?: string;
29
+ } = $props();
30
+
31
+ function fallback(row: Row, column: DataColumn): string {
32
+ const value = (row as Record<string, unknown>)[column.key];
33
+ return value == null ? '' : String(value);
34
+ }
35
+
36
+ function onRowKeydown(event: KeyboardEvent, row: Row) {
37
+ if (event.key === 'Enter' || event.key === ' ') {
38
+ event.preventDefault();
39
+ onRowClick?.(row);
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <div class={cn('terminoir-table-wrap', className)}>
45
+ <table class="terminoir-table" aria-label={label}>
46
+ <thead>
47
+ <tr>
48
+ {#each columns as column (column.key)}
49
+ <th
50
+ scope="col"
51
+ style:width={column.width}
52
+ class:terminoir-table__end={column.align === 'end'}
53
+ class:terminoir-table__mobile-hidden={column.hideOnMobile}
54
+ >
55
+ {column.label}
56
+ </th>
57
+ {/each}
58
+ </tr>
59
+ </thead>
60
+ <tbody>
61
+ {#each rows as row (rowKey(row))}
62
+ <tr
63
+ class:terminoir-table__row--interactive={!!onRowClick}
64
+ tabindex={onRowClick ? 0 : undefined}
65
+ onclick={onRowClick ? () => onRowClick(row) : undefined}
66
+ onkeydown={onRowClick ? (event) => onRowKeydown(event, row) : undefined}
67
+ >
68
+ {#each columns as column (column.key)}
69
+ <td
70
+ class:terminoir-table__end={column.align === 'end'}
71
+ class:terminoir-table__mobile-hidden={column.hideOnMobile}
72
+ >
73
+ {#if cell}{@render cell(row, column)}{:else}{fallback(row, column)}{/if}
74
+ </td>
75
+ {/each}
76
+ </tr>
77
+ {/each}
78
+ </tbody>
79
+ </table>
80
+ {#if rows.length === 0 && empty}
81
+ <div class="terminoir-table__empty">{@render empty()}</div>
82
+ {/if}
83
+ </div>
84
+
85
+ <style>
86
+ .terminoir-table-wrap {
87
+ width: 100%;
88
+ overflow-x: auto;
89
+ border: 1px solid var(--border);
90
+ border-radius: var(--radius);
91
+ }
92
+ .terminoir-table {
93
+ width: 100%;
94
+ border-collapse: collapse;
95
+ font-size: 0.875rem;
96
+ }
97
+ th {
98
+ position: sticky;
99
+ top: 0;
100
+ padding: 0.55rem 0.85rem;
101
+ text-align: left;
102
+ font-size: 0.6875rem;
103
+ font-weight: 600;
104
+ letter-spacing: 0.06em;
105
+ text-transform: uppercase;
106
+ color: var(--muted-foreground);
107
+ background: color-mix(in srgb, var(--card) 80%, var(--background));
108
+ border-bottom: 1px solid var(--border);
109
+ white-space: nowrap;
110
+ }
111
+ td {
112
+ padding: 0.55rem 0.85rem;
113
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
114
+ vertical-align: middle;
115
+ max-width: 0;
116
+ overflow: hidden;
117
+ text-overflow: ellipsis;
118
+ white-space: nowrap;
119
+ }
120
+ tbody tr:last-child td { border-bottom: 0; }
121
+ .terminoir-table__end { text-align: right; }
122
+ .terminoir-table__row--interactive { cursor: pointer; }
123
+ .terminoir-table__row--interactive:hover td {
124
+ background: color-mix(in srgb, var(--foreground) 4%, transparent);
125
+ }
126
+ .terminoir-table__row--interactive:focus-visible {
127
+ outline: 2px solid var(--ring);
128
+ outline-offset: -2px;
129
+ }
130
+ .terminoir-table__empty {
131
+ padding: var(--space-8) var(--space-4);
132
+ text-align: center;
133
+ color: var(--muted-foreground);
134
+ font-size: 0.875rem;
135
+ }
136
+ @media (max-width: 700px) {
137
+ .terminoir-table__mobile-hidden { display: none; }
138
+ }
139
+ </style>
@@ -0,0 +1,39 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { DataColumn } from '../utils/componentTypes.js';
3
+ declare function $$render<Row>(): {
4
+ props: {
5
+ columns: DataColumn[];
6
+ rows: Row[];
7
+ rowKey: (row: Row) => string;
8
+ /** Accessible table name. */
9
+ label: string;
10
+ /** Renders one cell; defaults to `String(row[column.key])`. */
11
+ cell?: Snippet<[Row, DataColumn]>;
12
+ /** Makes rows clickable and keyboard-activatable. */
13
+ onRowClick?: (row: Row) => void;
14
+ /** Shown in place of the table body when there are no rows. */
15
+ empty?: Snippet;
16
+ class?: string;
17
+ };
18
+ exports: {};
19
+ bindings: "";
20
+ slots: {};
21
+ events: {};
22
+ };
23
+ declare class __sveltets_Render<Row> {
24
+ props(): ReturnType<typeof $$render<Row>>['props'];
25
+ events(): ReturnType<typeof $$render<Row>>['events'];
26
+ slots(): ReturnType<typeof $$render<Row>>['slots'];
27
+ bindings(): "";
28
+ exports(): {};
29
+ }
30
+ interface $$IsomorphicComponent {
31
+ new <Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Row>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Row>['props']>, ReturnType<__sveltets_Render<Row>['events']>, ReturnType<__sveltets_Render<Row>['slots']>> & {
32
+ $$bindings?: ReturnType<__sveltets_Render<Row>['bindings']>;
33
+ } & ReturnType<__sveltets_Render<Row>['exports']>;
34
+ <Row>(internal: unknown, props: ReturnType<__sveltets_Render<Row>['props']> & {}): ReturnType<__sveltets_Render<Row>['exports']>;
35
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
36
+ }
37
+ declare const DataTable: $$IsomorphicComponent;
38
+ type DataTable<Row> = InstanceType<typeof DataTable<Row>>;
39
+ export default DataTable;
@@ -0,0 +1,41 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+
4
+ import type { DescriptionItem } from '../utils/componentTypes.js';
5
+
6
+ let {
7
+ items,
8
+ class: className = '',
9
+ }: {
10
+ items: DescriptionItem[];
11
+ class?: string;
12
+ } = $props();
13
+ </script>
14
+
15
+ <dl class={cn('terminoir-dl', className)}>
16
+ {#each items as item (item.term)}
17
+ <div class="terminoir-dl__row">
18
+ <dt>{item.term}</dt>
19
+ <dd class:terminoir-dl__danger={item.tone === 'danger'} class:terminoir-dl__success={item.tone === 'success'}>
20
+ {item.detail}
21
+ </dd>
22
+ </div>
23
+ {/each}
24
+ </dl>
25
+
26
+ <style>
27
+ .terminoir-dl { margin: 0; display: flex; flex-direction: column; }
28
+ .terminoir-dl__row {
29
+ display: flex;
30
+ justify-content: space-between;
31
+ gap: var(--space-4);
32
+ padding: 0.4rem 0;
33
+ border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
34
+ font-size: 0.8125rem;
35
+ }
36
+ .terminoir-dl__row:last-child { border-bottom: 0; }
37
+ dt { color: var(--muted-foreground); }
38
+ dd { margin: 0; color: var(--foreground); font-variant-numeric: tabular-nums; text-align: right; }
39
+ .terminoir-dl__danger { color: var(--destructive); }
40
+ .terminoir-dl__success { color: var(--status-done); }
41
+ </style>