terminoir 0.15.0 → 0.16.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.
package/README.md CHANGED
@@ -159,7 +159,8 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
159
159
  | `PropertyRow` | Compact icon + label row for inline-edited values (record properties); hover highlights the row |
160
160
  | `ChipSelect`, `DateField` | Value shown as a coloured `Chip` that opens a grouped option list (`ChipSelectOption`, `ChipSelectGroup`); date shown as "Today" / "Tomorrow" / weekday / "Nov 12" (`dayLabel`), `deadline` tints past and today, opens the native picker, Backspace clears |
161
161
  | `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
162
- | `EditableText` | A name you click to rename in place (Enter or click away saves, Escape cancels); `PageHeader` and `ListItem` take `onrename` for the same |
162
+ | `OptionsEditor` | Notion-style option list for select/status values: drag to reorder, click a name to rename, colour and delete from each row's menu, type to add; `ChipSelect` takes `oncreate` to add one from its search |
163
+ | `EditableText` | A name you click to rename in place (Enter or click away saves, Escape cancels); `PageHeader`, `ListItem` and `PropertyRow` take `onrename` for the same |
163
164
  | `CalendarGrid` | Week (scrolling hours) and month grids; `CalendarEvent` with `muted`/`color`; helpers `calendarRange`, `shiftCalendar`, `eventsOn` |
164
165
  | `CanvasStage`, `DocumentViewer` | Full-bleed canvas host with floating overlay and side panel; paged viewer with toolbar and highlight marks |
165
166
  | `AccentSwatches` | `bind:value` accent hex from `ACCENT_PALETTE` |
@@ -3,6 +3,7 @@
3
3
  import { cn } from '../utils/cn.js';
4
4
  import type { ChipSelectGroup, ChipSelectOption } from '../utils/componentTypes.js';
5
5
  import { moveIndex } from '../utils/listNav.js';
6
+ import { matchingOptions, namesOption } from '../utils/options.js';
6
7
  import Chip from './Chip.svelte';
7
8
  import NavIcon from './NavIcon.svelte';
8
9
 
@@ -14,6 +15,7 @@
14
15
  placeholder = 'Empty',
15
16
  clearable = true,
16
17
  onchange,
18
+ oncreate,
17
19
  class: className = '',
18
20
  }: {
19
21
  /** Accessible name, e.g. the property name. */
@@ -27,30 +29,43 @@
27
29
  /** Offer a "Clear" entry when something is selected. */
28
30
  clearable?: boolean;
29
31
  onchange: (value: string) => void;
32
+ /** Offers "Create …" for a search that names no option; called with the trimmed name. */
33
+ oncreate?: (label: string) => void;
30
34
  class?: string;
31
35
  } = $props();
32
36
 
33
37
  const uid = $props.id();
34
38
  const CLEAR = { id: '', label: 'Clear' } satisfies ChipSelectOption;
39
+ const CREATE_ID = '\u0000create';
35
40
 
36
41
  let open = $state(false);
37
42
  let active = $state(-1);
38
43
  let root: HTMLDivElement | undefined = $state();
39
44
  let trigger: HTMLButtonElement | undefined = $state();
40
- let list: HTMLUListElement | undefined = $state();
45
+ let search: HTMLInputElement | undefined = $state();
46
+ let query = $state('');
41
47
 
42
48
  const selected = $derived(options.find((o) => o.id === value));
49
+ const visible = $derived(matchingOptions(options, query));
43
50
  const sections = $derived([
44
- { id: '', label: '', options: options.filter((o) => !o.group || !groups.some((g) => g.id === o.group)) },
45
- ...groups.map((g) => ({ ...g, options: options.filter((o) => o.group === g.id) })),
51
+ { id: '', label: '', options: visible.filter((o) => !o.group || !groups.some((g) => g.id === o.group)) },
52
+ ...groups.map((g) => ({ ...g, options: visible.filter((o) => o.group === g.id) })),
46
53
  ].filter((section) => section.options.length));
47
- const choices = $derived([...sections.flatMap((s) => s.options), ...(clearable && selected ? [CLEAR] : [])]);
54
+ const create = $derived<ChipSelectOption | null>(
55
+ oncreate && query.trim() && !namesOption(options, query) ? { id: CREATE_ID, label: query.trim() } : null,
56
+ );
57
+ const choices = $derived([
58
+ ...sections.flatMap((s) => s.options),
59
+ ...(create ? [create] : []),
60
+ ...(clearable && selected && !query.trim() ? [CLEAR] : []),
61
+ ]);
48
62
 
49
63
  async function show() {
50
64
  open = true;
65
+ query = '';
51
66
  active = Math.max(0, choices.findIndex((o) => o.id === value));
52
67
  await tick();
53
- list?.focus();
68
+ search?.focus();
54
69
  }
55
70
 
56
71
  function hide() {
@@ -60,20 +75,25 @@
60
75
 
61
76
  function choose(option: ChipSelectOption | undefined) {
62
77
  if (!option) return;
63
- if (option.id !== value) onchange(option.id);
78
+ if (option.id === CREATE_ID) oncreate?.(option.label);
79
+ else if (option.id !== value) onchange(option.id);
64
80
  hide();
65
81
  }
66
82
 
83
+ function onSearchInput() {
84
+ active = choices.length ? 0 : -1;
85
+ }
86
+
67
87
  function onTriggerKeydown(event: KeyboardEvent) {
68
88
  if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
69
89
  event.preventDefault();
70
90
  void show();
71
91
  }
72
92
 
73
- function onListKeydown(event: KeyboardEvent) {
93
+ function onSearchKeydown(event: KeyboardEvent) {
74
94
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
75
95
  active = moveIndex(active, event.key === 'ArrowDown' ? 1 : -1, choices.length);
76
- } else if (event.key === 'Enter' || event.key === ' ') {
96
+ } else if (event.key === 'Enter') {
77
97
  choose(choices[active]);
78
98
  } else if (event.key === 'Escape') {
79
99
  hide();
@@ -99,7 +119,7 @@
99
119
 
100
120
  {#snippet item(option: ChipSelectOption)}
101
121
  {@const index = choices.indexOf(option)}
102
- <!-- Listbox pattern: focus stays on the list, which handles arrow keys and Enter. -->
122
+ <!-- Combobox pattern: focus stays in the search field, which handles arrow keys and Enter. -->
103
123
  <!-- svelte-ignore a11y_click_events_have_key_events -->
104
124
  <li
105
125
  id={`${uid}-opt-${index}`}
@@ -112,6 +132,8 @@
112
132
  >
113
133
  {#if option === CLEAR}
114
134
  <span class="terminoir-chip-select__clear"><NavIcon name="xmark" /> {CLEAR.label}</span>
135
+ {:else if option.id === CREATE_ID}
136
+ <span class="terminoir-chip-select__clear">Create <Chip color="grey">{option.label}</Chip></span>
115
137
  {:else}
116
138
  <Chip color={option.color ?? 'grey'}>{option.label}</Chip>
117
139
  {#if option.id === value}<NavIcon name="check" class="terminoir-chip-select__check" />{/if}
@@ -137,21 +159,32 @@
137
159
  {/if}
138
160
  </button>
139
161
  {#if open}
140
- <ul
141
- bind:this={list}
142
- class="terminoir-chip-select__list glass-popover terminoir-focusable"
143
- role="listbox"
144
- aria-label={label}
145
- aria-activedescendant={active >= 0 ? `${uid}-opt-${active}` : undefined}
146
- tabindex="-1"
147
- onkeydown={onListKeydown}
148
- >
149
- {#each sections as section (section.id)}
150
- {#if section.label}<li role="presentation" class="terminoir-chip-select__heading">{section.label}</li>{/if}
151
- {#each section.options as option (option.id)}{@render item(option)}{/each}
152
- {/each}
153
- {#if clearable && selected}{@render item(CLEAR)}{/if}
154
- </ul>
162
+ <div class="terminoir-chip-select__popover glass-popover">
163
+ <input
164
+ bind:this={search}
165
+ class="terminoir-chip-select__search"
166
+ role="combobox"
167
+ aria-label={`Search ${label}`}
168
+ aria-controls={`${uid}-list`}
169
+ aria-expanded="true"
170
+ aria-autocomplete="list"
171
+ aria-activedescendant={active >= 0 ? `${uid}-opt-${active}` : undefined}
172
+ placeholder={oncreate ? 'Search or create…' : 'Search…'}
173
+ autocomplete="off"
174
+ bind:value={query}
175
+ oninput={onSearchInput}
176
+ onkeydown={onSearchKeydown}
177
+ />
178
+ <ul id={`${uid}-list`} class="terminoir-chip-select__list" role="listbox" aria-label={label}>
179
+ {#each sections as section (section.id)}
180
+ {#if section.label}<li role="presentation" class="terminoir-chip-select__heading">{section.label}</li>{/if}
181
+ {#each section.options as option (option.id)}{@render item(option)}{/each}
182
+ {/each}
183
+ {#if create}{@render item(create)}{/if}
184
+ {#if choices.includes(CLEAR)}{@render item(CLEAR)}{/if}
185
+ {#if !choices.length}<li role="presentation" class="terminoir-chip-select__heading">No matches</li>{/if}
186
+ </ul>
187
+ </div>
155
188
  {/if}
156
189
  </div>
157
190
 
@@ -183,16 +216,32 @@
183
216
  color: var(--muted-foreground);
184
217
  opacity: 0.7;
185
218
  }
186
- .terminoir-chip-select__list {
219
+ .terminoir-chip-select__popover {
187
220
  position: absolute;
188
221
  top: calc(100% + var(--space-1));
189
222
  left: 0;
190
223
  z-index: 40;
191
224
  min-width: 12rem;
225
+ padding: var(--space-1);
226
+ }
227
+ .terminoir-chip-select__search {
228
+ box-sizing: border-box;
229
+ width: 100%;
230
+ margin-bottom: var(--space-1);
231
+ padding: 0.35rem 0.5rem;
232
+ border: 1px solid color-mix(in srgb, var(--foreground) 12%, transparent);
233
+ border-radius: calc(var(--radius-control) - 0.2rem);
234
+ background: transparent;
235
+ color: var(--foreground);
236
+ font: inherit;
237
+ font-size: 0.8125rem;
238
+ }
239
+ .terminoir-chip-select__search:focus { outline: none; border-color: var(--ring); }
240
+ .terminoir-chip-select__list {
192
241
  max-height: 18rem;
193
242
  overflow-y: auto;
194
243
  margin: 0;
195
- padding: var(--space-1);
244
+ padding: 0;
196
245
  list-style: none;
197
246
  }
198
247
  .terminoir-chip-select__heading {
@@ -11,6 +11,8 @@ type $$ComponentProps = {
11
11
  /** Offer a "Clear" entry when something is selected. */
12
12
  clearable?: boolean;
13
13
  onchange: (value: string) => void;
14
+ /** Offers "Create …" for a search that names no option; called with the trimmed name. */
15
+ oncreate?: (label: string) => void;
14
16
  class?: string;
15
17
  };
16
18
  declare const ChipSelect: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -55,6 +55,14 @@
55
55
  }
56
56
  }
57
57
 
58
+ /**
59
+ * Clicking away saves, but only once the event that moved focus is over: a page that blurs
60
+ * fields on Escape (in the capture phase) must not save what Escape is about to cancel.
61
+ */
62
+ function commitAfterBlur() {
63
+ setTimeout(() => void commit(false));
64
+ }
65
+
58
66
  function onkeydown(event: KeyboardEvent) {
59
67
  if (event.key === 'Enter') {
60
68
  event.preventDefault();
@@ -75,7 +83,7 @@
75
83
  bind:value={draft}
76
84
  use:focusAndSelect
77
85
  {onkeydown}
78
- onblur={() => void commit(false)}
86
+ onblur={commitAfterBlur}
79
87
  />
80
88
  {:else}
81
89
  <button
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { MenuItem } from '../utils/menu.js';
3
+ import { labelColor } from '../utils/palette.js';
3
4
  import NavIcon from './NavIcon.svelte';
4
5
 
5
6
  let { items, onselect }: { items: MenuItem[]; onselect: (item: MenuItem) => void } = $props();
@@ -9,19 +10,33 @@
9
10
  <li role="none">
10
11
  <button
11
12
  type="button"
12
- role="menuitem"
13
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
14
+ aria-checked={item.checked}
13
15
  class="terminoir-menu__item terminoir-focusable"
14
16
  class:terminoir-menu__item--danger={item.tone === 'danger'}
15
17
  disabled={item.disabled}
16
18
  onclick={() => onselect(item)}
17
19
  >
20
+ {#if item.color}<span class="terminoir-menu__dot" style:background={labelColor(item.color)} aria-hidden="true"></span>{/if}
18
21
  {#if item.icon}<NavIcon name={item.icon} />{/if}
19
22
  <span>{item.label}</span>
23
+ {#if item.checked}<NavIcon name="check" class="terminoir-menu__check" />{/if}
20
24
  </button>
21
25
  </li>
22
26
  {/each}
23
27
 
24
28
  <style>
29
+ .terminoir-menu__dot {
30
+ flex-shrink: 0;
31
+ width: 0.6rem;
32
+ height: 0.6rem;
33
+ border-radius: 999px;
34
+ }
35
+ .terminoir-menu__item :global(.terminoir-menu__check) {
36
+ margin-left: auto;
37
+ font-size: 0.75rem;
38
+ color: var(--muted-foreground);
39
+ }
25
40
  .terminoir-menu__item {
26
41
  display: flex;
27
42
  align-items: center;
@@ -0,0 +1,218 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+ import { moveTo, nearestIndex } from '../utils/layout.js';
5
+ import type { MenuItem } from '../utils/menu.js';
6
+ import { nextOptionColor, optionLabelError, type EditableOption } from '../utils/options.js';
7
+ import { LABEL_PALETTE } from '../utils/palette.js';
8
+ import Chip from './Chip.svelte';
9
+ import DropdownMenu from './DropdownMenu.svelte';
10
+ import EditableText from './EditableText.svelte';
11
+ import FieldMessage from './FieldMessage.svelte';
12
+ import Input from './Input.svelte';
13
+ import NavIcon from './NavIcon.svelte';
14
+
15
+ let {
16
+ label,
17
+ options,
18
+ onchange,
19
+ colors = Object.keys(LABEL_PALETTE),
20
+ placeholder = 'Add an option…',
21
+ trailing,
22
+ class: className = '',
23
+ }: {
24
+ /** Accessible name of the list, e.g. "Status options". */
25
+ label: string;
26
+ options: EditableOption[];
27
+ /** The whole list after any change: add, rename, recolour, reorder or delete. New options get their name as id. */
28
+ onchange: (options: EditableOption[]) => void;
29
+ /** Palette names offered in each option's menu. */
30
+ colors?: readonly string[];
31
+ placeholder?: string;
32
+ /** Extra control per row, before its menu (e.g. a status group). */
33
+ trailing?: Snippet<[EditableOption, number]>;
34
+ class?: string;
35
+ } = $props();
36
+
37
+ const uid = $props.id();
38
+ let draft = $state<EditableOption[] | null>(null);
39
+ const shown = $derived(draft ?? options);
40
+ let newLabel = $state('');
41
+ let error = $state<string | undefined>();
42
+ let list: HTMLUListElement | undefined = $state();
43
+
44
+ function add() {
45
+ const name = newLabel.trim();
46
+ if (!name) return;
47
+ error = optionLabelError(options, name) ?? undefined;
48
+ if (error) return;
49
+ onchange([...options, { id: name, label: name, color: nextOptionColor(options, colors) }]);
50
+ newLabel = '';
51
+ }
52
+
53
+ function update(id: string, patch: Partial<EditableOption>) {
54
+ onchange(options.map((o) => (o.id === id ? { ...o, ...patch } : o)));
55
+ }
56
+
57
+ function rename(option: EditableOption, name: string) {
58
+ error = optionLabelError(options, name, option.id) ?? undefined;
59
+ if (!error) update(option.id, { label: name });
60
+ }
61
+
62
+ function remove(id: string) {
63
+ onchange(options.filter((o) => o.id !== id));
64
+ }
65
+
66
+ function moveBy(id: string, delta: number) {
67
+ const from = options.findIndex((o) => o.id === id);
68
+ onchange(moveTo(options, id, from + delta));
69
+ }
70
+
71
+ function startDrag(event: PointerEvent, id: string) {
72
+ const handle = event.currentTarget as HTMLElement;
73
+ handle.setPointerCapture(event.pointerId);
74
+ draft = options.slice();
75
+ const rows = () => [...(list?.querySelectorAll<HTMLElement>(':scope > li') ?? [])];
76
+ const move = (e: PointerEvent) => {
77
+ const centres = rows().map((row) => {
78
+ const r = row.getBoundingClientRect();
79
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
80
+ });
81
+ if (draft) draft = moveTo(draft, id, nearestIndex(centres, centres[0]?.x ?? 0, e.clientY));
82
+ };
83
+ const end = () => {
84
+ handle.removeEventListener('pointermove', move);
85
+ handle.removeEventListener('pointerup', end);
86
+ handle.removeEventListener('pointercancel', end);
87
+ if (draft) onchange(draft);
88
+ draft = null;
89
+ };
90
+ handle.addEventListener('pointermove', move);
91
+ handle.addEventListener('pointerup', end);
92
+ handle.addEventListener('pointercancel', end);
93
+ }
94
+
95
+ function keyMove(event: KeyboardEvent, id: string) {
96
+ if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
97
+ event.preventDefault();
98
+ moveBy(id, event.key === 'ArrowUp' ? -1 : 1);
99
+ }
100
+
101
+ function capitalized(name: string): string {
102
+ return name.charAt(0).toUpperCase() + name.slice(1);
103
+ }
104
+
105
+ function menu(option: EditableOption, index: number): MenuItem[] {
106
+ return [
107
+ { id: 'up', label: 'Move up', icon: 'arrow-up', disabled: index === 0, onSelect: () => moveBy(option.id, -1) },
108
+ { id: 'down', label: 'Move down', icon: 'arrow-down', disabled: index === options.length - 1, onSelect: () => moveBy(option.id, 1) },
109
+ ...colors.map((color) => ({
110
+ id: `color-${color}`,
111
+ label: capitalized(color),
112
+ color,
113
+ checked: (option.color ?? 'grey') === color,
114
+ onSelect: () => update(option.id, { color }),
115
+ })),
116
+ { id: 'delete', label: 'Delete', icon: 'trash', tone: 'danger' as const, onSelect: () => remove(option.id) },
117
+ ];
118
+ }
119
+
120
+ function onAddKeydown(event: KeyboardEvent) {
121
+ if (event.key !== 'Enter') return;
122
+ event.preventDefault();
123
+ add();
124
+ }
125
+ </script>
126
+
127
+ <div class={cn('terminoir-options', className)}>
128
+ {#if shown.length}
129
+ <ul bind:this={list} class={cn('terminoir-options__list', draft && 'terminoir-options__list--dragging')} aria-label={label}>
130
+ {#each shown as option, index (option.id)}
131
+ <li class="terminoir-options__row">
132
+ <button
133
+ type="button"
134
+ class="terminoir-options__handle terminoir-focusable"
135
+ aria-label={`Move ${option.label} (drag, or use up and down arrows)`}
136
+ title="Drag to reorder"
137
+ onpointerdown={(event) => startDrag(event, option.id)}
138
+ onkeydown={(event) => keyMove(event, option.id)}
139
+ >
140
+ <NavIcon name="grip-vertical" />
141
+ </button>
142
+ <span class="terminoir-options__name">
143
+ <Chip color={option.color ?? 'grey'}>
144
+ <EditableText value={option.label} label={`Rename ${option.label}`} onrename={(name) => rename(option, name)} />
145
+ </Chip>
146
+ </span>
147
+ {#if trailing}<span class="terminoir-options__trailing">{@render trailing(option, index)}</span>{/if}
148
+ <DropdownMenu label={`${option.label} settings`} icon="ellipsis" iconOnly align="end" items={menu(option, index)} />
149
+ </li>
150
+ {/each}
151
+ </ul>
152
+ {/if}
153
+ <Input
154
+ label={`Add to ${label}`}
155
+ hideLabel
156
+ quiet
157
+ {placeholder}
158
+ autocomplete="off"
159
+ bind:value={newLabel}
160
+ oninput={() => (error = undefined)}
161
+ onkeydown={onAddKeydown}
162
+ onblur={add}
163
+ aria-describedby={error ? `${uid}-error` : undefined}
164
+ />
165
+ <FieldMessage id={`${uid}-error`} {error} />
166
+ </div>
167
+
168
+ <style>
169
+ .terminoir-options {
170
+ display: flex;
171
+ flex-direction: column;
172
+ gap: var(--space-1);
173
+ min-width: 0;
174
+ }
175
+ .terminoir-options__list {
176
+ display: flex;
177
+ flex-direction: column;
178
+ margin: 0;
179
+ padding: 0;
180
+ list-style: none;
181
+ }
182
+ .terminoir-options__list--dragging { cursor: grabbing; user-select: none; }
183
+ .terminoir-options__row {
184
+ display: flex;
185
+ align-items: center;
186
+ gap: var(--space-2);
187
+ min-height: 2.25rem;
188
+ min-width: 0;
189
+ border-radius: var(--radius-control);
190
+ }
191
+ .terminoir-options__row:hover { background: color-mix(in srgb, var(--foreground) 4%, transparent); }
192
+ .terminoir-options__handle {
193
+ display: inline-flex;
194
+ align-items: center;
195
+ justify-content: center;
196
+ width: 1.5rem;
197
+ height: 1.75rem;
198
+ padding: 0;
199
+ border: 0;
200
+ border-radius: var(--radius-control);
201
+ background: transparent;
202
+ color: var(--muted-foreground);
203
+ cursor: grab;
204
+ touch-action: none;
205
+ }
206
+ .terminoir-options__name {
207
+ flex: 1;
208
+ min-width: 0;
209
+ display: flex;
210
+ }
211
+ .terminoir-options__name :global(.terminoir-chip) { max-width: 100%; min-width: 0; }
212
+ .terminoir-options__name :global(.terminoir-chip__label) { min-width: 0; overflow: hidden; }
213
+ .terminoir-options__trailing {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ flex-shrink: 0;
217
+ }
218
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type EditableOption } from '../utils/options.js';
3
+ type $$ComponentProps = {
4
+ /** Accessible name of the list, e.g. "Status options". */
5
+ label: string;
6
+ options: EditableOption[];
7
+ /** The whole list after any change: add, rename, recolour, reorder or delete. New options get their name as id. */
8
+ onchange: (options: EditableOption[]) => void;
9
+ /** Palette names offered in each option's menu. */
10
+ colors?: readonly string[];
11
+ placeholder?: string;
12
+ /** Extra control per row, before its menu (e.g. a status group). */
13
+ trailing?: Snippet<[EditableOption, number]>;
14
+ class?: string;
15
+ };
16
+ declare const OptionsEditor: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type OptionsEditor = ReturnType<typeof OptionsEditor>;
18
+ export default OptionsEditor;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
3
  import { cn } from '../utils/cn.js';
4
+ import EditableText from './EditableText.svelte';
4
5
  import NavIcon from './NavIcon.svelte';
5
6
 
6
7
  let {
@@ -8,6 +9,7 @@
8
9
  icon,
9
10
  class: className = '',
10
11
  children,
12
+ onrename,
11
13
  }: {
12
14
  label: string;
13
15
  /** NavIcon name hinting at the value's kind, e.g. `calendar`. */
@@ -15,13 +17,17 @@
15
17
  class?: string;
16
18
  /** One value control, rendered with its own label hidden. */
17
19
  children?: Snippet;
20
+ /** Makes the label editable in place (renames the property). */
21
+ onrename?: (label: string) => void | Promise<void>;
18
22
  } = $props();
19
23
  </script>
20
24
 
21
25
  <div class={cn('terminoir-property-row', className)}>
22
26
  <span class="terminoir-property-row__label" title={label}>
23
27
  {#if icon}<NavIcon name={icon} />{/if}
24
- <span class="terminoir-property-row__text">{label}</span>
28
+ <span class={cn('terminoir-property-row__text', onrename && 'terminoir-property-row__text--editable')}>
29
+ {#if onrename}<EditableText value={label} label={`Rename ${label}`} {onrename} />{:else}{label}{/if}
30
+ </span>
25
31
  </span>
26
32
  <div class="terminoir-property-row__value">{@render children?.()}</div>
27
33
  </div>
@@ -62,6 +68,8 @@
62
68
  text-overflow: ellipsis;
63
69
  white-space: nowrap;
64
70
  }
71
+ /* Room for the rename field's border, which the truncating label would clip. */
72
+ .terminoir-property-row__text--editable { padding: 1px 0.3rem; margin: -1px -0.3rem; }
65
73
  .terminoir-property-row__value {
66
74
  min-width: 0;
67
75
  }
@@ -6,6 +6,8 @@ type $$ComponentProps = {
6
6
  class?: string;
7
7
  /** One value control, rendered with its own label hidden. */
8
8
  children?: Snippet;
9
+ /** Makes the label editable in place (renames the property). */
10
+ onrename?: (label: string) => void | Promise<void>;
9
11
  };
10
12
  declare const PropertyRow: import("svelte").Component<$$ComponentProps, {}, "">;
11
13
  type PropertyRow = ReturnType<typeof PropertyRow>;
package/dist/index.d.ts CHANGED
@@ -85,6 +85,8 @@ export { default as MarkdownEditor } from './components/MarkdownEditor.svelte';
85
85
  export { default as FileButton } from './components/FileButton.svelte';
86
86
  export { default as TitleInput } from './components/TitleInput.svelte';
87
87
  export { default as EditableText } from './components/EditableText.svelte';
88
+ export { default as OptionsEditor } from './components/OptionsEditor.svelte';
89
+ export type { EditableOption } from './utils/options.js';
88
90
  export type { PickerOption } from './utils/componentTypes.js';
89
91
  export { default as RadioCards } from './components/RadioCards.svelte';
90
92
  export type { RadioCardOption } from './utils/componentTypes.js';
package/dist/index.js CHANGED
@@ -77,6 +77,7 @@ export { default as MarkdownEditor } from './components/MarkdownEditor.svelte';
77
77
  export { default as FileButton } from './components/FileButton.svelte';
78
78
  export { default as TitleInput } from './components/TitleInput.svelte';
79
79
  export { default as EditableText } from './components/EditableText.svelte';
80
+ export { default as OptionsEditor } from './components/OptionsEditor.svelte';
80
81
  export { default as RadioCards } from './components/RadioCards.svelte';
81
82
  export { default as SegmentedControl } from './components/SegmentedControl.svelte';
82
83
  // Overlays
@@ -6,5 +6,9 @@ export type MenuItem = {
6
6
  /** `danger` for destructive actions (delete, revoke). */
7
7
  tone?: 'default' | 'danger';
8
8
  disabled?: boolean;
9
+ /** A colour dot before the label (palette name or CSS colour), e.g. to pick a colour. */
10
+ color?: string;
11
+ /** Marks the current choice with a check after the label. */
12
+ checked?: boolean;
9
13
  onSelect?: () => void;
10
14
  };
@@ -0,0 +1,19 @@
1
+ /** Helpers for user-defined option lists (select and status values). */
2
+ export type EditableOption = {
3
+ id: string;
4
+ label: string;
5
+ /** Palette name or CSS colour; grey when unset. */
6
+ color?: string;
7
+ };
8
+ /** Why `label` cannot name an option (blank, or another option has it), or null when it can. */
9
+ export declare function optionLabelError(options: EditableOption[], label: string, exceptId?: string): string | null;
10
+ /** The colour a new option gets: the palette in turn, so neighbours differ. */
11
+ export declare function nextOptionColor(options: EditableOption[], colors: readonly string[]): string | undefined;
12
+ /** Whether a search names an option exactly (ignoring case and spaces), so no "Create" is offered. */
13
+ export declare function namesOption(options: {
14
+ label: string;
15
+ }[], query: string): boolean;
16
+ /** Options whose label contains the search (ignoring case); all of them for an empty search. */
17
+ export declare function matchingOptions<T extends {
18
+ label: string;
19
+ }>(options: T[], query: string): T[];
@@ -0,0 +1,23 @@
1
+ /** Helpers for user-defined option lists (select and status values). */
2
+ /** Why `label` cannot name an option (blank, or another option has it), or null when it can. */
3
+ export function optionLabelError(options, label, exceptId) {
4
+ const name = label.trim().toLowerCase();
5
+ if (!name)
6
+ return 'Give the option a name.';
7
+ const taken = options.some((o) => o.id !== exceptId && o.label.trim().toLowerCase() === name);
8
+ return taken ? 'There is already an option with that name.' : null;
9
+ }
10
+ /** The colour a new option gets: the palette in turn, so neighbours differ. */
11
+ export function nextOptionColor(options, colors) {
12
+ return colors.length ? colors[options.length % colors.length] : undefined;
13
+ }
14
+ /** Whether a search names an option exactly (ignoring case and spaces), so no "Create" is offered. */
15
+ export function namesOption(options, query) {
16
+ const name = query.trim().toLowerCase();
17
+ return options.some((o) => o.label.trim().toLowerCase() === name);
18
+ }
19
+ /** Options whose label contains the search (ignoring case); all of them for an empty search. */
20
+ export function matchingOptions(options, query) {
21
+ const needle = query.trim().toLowerCase();
22
+ return needle ? options.filter((o) => o.label.toLowerCase().includes(needle)) : options;
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terminoir",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Terminoir — Liquid Glass design tokens and Svelte 5 UI components.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",