terminoir 0.7.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.
- package/README.md +5 -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/Timeline.svelte +130 -0
- package/dist/components/Timeline.svelte.d.ts +11 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -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/timeline.d.ts +36 -0
- package/dist/utils/timeline.js +48 -0
- package/package.json +1 -1
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
|
|
|
@@ -160,6 +160,10 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
|
|
|
160
160
|
| `CanvasStage`, `DocumentViewer` | Full-bleed canvas host with floating overlay and side panel; paged viewer with toolbar and highlight marks |
|
|
161
161
|
| `AccentSwatches` | `bind:value` accent hex from `ACCENT_PALETTE` |
|
|
162
162
|
| `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 |
|
|
163
|
+
| `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` |
|
|
164
|
+
| `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 |
|
|
165
|
+
| `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 |
|
|
166
|
+
| `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
167
|
|
|
164
168
|
`labelColor(name)` resolves palette names (`green`, `gray`…, see `LABEL_PALETTE`) or passes CSS colours through; `Chip`, `GroupedList` and `SegmentBar` share it.
|
|
165
169
|
|
|
@@ -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;
|
|
@@ -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';
|
package/dist/index.js
CHANGED
|
@@ -46,6 +46,11 @@ export { default as Inline } from './components/Inline.svelte';
|
|
|
46
46
|
export { default as Text } from './components/Text.svelte';
|
|
47
47
|
export { default as Disclosure } from './components/Disclosure.svelte';
|
|
48
48
|
export { default as GroupedList } from './components/GroupedList.svelte';
|
|
49
|
+
export { default as Timeline } from './components/Timeline.svelte';
|
|
50
|
+
export { default as BucketGrid } from './components/BucketGrid.svelte';
|
|
51
|
+
export { default as GridLayout } from './components/GridLayout.svelte';
|
|
52
|
+
export { default as ExpressionEditor } from './components/ExpressionEditor.svelte';
|
|
53
|
+
export { describe as describeExpression, isComplete as isCompleteExpression, } from './utils/expression.js';
|
|
49
54
|
export { default as SegmentBar } from './components/SegmentBar.svelte';
|
|
50
55
|
export { default as NavSection } from './components/NavSection.svelte';
|
|
51
56
|
export { default as Spacer } from './components/Spacer.svelte';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed expression trees for a block editor. The op catalogue (e.g. Oyster's
|
|
3
|
+
* `GET /expressions/ops`) says what each op returns and what its slots accept; the editor only
|
|
4
|
+
* offers ops whose result fits the slot, so users cannot build an ill-typed tree.
|
|
5
|
+
*/
|
|
6
|
+
export type ExpressionKind = 'boolean' | 'number' | 'date' | 'text' | 'value' | 'ids';
|
|
7
|
+
export type ExpressionSlot = {
|
|
8
|
+
name: string;
|
|
9
|
+
kind: ExpressionKind;
|
|
10
|
+
many?: boolean;
|
|
11
|
+
};
|
|
12
|
+
export type ExpressionOp = {
|
|
13
|
+
op: string;
|
|
14
|
+
label: string;
|
|
15
|
+
group: string;
|
|
16
|
+
returns: ExpressionKind;
|
|
17
|
+
slots: ExpressionSlot[];
|
|
18
|
+
/** Plain fields on the node. A param named `key` picks a field; others are typed in. */
|
|
19
|
+
params: string[];
|
|
20
|
+
};
|
|
21
|
+
export type ExpressionField = {
|
|
22
|
+
key: string;
|
|
23
|
+
label?: string;
|
|
24
|
+
kind?: ExpressionKind;
|
|
25
|
+
};
|
|
26
|
+
export type ExpressionNode = {
|
|
27
|
+
op: string;
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
};
|
|
30
|
+
/** A slot of `kind` accepts an op returning `returns` (`value` is the wildcard both ways, `ids` only fits `ids`). */
|
|
31
|
+
export declare function fits(kind: ExpressionKind, returns: ExpressionKind): boolean;
|
|
32
|
+
export declare function opsFor(kind: ExpressionKind, ops: ExpressionOp[]): ExpressionOp[];
|
|
33
|
+
/** A fresh node for an op: empty single slots, one empty entry in list slots, blank params. */
|
|
34
|
+
export declare function emptyNode(op: ExpressionOp): ExpressionNode;
|
|
35
|
+
/** Text typed into a param, as the value it stands for (numbers and true/false become typed). */
|
|
36
|
+
export declare function parseParam(param: string, text: string): unknown;
|
|
37
|
+
export declare function paramText(value: unknown): string;
|
|
38
|
+
/** True when every slot is filled and every key param names a field. */
|
|
39
|
+
export declare function isComplete(node: unknown, ops: ExpressionOp[]): boolean;
|
|
40
|
+
/** Plain-English reading of a tree, built from op labels and slot names (no per-op templates). */
|
|
41
|
+
export declare function describe(node: unknown, ops: ExpressionOp[], fields?: ExpressionField[]): string;
|
|
42
|
+
export type ExpressionPath = (string | number)[];
|
|
43
|
+
/** A copy of `root` with the value at `path` replaced (`undefined` removes a list entry). */
|
|
44
|
+
export declare function setAt(root: unknown, path: ExpressionPath, value: unknown): unknown;
|
|
45
|
+
/** Ops grouped by their `group`, in first-seen order, for an `<optgroup>` picker. */
|
|
46
|
+
export declare function groupOps(ops: ExpressionOp[]): {
|
|
47
|
+
group: string;
|
|
48
|
+
ops: ExpressionOp[];
|
|
49
|
+
}[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed expression trees for a block editor. The op catalogue (e.g. Oyster's
|
|
3
|
+
* `GET /expressions/ops`) says what each op returns and what its slots accept; the editor only
|
|
4
|
+
* offers ops whose result fits the slot, so users cannot build an ill-typed tree.
|
|
5
|
+
*/
|
|
6
|
+
/** A slot of `kind` accepts an op returning `returns` (`value` is the wildcard both ways, `ids` only fits `ids`). */
|
|
7
|
+
export function fits(kind, returns) {
|
|
8
|
+
if (kind === 'ids' || returns === 'ids')
|
|
9
|
+
return kind === returns;
|
|
10
|
+
return kind === 'value' || returns === 'value' || kind === returns;
|
|
11
|
+
}
|
|
12
|
+
export function opsFor(kind, ops) {
|
|
13
|
+
return ops.filter((op) => fits(kind, op.returns));
|
|
14
|
+
}
|
|
15
|
+
/** A fresh node for an op: empty single slots, one empty entry in list slots, blank params. */
|
|
16
|
+
export function emptyNode(op) {
|
|
17
|
+
const node = { op: op.op };
|
|
18
|
+
for (const slot of op.slots)
|
|
19
|
+
node[slot.name] = slot.many ? [] : null;
|
|
20
|
+
for (const param of op.params)
|
|
21
|
+
node[param] = param === 'options' ? [] : '';
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
/** Text typed into a param, as the value it stands for (numbers and true/false become typed). */
|
|
25
|
+
export function parseParam(param, text) {
|
|
26
|
+
if (param === 'options')
|
|
27
|
+
return text.split(',').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
const trimmed = text.trim();
|
|
29
|
+
if (param === 'key')
|
|
30
|
+
return trimmed;
|
|
31
|
+
if (trimmed === 'true' || trimmed === 'false')
|
|
32
|
+
return trimmed === 'true';
|
|
33
|
+
if (trimmed !== '' && !Number.isNaN(Number(trimmed)))
|
|
34
|
+
return Number(trimmed);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
export function paramText(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.join(', ');
|
|
40
|
+
return value == null ? '' : String(value);
|
|
41
|
+
}
|
|
42
|
+
/** True when every slot is filled and every key param names a field. */
|
|
43
|
+
export function isComplete(node, ops) {
|
|
44
|
+
if (!node || typeof node !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const n = node;
|
|
47
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
48
|
+
if (!spec)
|
|
49
|
+
return false;
|
|
50
|
+
if (spec.params.includes('key') && !n.key)
|
|
51
|
+
return false;
|
|
52
|
+
return spec.slots.every((slot) => {
|
|
53
|
+
const value = n[slot.name];
|
|
54
|
+
if (slot.many)
|
|
55
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => isComplete(v, ops));
|
|
56
|
+
return isComplete(value, ops);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Plain-English reading of a tree, built from op labels and slot names (no per-op templates). */
|
|
60
|
+
export function describe(node, ops, fields = []) {
|
|
61
|
+
if (!node || typeof node !== 'object')
|
|
62
|
+
return '…';
|
|
63
|
+
const n = node;
|
|
64
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
65
|
+
if (!spec)
|
|
66
|
+
return n.op;
|
|
67
|
+
const label = spec.label.toLowerCase();
|
|
68
|
+
if ('key' in n && spec.params.includes('key')) {
|
|
69
|
+
const field = fields.find((f) => f.key === n.key);
|
|
70
|
+
const name = field?.label ?? (n.key ? String(n.key) : '…');
|
|
71
|
+
return spec.slots.length ? `${label} ${name}` : spec.op === 'field' ? name : `${label} ${name}`;
|
|
72
|
+
}
|
|
73
|
+
if (spec.params.includes('value') && !spec.slots.length)
|
|
74
|
+
return JSON.stringify(n.value ?? '');
|
|
75
|
+
const part = (v) => describe(v, ops, fields);
|
|
76
|
+
const names = spec.slots.map((s) => s.name);
|
|
77
|
+
if (names.length === 2 && names[0] === 'left')
|
|
78
|
+
return `${part(n.left)} ${label} ${part(n.right)}`;
|
|
79
|
+
const many = spec.slots.find((s) => s.many);
|
|
80
|
+
if (many) {
|
|
81
|
+
const items = (Array.isArray(n[many.name]) ? n[many.name] : []).map(part);
|
|
82
|
+
return items.length > 1 ? `(${items.join(` ${label} `)})` : (items[0] ?? '…');
|
|
83
|
+
}
|
|
84
|
+
const pieces = spec.slots.map((s) => part(n[s.name]));
|
|
85
|
+
const options = spec.params.includes('options') ? ` ${paramText(n.options)}` : '';
|
|
86
|
+
if (!pieces.length)
|
|
87
|
+
return label;
|
|
88
|
+
if (pieces.length > 1)
|
|
89
|
+
return `${label} ${pieces.join(', ')}`;
|
|
90
|
+
// "is empty", "is one of" read after their argument; "not", "days until" before it.
|
|
91
|
+
return label.startsWith('is ') ? `${pieces[0]} ${label}${options}` : `${label} ${pieces[0]}`;
|
|
92
|
+
}
|
|
93
|
+
/** A copy of `root` with the value at `path` replaced (`undefined` removes a list entry). */
|
|
94
|
+
export function setAt(root, path, value) {
|
|
95
|
+
if (!path.length)
|
|
96
|
+
return value;
|
|
97
|
+
const [head, ...rest] = path;
|
|
98
|
+
if (Array.isArray(root)) {
|
|
99
|
+
const next = root.slice();
|
|
100
|
+
const index = Number(head);
|
|
101
|
+
if (!rest.length && value === undefined)
|
|
102
|
+
next.splice(index, 1);
|
|
103
|
+
else
|
|
104
|
+
next[index] = setAt(root[index], rest, value);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
const obj = (root && typeof root === 'object' ? root : {});
|
|
108
|
+
return { ...obj, [head]: setAt(obj[head], rest, value) };
|
|
109
|
+
}
|
|
110
|
+
/** Ops grouped by their `group`, in first-seen order, for an `<optgroup>` picker. */
|
|
111
|
+
export function groupOps(ops) {
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const op of ops)
|
|
114
|
+
groups.set(op.group, [...(groups.get(op.group) ?? []), op]);
|
|
115
|
+
return [...groups].map(([group, list]) => ({ group, ops: list }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export type GridItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
span: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function moveTo<T extends {
|
|
7
|
+
id: string;
|
|
8
|
+
}>(items: T[], id: string, index: number): T[];
|
|
9
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
10
|
+
export declare function nearestIndex(centres: {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}[], x: number, y: number): number;
|
|
14
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
15
|
+
export declare function spanAfterDrag(span: number, dx: number, columnWidth: number, min: number, max: number): number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export function moveTo(items, id, index) {
|
|
3
|
+
const from = items.findIndex((i) => i.id === id);
|
|
4
|
+
if (from < 0)
|
|
5
|
+
return items;
|
|
6
|
+
const next = items.slice();
|
|
7
|
+
const [item] = next.splice(from, 1);
|
|
8
|
+
next.splice(Math.max(0, Math.min(index, next.length)), 0, item);
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
11
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
12
|
+
export function nearestIndex(centres, x, y) {
|
|
13
|
+
let best = 0;
|
|
14
|
+
let bestDistance = Infinity;
|
|
15
|
+
centres.forEach((c, i) => {
|
|
16
|
+
const distance = (c.x - x) ** 2 + (c.y - y) ** 2;
|
|
17
|
+
if (distance < bestDistance)
|
|
18
|
+
[best, bestDistance] = [i, distance];
|
|
19
|
+
});
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
23
|
+
export function spanAfterDrag(span, dx, columnWidth, min, max) {
|
|
24
|
+
if (columnWidth <= 0)
|
|
25
|
+
return span;
|
|
26
|
+
return Math.max(min, Math.min(max, span + Math.round(dx / columnWidth)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type TimelineItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
start: Date;
|
|
5
|
+
/** Inclusive last day; defaults to `start` (a one-day bar). */
|
|
6
|
+
end?: Date;
|
|
7
|
+
/** Palette name or CSS colour for the bar. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Rows sharing a group sit under one heading. */
|
|
10
|
+
group?: string;
|
|
11
|
+
};
|
|
12
|
+
export type TimelineBar = {
|
|
13
|
+
item: TimelineItem;
|
|
14
|
+
left: number;
|
|
15
|
+
width: number;
|
|
16
|
+
};
|
|
17
|
+
export type TimelineRow = {
|
|
18
|
+
group: string | null;
|
|
19
|
+
bars: TimelineBar[];
|
|
20
|
+
};
|
|
21
|
+
export type TimelineScale = {
|
|
22
|
+
start: Date;
|
|
23
|
+
days: number;
|
|
24
|
+
ticks: {
|
|
25
|
+
date: Date;
|
|
26
|
+
left: number;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
30
|
+
export declare function timelineScale(items: TimelineItem[], today?: Date): TimelineScale;
|
|
31
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
32
|
+
export declare function timelineBar(item: TimelineItem, scale: TimelineScale): TimelineBar;
|
|
33
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
34
|
+
export declare function timelineRows(items: TimelineItem[], scale: TimelineScale): TimelineRow[];
|
|
35
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
36
|
+
export declare function todayOffset(scale: TimelineScale, today?: Date): number | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { addDays, startOfDay } from './calendar.js';
|
|
2
|
+
const DAY_MS = 86_400_000;
|
|
3
|
+
const PAD_DAYS = 1;
|
|
4
|
+
const MAX_TICKS = 12;
|
|
5
|
+
function dayIndex(from, d) {
|
|
6
|
+
return Math.round((startOfDay(d).getTime() - from.getTime()) / DAY_MS);
|
|
7
|
+
}
|
|
8
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
9
|
+
export function timelineScale(items, today = new Date()) {
|
|
10
|
+
const starts = items.map((i) => startOfDay(i.start).getTime());
|
|
11
|
+
const ends = items.map((i) => startOfDay(i.end ?? i.start).getTime());
|
|
12
|
+
const first = starts.length ? Math.min(...starts) : startOfDay(today).getTime();
|
|
13
|
+
const last = ends.length ? Math.max(...ends) : first;
|
|
14
|
+
const start = addDays(new Date(first), -PAD_DAYS);
|
|
15
|
+
const days = Math.round((last - first) / DAY_MS) + 1 + 2 * PAD_DAYS;
|
|
16
|
+
const step = Math.max(1, Math.ceil(days / MAX_TICKS));
|
|
17
|
+
const ticks = [];
|
|
18
|
+
for (let i = 0; i < days; i += step)
|
|
19
|
+
ticks.push({ date: addDays(start, i), left: (i / days) * 100 });
|
|
20
|
+
return { start, days, ticks };
|
|
21
|
+
}
|
|
22
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
23
|
+
export function timelineBar(item, scale) {
|
|
24
|
+
const from = dayIndex(scale.start, item.start);
|
|
25
|
+
const to = Math.max(from, dayIndex(scale.start, item.end ?? item.start));
|
|
26
|
+
return { item, left: (from / scale.days) * 100, width: ((to - from + 1) / scale.days) * 100 };
|
|
27
|
+
}
|
|
28
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
29
|
+
export function timelineRows(items, scale) {
|
|
30
|
+
const groups = new Map();
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const key = item.group ?? null;
|
|
33
|
+
groups.set(key, [...(groups.get(key) ?? []), item]);
|
|
34
|
+
}
|
|
35
|
+
const keys = [...groups.keys()].sort((a, b) => (a === null ? -1 : b === null ? 1 : 0));
|
|
36
|
+
return keys.map((group) => ({
|
|
37
|
+
group,
|
|
38
|
+
bars: (groups.get(group) ?? [])
|
|
39
|
+
.slice()
|
|
40
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
|
41
|
+
.map((item) => timelineBar(item, scale)),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
45
|
+
export function todayOffset(scale, today = new Date()) {
|
|
46
|
+
const i = dayIndex(scale.start, today);
|
|
47
|
+
return i < 0 || i >= scale.days ? null : ((i + 0.5) / scale.days) * 100;
|
|
48
|
+
}
|