terminoir 0.7.0 → 0.9.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 +6 -1
- package/dist/components/BucketGrid.svelte +109 -0
- package/dist/components/BucketGrid.svelte.d.ts +25 -0
- package/dist/components/ExpressionEditor.svelte +186 -0
- package/dist/components/ExpressionEditor.svelte.d.ts +17 -0
- package/dist/components/GridLayout.svelte +194 -0
- package/dist/components/GridLayout.svelte.d.ts +15 -0
- package/dist/components/MarkdownEditor.svelte +362 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +18 -0
- package/dist/components/Timeline.svelte +130 -0
- package/dist/components/Timeline.svelte.d.ts +11 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +6 -0
- package/dist/utils/expression.d.ts +49 -0
- package/dist/utils/expression.js +116 -0
- package/dist/utils/layout.d.ts +15 -0
- package/dist/utils/layout.js +27 -0
- package/dist/utils/markdownEditor/blocks.d.ts +27 -0
- package/dist/utils/markdownEditor/blocks.js +107 -0
- package/dist/utils/markdownEditor/triggers.d.ts +17 -0
- package/dist/utils/markdownEditor/triggers.js +69 -0
- package/dist/utils/markdownEditor/wikilink.d.ts +20 -0
- package/dist/utils/markdownEditor/wikilink.js +92 -0
- package/dist/utils/timeline.d.ts +36 -0
- package/dist/utils/timeline.js +48 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ To try unreleased changes in an app, `pnpm link /path/to/terminoir` from the app
|
|
|
14
14
|
|
|
15
15
|
### Releasing
|
|
16
16
|
|
|
17
|
-
Bump `version` (semver: new component or prop → minor; breaking prop/CSS change → major), then `
|
|
17
|
+
Bump `version` (semver: new component or prop → minor; breaking prop/CSS change → major), then push `main`. The `Publish` workflow (`.github/workflows/publish.yml`) publishes any version not yet on npm via trusted publishing (`prepublishOnly` checks, builds and tests first); don't publish from a laptop. Apps bump their `terminoir` range to adopt.
|
|
18
18
|
|
|
19
19
|
Peer dependency: **Svelte 5** (for components). Optional **React 18–19** for `terminoir/react`. CSS-only consumers can import styles without JS.
|
|
20
20
|
|
|
@@ -153,6 +153,7 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
|
|
|
153
153
|
| `DataTable` | Columns, `cell` snippet, clickable rows, `hideOnMobile`, empty state |
|
|
154
154
|
| `List`, `ListItem`, `DescriptionList`, `FormRow` | Rows with leading/trailing slots; key–value pairs; label-left form rows |
|
|
155
155
|
| `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation; grows to fill a row after chips |
|
|
156
|
+
| `MarkdownEditor` | Notion-style editor that reads and writes markdown (Milkdown): `/` block menu, block handle (`+` and drag to move), checklists, markdown shortcuts, undo. `linkSearch` enables `[[` links written as `[[id\|label]]`; `onlinkopen` gets the clicked target. Outside `value` changes apply while it is not focused; `onchange` fires only after a user edit |
|
|
156
157
|
| `PropertyRow` | Compact icon + label row for inline-edited values (record properties); hover highlights the row |
|
|
157
158
|
| `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 |
|
|
158
159
|
| `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
|
|
@@ -160,6 +161,10 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
|
|
|
160
161
|
| `CanvasStage`, `DocumentViewer` | Full-bleed canvas host with floating overlay and side panel; paged viewer with toolbar and highlight marks |
|
|
161
162
|
| `AccentSwatches` | `bind:value` accent hex from `ACCENT_PALETTE` |
|
|
162
163
|
| `GroupedList`, `SegmentBar` | Items in labelled groups with a colour dot and count, "Show n more" past `limit`, `collapsed` groups behind a toggle (e.g. finished items); a thin bar of proportional coloured segments |
|
|
164
|
+
| `Timeline` | Gantt rows: a title column and bars from `start` to `end` (inclusive, or one day) on a shared day scale, grouped under headings, today marked; `TimelineItem` |
|
|
165
|
+
| `BucketGrid` | Board columns or a 2×2 `quadrants` grid of drop zones with a heading, colour dot and count; the caller renders cards (`children(bucket)`) and sets `dragType`; `ondrop(key, payload)`, `droppable: false` refuses drops |
|
|
166
|
+
| `GridLayout` | Items in reading order on a 12-column grid: drag the move handle (`children(item, handle)` renders it where you want) to reorder, drag the right edge to change span (`minSpan`); arrow keys do both; `onchange(items)` on release |
|
|
167
|
+
| `ExpressionEditor` | Scratch-style block editor for a typed expression tree from an op catalogue (`ExpressionOp`: returns, slots with kinds, params); slots only offer ops whose result fits; params named `key` pick from `fields`; plain-English reading underneath (`describeExpression`) |
|
|
163
168
|
|
|
164
169
|
`labelColor(name)` resolves palette names (`green`, `gray`…, see `LABEL_PALETTE`) or passes CSS colours through; `Chip`, `GroupedList` and `SegmentBar` share it.
|
|
165
170
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils/cn.js';
|
|
4
|
+
import { labelColor } from '../utils/palette.js';
|
|
5
|
+
|
|
6
|
+
export type Bucket = {
|
|
7
|
+
key: string;
|
|
8
|
+
label: string;
|
|
9
|
+
/** Palette name or CSS colour for the heading dot. */
|
|
10
|
+
color?: string | null;
|
|
11
|
+
count: number;
|
|
12
|
+
/** False to refuse drops (e.g. an Unsorted cell that cannot be written). */
|
|
13
|
+
droppable?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Board columns or a 2x2 matrix of drop zones. Callers render each bucket's cards with
|
|
18
|
+
* `children` and make them draggable with `dragType`; `ondrop` gets the dropped payload.
|
|
19
|
+
*/
|
|
20
|
+
let {
|
|
21
|
+
buckets,
|
|
22
|
+
layout = 'columns',
|
|
23
|
+
dragType,
|
|
24
|
+
label,
|
|
25
|
+
ondrop,
|
|
26
|
+
children,
|
|
27
|
+
class: className = '',
|
|
28
|
+
}: {
|
|
29
|
+
buckets: Bucket[];
|
|
30
|
+
/** `columns` side by side (wrapping on phones), or `quadrants` (2x2, first four buckets). */
|
|
31
|
+
layout?: 'columns' | 'quadrants';
|
|
32
|
+
/** The `DataTransfer` type the cards set; other drags are ignored. */
|
|
33
|
+
dragType: string;
|
|
34
|
+
/** Accessible name for the grid. */
|
|
35
|
+
label: string;
|
|
36
|
+
ondrop: (bucketKey: string, payload: string) => void;
|
|
37
|
+
children: Snippet<[Bucket]>;
|
|
38
|
+
class?: string;
|
|
39
|
+
} = $props();
|
|
40
|
+
|
|
41
|
+
let over = $state<string | null>(null);
|
|
42
|
+
|
|
43
|
+
function accepts(event: DragEvent, bucket: Bucket): boolean {
|
|
44
|
+
return bucket.droppable !== false && !!event.dataTransfer?.types.includes(dragType);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function dragOver(event: DragEvent, bucket: Bucket) {
|
|
48
|
+
if (!accepts(event, bucket)) return;
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
over = bucket.key;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function drop(event: DragEvent, bucket: Bucket) {
|
|
54
|
+
over = null;
|
|
55
|
+
if (!accepts(event, bucket)) return;
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
const payload = event.dataTransfer?.getData(dragType);
|
|
58
|
+
if (payload) ondrop(bucket.key, payload);
|
|
59
|
+
}
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
<div class={cn('terminoir-buckets', `terminoir-buckets--${layout}`, className)} role="group" aria-label={label}>
|
|
63
|
+
{#each buckets as bucket (bucket.key)}
|
|
64
|
+
<section
|
|
65
|
+
class="terminoir-buckets__zone"
|
|
66
|
+
class:terminoir-buckets__zone--over={over === bucket.key}
|
|
67
|
+
aria-label={bucket.label}
|
|
68
|
+
ondragover={(event) => dragOver(event, bucket)}
|
|
69
|
+
ondragleave={() => (over = over === bucket.key ? null : over)}
|
|
70
|
+
ondrop={(event) => drop(event, bucket)}
|
|
71
|
+
>
|
|
72
|
+
<header class="terminoir-buckets__head">
|
|
73
|
+
<span class="terminoir-buckets__dot" style:background={labelColor(bucket.color) ?? 'var(--muted-foreground)'}></span>
|
|
74
|
+
<span class="terminoir-buckets__label">{bucket.label}</span>
|
|
75
|
+
<span class="terminoir-buckets__count">{bucket.count}</span>
|
|
76
|
+
</header>
|
|
77
|
+
<div class="terminoir-buckets__items">{@render children(bucket)}</div>
|
|
78
|
+
</section>
|
|
79
|
+
{/each}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.terminoir-buckets { display: grid; gap: var(--space-3); min-width: 0; }
|
|
84
|
+
.terminoir-buckets--columns { grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); }
|
|
85
|
+
.terminoir-buckets--quadrants { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
86
|
+
@media (max-width: 640px) {
|
|
87
|
+
.terminoir-buckets--quadrants { grid-template-columns: minmax(0, 1fr); }
|
|
88
|
+
}
|
|
89
|
+
.terminoir-buckets__zone {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
gap: var(--space-2);
|
|
93
|
+
min-width: 0;
|
|
94
|
+
padding: var(--space-3);
|
|
95
|
+
border: 1px solid var(--border);
|
|
96
|
+
border-radius: var(--radius);
|
|
97
|
+
background: color-mix(in srgb, var(--foreground) 3%, transparent);
|
|
98
|
+
transition: border-color 120ms ease, background 120ms ease;
|
|
99
|
+
}
|
|
100
|
+
.terminoir-buckets__zone--over {
|
|
101
|
+
border-color: var(--primary);
|
|
102
|
+
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
|
103
|
+
}
|
|
104
|
+
.terminoir-buckets__head { display: flex; align-items: center; gap: var(--space-2); font-size: 0.8125rem; }
|
|
105
|
+
.terminoir-buckets__dot { width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none; }
|
|
106
|
+
.terminoir-buckets__label { font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
107
|
+
.terminoir-buckets__count { color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
|
|
108
|
+
.terminoir-buckets__items { display: flex; flex-direction: column; gap: var(--space-2); min-height: 2.5rem; }
|
|
109
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
export type Bucket = {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
/** Palette name or CSS colour for the heading dot. */
|
|
6
|
+
color?: string | null;
|
|
7
|
+
count: number;
|
|
8
|
+
/** False to refuse drops (e.g. an Unsorted cell that cannot be written). */
|
|
9
|
+
droppable?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
buckets: Bucket[];
|
|
13
|
+
/** `columns` side by side (wrapping on phones), or `quadrants` (2x2, first four buckets). */
|
|
14
|
+
layout?: 'columns' | 'quadrants';
|
|
15
|
+
/** The `DataTransfer` type the cards set; other drags are ignored. */
|
|
16
|
+
dragType: string;
|
|
17
|
+
/** Accessible name for the grid. */
|
|
18
|
+
label: string;
|
|
19
|
+
ondrop: (bucketKey: string, payload: string) => void;
|
|
20
|
+
children: Snippet<[Bucket]>;
|
|
21
|
+
class?: string;
|
|
22
|
+
};
|
|
23
|
+
declare const BucketGrid: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
24
|
+
type BucketGrid = ReturnType<typeof BucketGrid>;
|
|
25
|
+
export default BucketGrid;
|
|
@@ -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;
|