terminoir 0.6.0 → 0.8.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.
@@ -0,0 +1,186 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+ import {
4
+ describe,
5
+ emptyNode,
6
+ groupOps,
7
+ isComplete,
8
+ opsFor,
9
+ paramText,
10
+ parseParam,
11
+ setAt,
12
+ type ExpressionField,
13
+ type ExpressionKind,
14
+ type ExpressionNode,
15
+ type ExpressionOp,
16
+ type ExpressionPath,
17
+ type ExpressionSlot,
18
+ } from '../utils/expression.js';
19
+ import IconButton from './IconButton.svelte';
20
+ import NavIcon from './NavIcon.svelte';
21
+
22
+ /**
23
+ * Scratch-style block editor for a typed expression tree. Each block is one node; its slots only
24
+ * offer ops whose result fits, so the tree is always well-typed. A plain-English reading sits
25
+ * under the blocks.
26
+ */
27
+ let {
28
+ ops,
29
+ fields = [],
30
+ value,
31
+ kind = 'boolean',
32
+ label,
33
+ onchange,
34
+ class: className = '',
35
+ }: {
36
+ /** The op catalogue, e.g. Oyster's `GET /expressions/ops`. */
37
+ ops: ExpressionOp[];
38
+ /** Fields a `key` param can name. */
39
+ fields?: ExpressionField[];
40
+ value: ExpressionNode | null;
41
+ /** What the whole expression must return. */
42
+ kind?: ExpressionKind;
43
+ /** Accessible name for the editor. */
44
+ label: string;
45
+ onchange: (value: ExpressionNode | null) => void;
46
+ class?: string;
47
+ } = $props();
48
+
49
+ const NONE = '';
50
+ const reading = $derived(value ? describe(value, ops, fields) : 'Nothing yet');
51
+ const complete = $derived(value ? isComplete(value, ops) : true);
52
+
53
+ function specOf(node: ExpressionNode | null): ExpressionOp | undefined {
54
+ return node ? ops.find((o) => o.op === node.op) : undefined;
55
+ }
56
+
57
+ function update(path: ExpressionPath, next: unknown) {
58
+ onchange(setAt(value, path, next) as ExpressionNode | null);
59
+ }
60
+
61
+ function choose(path: ExpressionPath, opName: string) {
62
+ const spec = ops.find((o) => o.op === opName);
63
+ update(path, spec ? emptyNode(spec) : null);
64
+ }
65
+ </script>
66
+
67
+ {#snippet picker(slotKind: ExpressionKind, path: ExpressionPath, current: string, pickerLabel: string)}
68
+ <select
69
+ class="terminoir-select terminoir-expr__pick terminoir-focusable"
70
+ aria-label={pickerLabel}
71
+ value={current}
72
+ onchange={(e) => {
73
+ const select = e.currentTarget as HTMLSelectElement;
74
+ choose(path, select.value);
75
+ // An "add" picker (no current op) goes back to its prompt for the next entry.
76
+ if (!current) select.value = NONE;
77
+ }}
78
+ >
79
+ <option value={NONE}>{current ? 'Remove' : 'Choose…'}</option>
80
+ {#each groupOps(opsFor(slotKind, ops)) as group (group.group)}
81
+ <optgroup label={group.group}>
82
+ {#each group.ops as op (op.op)}<option value={op.op}>{op.label}</option>{/each}
83
+ </optgroup>
84
+ {/each}
85
+ </select>
86
+ {/snippet}
87
+
88
+ {#snippet param(node: ExpressionNode, name: string, path: ExpressionPath)}
89
+ {#if name === 'key'}
90
+ <select
91
+ class="terminoir-select terminoir-expr__pick terminoir-focusable"
92
+ aria-label="Field"
93
+ value={String(node.key ?? '')}
94
+ onchange={(e) => update([...path, 'key'], (e.currentTarget as HTMLSelectElement).value)}
95
+ >
96
+ <option value="">Field…</option>
97
+ {#each fields as field (field.key)}<option value={field.key}>{field.label ?? field.key}</option>{/each}
98
+ </select>
99
+ {:else}
100
+ <input
101
+ class="terminoir-input terminoir-expr__param terminoir-focusable"
102
+ aria-label={name}
103
+ placeholder={name === 'options' ? 'a, b, c' : name}
104
+ value={paramText(node[name])}
105
+ onchange={(e) => update([...path, name], parseParam(name, (e.currentTarget as HTMLInputElement).value))}
106
+ />
107
+ {/if}
108
+ {/snippet}
109
+
110
+ {#snippet slotView(node: ExpressionNode, slot: ExpressionSlot, path: ExpressionPath)}
111
+ <div class="terminoir-expr__slot">
112
+ <span class="terminoir-expr__slot-name">{slot.name}</span>
113
+ {#if slot.many}
114
+ {@const list = Array.isArray(node[slot.name]) ? (node[slot.name] as ExpressionNode[]) : []}
115
+ <div class="terminoir-expr__list">
116
+ {#each list as child, i (i)}
117
+ <div class="terminoir-expr__entry">
118
+ {@render block(child, slot.kind, [...path, slot.name, i])}
119
+ <IconButton label={`Remove ${slot.name} ${i + 1}`} size="sm" onclick={() => update([...path, slot.name, i], undefined)}>
120
+ <NavIcon name="xmark" />
121
+ </IconButton>
122
+ </div>
123
+ {/each}
124
+ {@render picker(slot.kind, [...path, slot.name, list.length], NONE, `Add to ${slot.name}`)}
125
+ </div>
126
+ {:else}
127
+ {@render block((node[slot.name] as ExpressionNode | null) ?? null, slot.kind, [...path, slot.name])}
128
+ {/if}
129
+ </div>
130
+ {/snippet}
131
+
132
+ {#snippet block(node: ExpressionNode | null, slotKind: ExpressionKind, path: ExpressionPath)}
133
+ {@const spec = specOf(node)}
134
+ <div class="terminoir-expr__block" class:terminoir-expr__block--empty={!node} data-kind={spec?.returns ?? slotKind}>
135
+ <div class="terminoir-expr__head">
136
+ {@render picker(slotKind, path, node?.op ?? NONE, path.length ? `Block at ${path.join(' ')}` : 'Expression')}
137
+ {#if node && spec}
138
+ {#each spec.params as name (name)}{@render param(node, name, path)}{/each}
139
+ {/if}
140
+ </div>
141
+ {#if node && spec?.slots.length}
142
+ <div class="terminoir-expr__slots">
143
+ {#each spec.slots as slot (slot.name)}{@render slotView(node, slot, path)}{/each}
144
+ </div>
145
+ {/if}
146
+ </div>
147
+ {/snippet}
148
+
149
+ <div class={cn('terminoir-expr', className)} role="group" aria-label={label}>
150
+ {@render block(value, kind, [])}
151
+ <p class="terminoir-expr__reading" class:terminoir-expr__reading--incomplete={!complete}>
152
+ {reading}{#if !complete} <span>(unfinished)</span>{/if}
153
+ </p>
154
+ </div>
155
+
156
+ <style>
157
+ .terminoir-expr { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; font-size: 0.8125rem; }
158
+ .terminoir-expr__block {
159
+ display: flex;
160
+ flex-direction: column;
161
+ gap: var(--space-2);
162
+ padding: var(--space-2);
163
+ border: 1px solid var(--border);
164
+ border-left: 3px solid var(--block-accent, var(--primary));
165
+ border-radius: var(--radius);
166
+ background: color-mix(in srgb, var(--foreground) 3%, transparent);
167
+ min-width: 0;
168
+ }
169
+ .terminoir-expr__block[data-kind='boolean'] { --block-accent: var(--primary); }
170
+ .terminoir-expr__block[data-kind='number'] { --block-accent: #3b82f6; }
171
+ .terminoir-expr__block[data-kind='date'] { --block-accent: #f59e0b; }
172
+ .terminoir-expr__block[data-kind='value'],
173
+ .terminoir-expr__block[data-kind='text'] { --block-accent: #10b981; }
174
+ .terminoir-expr__block--empty { border-style: dashed; background: transparent; }
175
+ .terminoir-expr__head { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
176
+ .terminoir-expr__pick { width: auto; min-width: 8rem; }
177
+ .terminoir-expr__param { width: 9rem; }
178
+ .terminoir-expr__slots { display: flex; flex-direction: column; gap: var(--space-2); padding-left: var(--space-3); }
179
+ .terminoir-expr__slot { display: flex; flex-direction: column; gap: var(--space-1); }
180
+ .terminoir-expr__slot-name { color: var(--muted-foreground); font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.04em; }
181
+ .terminoir-expr__list { display: flex; flex-direction: column; gap: var(--space-2); }
182
+ .terminoir-expr__entry { display: flex; gap: var(--space-1); align-items: flex-start; }
183
+ .terminoir-expr__entry > :first-child { flex: 1; }
184
+ .terminoir-expr__reading { margin: 0; color: var(--muted-foreground); }
185
+ .terminoir-expr__reading--incomplete span { color: var(--danger, #ef4444); }
186
+ </style>
@@ -0,0 +1,17 @@
1
+ import { type ExpressionField, type ExpressionKind, type ExpressionNode, type ExpressionOp } from '../utils/expression.js';
2
+ type $$ComponentProps = {
3
+ /** The op catalogue, e.g. Oyster's `GET /expressions/ops`. */
4
+ ops: ExpressionOp[];
5
+ /** Fields a `key` param can name. */
6
+ fields?: ExpressionField[];
7
+ value: ExpressionNode | null;
8
+ /** What the whole expression must return. */
9
+ kind?: ExpressionKind;
10
+ /** Accessible name for the editor. */
11
+ label: string;
12
+ onchange: (value: ExpressionNode | null) => void;
13
+ class?: string;
14
+ };
15
+ declare const ExpressionEditor: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type ExpressionEditor = ReturnType<typeof ExpressionEditor>;
17
+ export default ExpressionEditor;
@@ -0,0 +1,194 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+ import { moveTo, nearestIndex, spanAfterDrag, type GridItem } from '../utils/layout.js';
5
+ import NavIcon from './NavIcon.svelte';
6
+
7
+ /**
8
+ * Items in reading order on a column grid. Drag an item's move handle (render `handle` where it
9
+ * belongs, e.g. in a header) to reorder; drag its right edge to change its column span. Both
10
+ * have keyboard equivalents (arrow keys). `onchange` gets the new order and spans when a drag ends.
11
+ */
12
+ let {
13
+ items,
14
+ columns = 12,
15
+ minSpan = 3,
16
+ label,
17
+ onchange,
18
+ children,
19
+ class: className = '',
20
+ }: {
21
+ items: GridItem[];
22
+ columns?: number;
23
+ minSpan?: number;
24
+ /** Accessible name for the grid. */
25
+ label: string;
26
+ onchange: (items: GridItem[]) => void;
27
+ children: Snippet<[GridItem, Snippet]>;
28
+ class?: string;
29
+ } = $props();
30
+
31
+ let draft = $state<GridItem[] | null>(null);
32
+ const shown = $derived(draft ?? items);
33
+ let grid: HTMLDivElement | undefined = $state();
34
+
35
+ function cells(): HTMLElement[] {
36
+ return grid ? [...grid.querySelectorAll<HTMLElement>(':scope > .terminoir-grid__cell')] : [];
37
+ }
38
+
39
+ function commit() {
40
+ if (draft) onchange(draft);
41
+ draft = null;
42
+ }
43
+
44
+ function startMove(event: PointerEvent, id: string) {
45
+ const handle = event.currentTarget as HTMLElement;
46
+ handle.setPointerCapture(event.pointerId);
47
+ draft = items.slice();
48
+ const move = (e: PointerEvent) => {
49
+ const centres = cells().map((cell) => {
50
+ const r = cell.getBoundingClientRect();
51
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
52
+ });
53
+ if (draft) draft = moveTo(draft, id, nearestIndex(centres, e.clientX, e.clientY));
54
+ };
55
+ const end = () => {
56
+ handle.removeEventListener('pointermove', move);
57
+ handle.removeEventListener('pointerup', end);
58
+ handle.removeEventListener('pointercancel', end);
59
+ commit();
60
+ };
61
+ handle.addEventListener('pointermove', move);
62
+ handle.addEventListener('pointerup', end);
63
+ handle.addEventListener('pointercancel', end);
64
+ }
65
+
66
+ function startResize(event: PointerEvent, item: GridItem) {
67
+ const edge = event.currentTarget as HTMLElement;
68
+ edge.setPointerCapture(event.pointerId);
69
+ const startX = event.clientX;
70
+ const columnWidth = (grid?.clientWidth ?? 0) / columns;
71
+ draft = items.slice();
72
+ const move = (e: PointerEvent) => {
73
+ const span = spanAfterDrag(item.span, e.clientX - startX, columnWidth, minSpan, columns);
74
+ if (draft) draft = draft.map((i) => (i.id === item.id ? { ...i, span } : i));
75
+ };
76
+ const end = () => {
77
+ edge.removeEventListener('pointermove', move);
78
+ edge.removeEventListener('pointerup', end);
79
+ edge.removeEventListener('pointercancel', end);
80
+ commit();
81
+ };
82
+ edge.addEventListener('pointermove', move);
83
+ edge.addEventListener('pointerup', end);
84
+ edge.addEventListener('pointercancel', end);
85
+ }
86
+
87
+ function keyMove(event: KeyboardEvent, id: string) {
88
+ const step = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 }[event.key];
89
+ if (!step) return;
90
+ event.preventDefault();
91
+ const index = items.findIndex((i) => i.id === id);
92
+ onchange(moveTo(items, id, index + step));
93
+ }
94
+
95
+ function keyResize(event: KeyboardEvent, item: GridItem) {
96
+ const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
97
+ if (!step) return;
98
+ event.preventDefault();
99
+ const span = Math.max(minSpan, Math.min(columns, item.span + step));
100
+ if (span !== item.span) onchange(items.map((i) => (i.id === item.id ? { ...i, span } : i)));
101
+ }
102
+ </script>
103
+
104
+ <div
105
+ bind:this={grid}
106
+ class={cn('terminoir-grid', draft && 'terminoir-grid--dragging', className)}
107
+ style:--grid-columns={columns}
108
+ role="list"
109
+ aria-label={label}
110
+ >
111
+ {#each shown as item (item.id)}
112
+ {#snippet handle()}
113
+ <button
114
+ type="button"
115
+ class="terminoir-grid__move terminoir-focusable"
116
+ aria-label="Move (drag, or use arrow keys)"
117
+ title="Drag to move"
118
+ onpointerdown={(event) => startMove(event, item.id)}
119
+ onkeydown={(event) => keyMove(event, item.id)}
120
+ >
121
+ <NavIcon name="grip-vertical" />
122
+ </button>
123
+ {/snippet}
124
+ <div class="terminoir-grid__cell" style:--span={Math.min(item.span, columns)} role="listitem">
125
+ {@render children(item, handle)}
126
+ <button
127
+ type="button"
128
+ class="terminoir-grid__resize terminoir-focusable"
129
+ aria-label={`Width ${item.span} of ${columns} columns (drag, or use left and right arrows)`}
130
+ title="Drag to resize"
131
+ onpointerdown={(event) => startResize(event, item)}
132
+ onkeydown={(event) => keyResize(event, item)}
133
+ ></button>
134
+ </div>
135
+ {/each}
136
+ </div>
137
+
138
+ <style>
139
+ .terminoir-grid {
140
+ display: grid;
141
+ grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr));
142
+ gap: var(--space-4);
143
+ align-items: start;
144
+ min-width: 0;
145
+ }
146
+ .terminoir-grid__cell { position: relative; grid-column: span var(--span); min-width: 0; }
147
+ @media (max-width: 700px) {
148
+ .terminoir-grid__cell { grid-column: 1 / -1; }
149
+ .terminoir-grid__resize { display: none; }
150
+ }
151
+ .terminoir-grid--dragging { user-select: none; }
152
+ .terminoir-grid--dragging .terminoir-grid__cell { transition: none; }
153
+ .terminoir-grid__move {
154
+ display: inline-flex;
155
+ align-items: center;
156
+ justify-content: center;
157
+ width: 1.5rem;
158
+ height: 1.5rem;
159
+ padding: 0;
160
+ border: 0;
161
+ border-radius: var(--radius-sm, 0.375rem);
162
+ background: transparent;
163
+ color: var(--muted-foreground);
164
+ cursor: grab;
165
+ touch-action: none;
166
+ }
167
+ .terminoir-grid__move:active { cursor: grabbing; }
168
+ .terminoir-grid__move:hover { color: var(--foreground); background: color-mix(in srgb, var(--foreground) 8%, transparent); }
169
+ .terminoir-grid__resize {
170
+ position: absolute;
171
+ top: var(--space-3);
172
+ bottom: var(--space-3);
173
+ right: calc(var(--space-2) * -1);
174
+ width: var(--space-3);
175
+ padding: 0;
176
+ border: 0;
177
+ background: transparent;
178
+ cursor: ew-resize;
179
+ touch-action: none;
180
+ }
181
+ .terminoir-grid__resize::after {
182
+ content: '';
183
+ position: absolute;
184
+ top: 0;
185
+ bottom: 0;
186
+ left: 50%;
187
+ width: 2px;
188
+ border-radius: 1px;
189
+ background: transparent;
190
+ transition: background 120ms ease;
191
+ }
192
+ .terminoir-grid__resize:hover::after,
193
+ .terminoir-grid__resize:focus-visible::after { background: var(--primary); }
194
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type GridItem } from '../utils/layout.js';
3
+ type $$ComponentProps = {
4
+ items: GridItem[];
5
+ columns?: number;
6
+ minSpan?: number;
7
+ /** Accessible name for the grid. */
8
+ label: string;
9
+ onchange: (items: GridItem[]) => void;
10
+ children: Snippet<[GridItem, Snippet]>;
11
+ class?: string;
12
+ };
13
+ declare const GridLayout: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type GridLayout = ReturnType<typeof GridLayout>;
15
+ export default GridLayout;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { cn } from '../utils/cn.js';
4
+ import NavIcon from './NavIcon.svelte';
5
+
6
+ let {
7
+ label,
8
+ icon,
9
+ class: className = '',
10
+ children,
11
+ }: {
12
+ label: string;
13
+ /** NavIcon name hinting at the value's kind, e.g. `calendar`. */
14
+ icon?: string;
15
+ class?: string;
16
+ /** One value control, rendered with its own label hidden. */
17
+ children?: Snippet;
18
+ } = $props();
19
+ </script>
20
+
21
+ <div class={cn('terminoir-property-row', className)}>
22
+ <span class="terminoir-property-row__label" title={label}>
23
+ {#if icon}<NavIcon name={icon} />{/if}
24
+ <span class="terminoir-property-row__text">{label}</span>
25
+ </span>
26
+ <div class="terminoir-property-row__value">{@render children?.()}</div>
27
+ </div>
28
+
29
+ <style>
30
+ .terminoir-property-row {
31
+ display: grid;
32
+ grid-template-columns: minmax(6.5rem, 9.5rem) minmax(0, 1fr);
33
+ align-items: center;
34
+ gap: var(--space-3);
35
+ min-height: 2.25rem;
36
+ padding-inline: var(--space-2);
37
+ margin-inline: calc(-1 * var(--space-2));
38
+ border-radius: var(--radius-control);
39
+ transition: background 120ms ease;
40
+ }
41
+ .terminoir-property-row:hover,
42
+ .terminoir-property-row:focus-within {
43
+ background: color-mix(in srgb, var(--foreground) 4%, transparent);
44
+ }
45
+ .terminoir-property-row__label {
46
+ display: flex;
47
+ align-items: center;
48
+ gap: var(--space-2);
49
+ min-width: 0;
50
+ font-size: 0.8125rem;
51
+ color: var(--muted-foreground);
52
+ }
53
+ .terminoir-property-row__label :global(i) {
54
+ width: 1rem;
55
+ flex-shrink: 0;
56
+ text-align: center;
57
+ font-size: 0.75rem;
58
+ opacity: 0.75;
59
+ }
60
+ .terminoir-property-row__text {
61
+ overflow: hidden;
62
+ text-overflow: ellipsis;
63
+ white-space: nowrap;
64
+ }
65
+ .terminoir-property-row__value {
66
+ min-width: 0;
67
+ }
68
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ label: string;
4
+ /** NavIcon name hinting at the value's kind, e.g. `calendar`. */
5
+ icon?: string;
6
+ class?: string;
7
+ /** One value control, rendered with its own label hidden. */
8
+ children?: Snippet;
9
+ };
10
+ declare const PropertyRow: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type PropertyRow = ReturnType<typeof PropertyRow>;
12
+ export default PropertyRow;
@@ -136,6 +136,8 @@
136
136
  </div>
137
137
 
138
138
  <style>
139
+ /* In a row (e.g. after selected chips) the search box takes the remaining width. */
140
+ .terminoir-picker { flex: 1 1 8rem; min-width: 8rem; }
139
141
  .terminoir-picker__anchor { position: relative; }
140
142
  .terminoir-picker__list {
141
143
  position: absolute;
@@ -0,0 +1,130 @@
1
+ <script lang="ts">
2
+ import { cn } from '../utils/cn.js';
3
+ import { labelColor } from '../utils/palette.js';
4
+ import { timelineRows, timelineScale, todayOffset, type TimelineItem } from '../utils/timeline.js';
5
+
6
+ /** Gantt-style bars over a shared day scale; one row per item, grouped under headings. */
7
+ let {
8
+ items,
9
+ label,
10
+ onItemClick,
11
+ class: className = '',
12
+ }: {
13
+ items: TimelineItem[];
14
+ /** Accessible name for the chart. */
15
+ label: string;
16
+ onItemClick?: (item: TimelineItem) => void;
17
+ class?: string;
18
+ } = $props();
19
+
20
+ const scale = $derived(timelineScale(items));
21
+ const rows = $derived(timelineRows(items, scale));
22
+ const today = $derived(todayOffset(scale));
23
+
24
+ function day(d: Date): string {
25
+ return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
26
+ }
27
+
28
+ function range(item: TimelineItem): string {
29
+ return item.end && item.end.getTime() !== item.start.getTime() ? `${day(item.start)} – ${day(item.end)}` : day(item.start);
30
+ }
31
+ </script>
32
+
33
+ <div class={cn('terminoir-timeline', className)} role="group" aria-label={label}>
34
+ <div class="terminoir-timeline__grid">
35
+ <div class="terminoir-timeline__corner" aria-hidden="true"></div>
36
+ <div class="terminoir-timeline__scale" aria-hidden="true">
37
+ {#each scale.ticks as tick (tick.date.getTime())}
38
+ <span class="terminoir-timeline__tick" style:left={`${tick.left}%`}>{day(tick.date)}</span>
39
+ {/each}
40
+ </div>
41
+ {#each rows as row (row.group ?? '')}
42
+ {#if row.group}<div class="terminoir-timeline__group">{row.group}</div>{/if}
43
+ {#each row.bars as bar (bar.item.id)}
44
+ <div class="terminoir-timeline__label" title={bar.item.title}>{bar.item.title}</div>
45
+ <div class="terminoir-timeline__track">
46
+ {#if today !== null}<div class="terminoir-timeline__today" style:left={`${today}%`} aria-hidden="true"></div>{/if}
47
+ <button
48
+ type="button"
49
+ class="terminoir-timeline__bar terminoir-focusable"
50
+ style:left={`${bar.left}%`}
51
+ style:width={`${bar.width}%`}
52
+ style:--bar-color={labelColor(bar.item.color) ?? 'var(--primary)'}
53
+ aria-label={`${bar.item.title}, ${range(bar.item)}`}
54
+ title={`${bar.item.title} · ${range(bar.item)}`}
55
+ disabled={!onItemClick}
56
+ onclick={() => onItemClick?.(bar.item)}
57
+ ></button>
58
+ </div>
59
+ {/each}
60
+ {/each}
61
+ </div>
62
+ </div>
63
+
64
+ <style>
65
+ .terminoir-timeline {
66
+ --timeline-row: 1.75rem;
67
+ --timeline-label: minmax(8rem, 14rem);
68
+ font-size: 0.8125rem;
69
+ border: 1px solid var(--border);
70
+ border-radius: var(--radius);
71
+ overflow-x: auto;
72
+ min-width: 0;
73
+ }
74
+ .terminoir-timeline__grid {
75
+ display: grid;
76
+ grid-template-columns: var(--timeline-label) minmax(24rem, 1fr);
77
+ min-width: 0;
78
+ padding-bottom: var(--space-1);
79
+ }
80
+ .terminoir-timeline__corner,
81
+ .terminoir-timeline__scale { height: 1.75rem; border-bottom: 1px solid var(--border); }
82
+ .terminoir-timeline__scale { position: relative; color: var(--muted-foreground); font-size: 0.6875rem; }
83
+ .terminoir-timeline__tick {
84
+ position: absolute;
85
+ top: 0.5rem;
86
+ padding-left: 0.25rem;
87
+ border-left: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
88
+ white-space: nowrap;
89
+ }
90
+ .terminoir-timeline__group {
91
+ grid-column: 1 / -1;
92
+ padding: var(--space-2) var(--space-2) var(--space-1);
93
+ color: var(--muted-foreground);
94
+ font-size: 0.6875rem;
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.04em;
97
+ }
98
+ .terminoir-timeline__label {
99
+ display: flex;
100
+ align-items: center;
101
+ height: var(--timeline-row);
102
+ padding: 0 var(--space-2);
103
+ white-space: nowrap;
104
+ overflow: hidden;
105
+ text-overflow: ellipsis;
106
+ }
107
+ .terminoir-timeline__track { position: relative; height: var(--timeline-row); }
108
+ .terminoir-timeline__today {
109
+ position: absolute;
110
+ top: 0;
111
+ bottom: 0;
112
+ width: 1px;
113
+ background: var(--primary);
114
+ opacity: 0.5;
115
+ pointer-events: none;
116
+ }
117
+ .terminoir-timeline__bar {
118
+ position: absolute;
119
+ top: 0.375rem;
120
+ bottom: 0.375rem;
121
+ min-width: 0.5rem;
122
+ padding: 0;
123
+ border: 1px solid color-mix(in srgb, var(--bar-color) 70%, transparent);
124
+ border-radius: var(--radius-pill);
125
+ background: color-mix(in srgb, var(--bar-color) 45%, transparent);
126
+ cursor: pointer;
127
+ }
128
+ .terminoir-timeline__bar:disabled { cursor: default; }
129
+ .terminoir-timeline__bar:hover:not(:disabled) { background: color-mix(in srgb, var(--bar-color) 65%, transparent); }
130
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type TimelineItem } from '../utils/timeline.js';
2
+ type $$ComponentProps = {
3
+ items: TimelineItem[];
4
+ /** Accessible name for the chart. */
5
+ label: string;
6
+ onItemClick?: (item: TimelineItem) => void;
7
+ class?: string;
8
+ };
9
+ declare const Timeline: import("svelte").Component<$$ComponentProps, {}, "">;
10
+ type Timeline = ReturnType<typeof Timeline>;
11
+ export default Timeline;
package/dist/index.d.ts CHANGED
@@ -47,6 +47,14 @@ export { default as Inline } from './components/Inline.svelte';
47
47
  export { default as Text } from './components/Text.svelte';
48
48
  export { default as Disclosure } from './components/Disclosure.svelte';
49
49
  export { default as GroupedList } from './components/GroupedList.svelte';
50
+ export { default as Timeline } from './components/Timeline.svelte';
51
+ export type { TimelineItem } from './utils/timeline.js';
52
+ export { default as BucketGrid } from './components/BucketGrid.svelte';
53
+ export type { Bucket } from './components/BucketGrid.svelte';
54
+ export { default as GridLayout } from './components/GridLayout.svelte';
55
+ export type { GridItem } from './utils/layout.js';
56
+ export { default as ExpressionEditor } from './components/ExpressionEditor.svelte';
57
+ export { describe as describeExpression, isComplete as isCompleteExpression, type ExpressionField, type ExpressionKind, type ExpressionNode, type ExpressionOp, type ExpressionSlot, } from './utils/expression.js';
50
58
  export type { GroupedListGroup, GroupedListItem } from './components/GroupedList.svelte';
51
59
  export { default as SegmentBar } from './components/SegmentBar.svelte';
52
60
  export type { Segment } from './components/SegmentBar.svelte';
@@ -65,6 +73,11 @@ export { default as ListItem } from './components/ListItem.svelte';
65
73
  export { default as DescriptionList } from './components/DescriptionList.svelte';
66
74
  export type { DescriptionItem } from './utils/componentTypes.js';
67
75
  export { default as FormRow } from './components/FormRow.svelte';
76
+ export { default as PropertyRow } from './components/PropertyRow.svelte';
77
+ export { default as ChipSelect } from './components/ChipSelect.svelte';
78
+ export type { ChipSelectGroup, ChipSelectOption } from './utils/componentTypes.js';
79
+ export { default as DateField } from './components/DateField.svelte';
80
+ export { dayLabel, dayUrgency, parseYmd, type DayUrgency } from './utils/dayLabel.js';
68
81
  export { default as SearchPicker } from './components/SearchPicker.svelte';
69
82
  export { default as FileButton } from './components/FileButton.svelte';
70
83
  export { default as TitleInput } from './components/TitleInput.svelte';