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.
- package/README.md +8 -2
- package/dist/components/BucketGrid.svelte +109 -0
- package/dist/components/BucketGrid.svelte.d.ts +25 -0
- package/dist/components/ChipSelect.svelte +228 -0
- package/dist/components/ChipSelect.svelte.d.ts +18 -0
- package/dist/components/DateField.svelte +144 -0
- package/dist/components/DateField.svelte.d.ts +15 -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/PropertyRow.svelte +68 -0
- package/dist/components/PropertyRow.svelte.d.ts +12 -0
- package/dist/components/SearchPicker.svelte +2 -0
- package/dist/components/Timeline.svelte +130 -0
- package/dist/components/Timeline.svelte.d.ts +11 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +9 -0
- package/dist/utils/componentTypes.d.ts +12 -0
- package/dist/utils/dayLabel.d.ts +7 -0
- package/dist/utils/dayLabel.js +35 -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
|
|
|
@@ -152,12 +152,18 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
|
|
|
152
152
|
| `NavSection` | Sidebar section with title and action |
|
|
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
|
-
| `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation |
|
|
155
|
+
| `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation; grows to fill a row after chips |
|
|
156
|
+
| `PropertyRow` | Compact icon + label row for inline-edited values (record properties); hover highlights the row |
|
|
157
|
+
| `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 |
|
|
156
158
|
| `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
|
|
157
159
|
| `CalendarGrid` | Week (scrolling hours) and month grids; `CalendarEvent` with `muted`/`color`; helpers `calendarRange`, `shiftCalendar`, `eventsOn` |
|
|
158
160
|
| `CanvasStage`, `DocumentViewer` | Full-bleed canvas host with floating overlay and side panel; paged viewer with toolbar and highlight marks |
|
|
159
161
|
| `AccentSwatches` | `bind:value` accent hex from `ACCENT_PALETTE` |
|
|
160
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`) |
|
|
161
167
|
|
|
162
168
|
`labelColor(name)` resolves palette names (`green`, `gray`…, see `LABEL_PALETTE`) or passes CSS colours through; `Chip`, `GroupedList` and `SegmentBar` share it.
|
|
163
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,228 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { cn } from '../utils/cn.js';
|
|
4
|
+
import type { ChipSelectGroup, ChipSelectOption } from '../utils/componentTypes.js';
|
|
5
|
+
import { moveIndex } from '../utils/listNav.js';
|
|
6
|
+
import Chip from './Chip.svelte';
|
|
7
|
+
import NavIcon from './NavIcon.svelte';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
label,
|
|
11
|
+
value = '',
|
|
12
|
+
options,
|
|
13
|
+
groups = [],
|
|
14
|
+
placeholder = 'Empty',
|
|
15
|
+
clearable = true,
|
|
16
|
+
onchange,
|
|
17
|
+
class: className = '',
|
|
18
|
+
}: {
|
|
19
|
+
/** Accessible name, e.g. the property name. */
|
|
20
|
+
label: string;
|
|
21
|
+
/** Selected option id; empty string for none. */
|
|
22
|
+
value?: string;
|
|
23
|
+
options: ChipSelectOption[];
|
|
24
|
+
/** Headings, in order, for options with a `group`; ungrouped options come first. */
|
|
25
|
+
groups?: ChipSelectGroup[];
|
|
26
|
+
placeholder?: string;
|
|
27
|
+
/** Offer a "Clear" entry when something is selected. */
|
|
28
|
+
clearable?: boolean;
|
|
29
|
+
onchange: (value: string) => void;
|
|
30
|
+
class?: string;
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
const uid = $props.id();
|
|
34
|
+
const CLEAR = { id: '', label: 'Clear' } satisfies ChipSelectOption;
|
|
35
|
+
|
|
36
|
+
let open = $state(false);
|
|
37
|
+
let active = $state(-1);
|
|
38
|
+
let root: HTMLDivElement | undefined = $state();
|
|
39
|
+
let trigger: HTMLButtonElement | undefined = $state();
|
|
40
|
+
let list: HTMLUListElement | undefined = $state();
|
|
41
|
+
|
|
42
|
+
const selected = $derived(options.find((o) => o.id === value));
|
|
43
|
+
const sections = $derived([
|
|
44
|
+
{ id: '', label: '', options: options.filter((o) => !o.group || !groups.some((g) => g.id === o.group)) },
|
|
45
|
+
...groups.map((g) => ({ ...g, options: options.filter((o) => o.group === g.id) })),
|
|
46
|
+
].filter((section) => section.options.length));
|
|
47
|
+
const choices = $derived([...sections.flatMap((s) => s.options), ...(clearable && selected ? [CLEAR] : [])]);
|
|
48
|
+
|
|
49
|
+
async function show() {
|
|
50
|
+
open = true;
|
|
51
|
+
active = Math.max(0, choices.findIndex((o) => o.id === value));
|
|
52
|
+
await tick();
|
|
53
|
+
list?.focus();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hide() {
|
|
57
|
+
open = false;
|
|
58
|
+
trigger?.focus();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function choose(option: ChipSelectOption | undefined) {
|
|
62
|
+
if (!option) return;
|
|
63
|
+
if (option.id !== value) onchange(option.id);
|
|
64
|
+
hide();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onTriggerKeydown(event: KeyboardEvent) {
|
|
68
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
void show();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function onListKeydown(event: KeyboardEvent) {
|
|
74
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
75
|
+
active = moveIndex(active, event.key === 'ArrowDown' ? 1 : -1, choices.length);
|
|
76
|
+
} else if (event.key === 'Enter' || event.key === ' ') {
|
|
77
|
+
choose(choices[active]);
|
|
78
|
+
} else if (event.key === 'Escape') {
|
|
79
|
+
hide();
|
|
80
|
+
} else if (event.key === 'Tab') {
|
|
81
|
+
open = false;
|
|
82
|
+
return;
|
|
83
|
+
} else {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
event.stopPropagation();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
$effect(() => {
|
|
91
|
+
if (!open) return;
|
|
92
|
+
const onPointer = (event: PointerEvent) => {
|
|
93
|
+
if (root && !root.contains(event.target as Node)) open = false;
|
|
94
|
+
};
|
|
95
|
+
document.addEventListener('pointerdown', onPointer);
|
|
96
|
+
return () => document.removeEventListener('pointerdown', onPointer);
|
|
97
|
+
});
|
|
98
|
+
</script>
|
|
99
|
+
|
|
100
|
+
{#snippet item(option: ChipSelectOption)}
|
|
101
|
+
{@const index = choices.indexOf(option)}
|
|
102
|
+
<!-- Listbox pattern: focus stays on the list, which handles arrow keys and Enter. -->
|
|
103
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
104
|
+
<li
|
|
105
|
+
id={`${uid}-opt-${index}`}
|
|
106
|
+
role="option"
|
|
107
|
+
aria-selected={option.id === value}
|
|
108
|
+
class="terminoir-chip-select__option"
|
|
109
|
+
class:terminoir-chip-select__option--active={index === active}
|
|
110
|
+
onpointermove={() => (active = index)}
|
|
111
|
+
onclick={() => choose(option)}
|
|
112
|
+
>
|
|
113
|
+
{#if option === CLEAR}
|
|
114
|
+
<span class="terminoir-chip-select__clear"><NavIcon name="xmark" /> {CLEAR.label}</span>
|
|
115
|
+
{:else}
|
|
116
|
+
<Chip color={option.color ?? 'grey'}>{option.label}</Chip>
|
|
117
|
+
{#if option.id === value}<NavIcon name="check" class="terminoir-chip-select__check" />{/if}
|
|
118
|
+
{/if}
|
|
119
|
+
</li>
|
|
120
|
+
{/snippet}
|
|
121
|
+
|
|
122
|
+
<div bind:this={root} class={cn('terminoir-chip-select', className)}>
|
|
123
|
+
<button
|
|
124
|
+
bind:this={trigger}
|
|
125
|
+
type="button"
|
|
126
|
+
class="terminoir-chip-select__trigger terminoir-focusable"
|
|
127
|
+
aria-haspopup="listbox"
|
|
128
|
+
aria-expanded={open}
|
|
129
|
+
aria-label={`${label}: ${selected?.label ?? placeholder}`}
|
|
130
|
+
onclick={() => (open ? hide() : void show())}
|
|
131
|
+
onkeydown={onTriggerKeydown}
|
|
132
|
+
>
|
|
133
|
+
{#if selected}
|
|
134
|
+
<Chip color={selected.color ?? 'grey'}>{selected.label}</Chip>
|
|
135
|
+
{:else}
|
|
136
|
+
<span class="terminoir-chip-select__placeholder">{placeholder}</span>
|
|
137
|
+
{/if}
|
|
138
|
+
</button>
|
|
139
|
+
{#if open}
|
|
140
|
+
<ul
|
|
141
|
+
bind:this={list}
|
|
142
|
+
class="terminoir-chip-select__list glass-popover terminoir-focusable"
|
|
143
|
+
role="listbox"
|
|
144
|
+
aria-label={label}
|
|
145
|
+
aria-activedescendant={active >= 0 ? `${uid}-opt-${active}` : undefined}
|
|
146
|
+
tabindex="-1"
|
|
147
|
+
onkeydown={onListKeydown}
|
|
148
|
+
>
|
|
149
|
+
{#each sections as section (section.id)}
|
|
150
|
+
{#if section.label}<li role="presentation" class="terminoir-chip-select__heading">{section.label}</li>{/if}
|
|
151
|
+
{#each section.options as option (option.id)}{@render item(option)}{/each}
|
|
152
|
+
{/each}
|
|
153
|
+
{#if clearable && selected}{@render item(CLEAR)}{/if}
|
|
154
|
+
</ul>
|
|
155
|
+
{/if}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<style>
|
|
159
|
+
.terminoir-chip-select {
|
|
160
|
+
position: relative;
|
|
161
|
+
display: inline-block;
|
|
162
|
+
max-width: 100%;
|
|
163
|
+
}
|
|
164
|
+
.terminoir-chip-select__trigger {
|
|
165
|
+
display: inline-flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
width: max-content;
|
|
168
|
+
max-width: 100%;
|
|
169
|
+
min-height: 1.85rem;
|
|
170
|
+
padding: 0.15rem 0.35rem;
|
|
171
|
+
border: 0;
|
|
172
|
+
border-radius: var(--radius-control);
|
|
173
|
+
background: transparent;
|
|
174
|
+
color: inherit;
|
|
175
|
+
font: inherit;
|
|
176
|
+
cursor: pointer;
|
|
177
|
+
}
|
|
178
|
+
.terminoir-chip-select__trigger:hover {
|
|
179
|
+
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
|
180
|
+
}
|
|
181
|
+
.terminoir-chip-select__placeholder {
|
|
182
|
+
font-size: 0.875rem;
|
|
183
|
+
color: var(--muted-foreground);
|
|
184
|
+
opacity: 0.7;
|
|
185
|
+
}
|
|
186
|
+
.terminoir-chip-select__list {
|
|
187
|
+
position: absolute;
|
|
188
|
+
top: calc(100% + var(--space-1));
|
|
189
|
+
left: 0;
|
|
190
|
+
z-index: 40;
|
|
191
|
+
min-width: 12rem;
|
|
192
|
+
max-height: 18rem;
|
|
193
|
+
overflow-y: auto;
|
|
194
|
+
margin: 0;
|
|
195
|
+
padding: var(--space-1);
|
|
196
|
+
list-style: none;
|
|
197
|
+
}
|
|
198
|
+
.terminoir-chip-select__heading {
|
|
199
|
+
padding: 0.5rem 0.6rem 0.25rem;
|
|
200
|
+
font-size: 0.6875rem;
|
|
201
|
+
letter-spacing: 0.06em;
|
|
202
|
+
text-transform: uppercase;
|
|
203
|
+
color: var(--muted-foreground);
|
|
204
|
+
}
|
|
205
|
+
.terminoir-chip-select__option {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: space-between;
|
|
209
|
+
gap: var(--space-2);
|
|
210
|
+
padding: 0.35rem 0.5rem;
|
|
211
|
+
border-radius: calc(var(--radius-control) - 0.2rem);
|
|
212
|
+
cursor: pointer;
|
|
213
|
+
}
|
|
214
|
+
.terminoir-chip-select__option--active {
|
|
215
|
+
background: color-mix(in srgb, var(--foreground) 8%, transparent);
|
|
216
|
+
}
|
|
217
|
+
.terminoir-chip-select__option :global(.terminoir-chip-select__check) {
|
|
218
|
+
font-size: 0.75rem;
|
|
219
|
+
color: var(--muted-foreground);
|
|
220
|
+
}
|
|
221
|
+
.terminoir-chip-select__clear {
|
|
222
|
+
display: inline-flex;
|
|
223
|
+
align-items: center;
|
|
224
|
+
gap: var(--space-2);
|
|
225
|
+
font-size: 0.8125rem;
|
|
226
|
+
color: var(--muted-foreground);
|
|
227
|
+
}
|
|
228
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ChipSelectGroup, ChipSelectOption } from '../utils/componentTypes.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Accessible name, e.g. the property name. */
|
|
4
|
+
label: string;
|
|
5
|
+
/** Selected option id; empty string for none. */
|
|
6
|
+
value?: string;
|
|
7
|
+
options: ChipSelectOption[];
|
|
8
|
+
/** Headings, in order, for options with a `group`; ungrouped options come first. */
|
|
9
|
+
groups?: ChipSelectGroup[];
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
/** Offer a "Clear" entry when something is selected. */
|
|
12
|
+
clearable?: boolean;
|
|
13
|
+
onchange: (value: string) => void;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const ChipSelect: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type ChipSelect = ReturnType<typeof ChipSelect>;
|
|
18
|
+
export default ChipSelect;
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from '../utils/cn.js';
|
|
3
|
+
import { dayLabel, dayUrgency, parseYmd } from '../utils/dayLabel.js';
|
|
4
|
+
import Chip from './Chip.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
label,
|
|
8
|
+
value = '',
|
|
9
|
+
deadline = false,
|
|
10
|
+
placeholder = 'Empty',
|
|
11
|
+
onchange,
|
|
12
|
+
class: className = '',
|
|
13
|
+
}: {
|
|
14
|
+
/** Accessible name, e.g. the property name. */
|
|
15
|
+
label: string;
|
|
16
|
+
/** `YYYY-MM-DD`, or empty for no date. */
|
|
17
|
+
value?: string;
|
|
18
|
+
/** Colour past dates as overdue and today as due today. */
|
|
19
|
+
deadline?: boolean;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
/** Called with `YYYY-MM-DD`, or an empty string when cleared. */
|
|
22
|
+
onchange: (value: string) => void;
|
|
23
|
+
class?: string;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
let picker: HTMLInputElement | undefined = $state();
|
|
27
|
+
|
|
28
|
+
const date = $derived(parseYmd(value));
|
|
29
|
+
const tone = $derived.by(() => {
|
|
30
|
+
if (!deadline || !date) return 'default';
|
|
31
|
+
const urgency = dayUrgency(date);
|
|
32
|
+
return urgency === 'past' ? 'overdue' : urgency === 'today' ? 'today' : 'default';
|
|
33
|
+
});
|
|
34
|
+
const fullDate = $derived(date?.toLocaleDateString(undefined, { dateStyle: 'full' }) ?? '');
|
|
35
|
+
|
|
36
|
+
function openPicker() {
|
|
37
|
+
if (!picker) return;
|
|
38
|
+
try {
|
|
39
|
+
picker.showPicker();
|
|
40
|
+
} catch {
|
|
41
|
+
picker.focus();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function onKeydown(event: KeyboardEvent) {
|
|
46
|
+
if ((event.key === 'Backspace' || event.key === 'Delete') && value) {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
onchange('');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
</script>
|
|
52
|
+
|
|
53
|
+
<span class={cn('terminoir-date-field', className)}>
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
class="terminoir-date-field__trigger terminoir-focusable"
|
|
57
|
+
aria-label={`${label}: ${fullDate || placeholder}`}
|
|
58
|
+
title={fullDate || undefined}
|
|
59
|
+
onclick={openPicker}
|
|
60
|
+
onkeydown={onKeydown}
|
|
61
|
+
>
|
|
62
|
+
{#if date}
|
|
63
|
+
<Chip {tone}>{dayLabel(date)}</Chip>
|
|
64
|
+
{:else}
|
|
65
|
+
<span class="terminoir-date-field__placeholder">{placeholder}</span>
|
|
66
|
+
{/if}
|
|
67
|
+
</button>
|
|
68
|
+
{#if date}
|
|
69
|
+
<button type="button" class="terminoir-date-field__clear terminoir-focusable" aria-label={`Clear ${label}`} onclick={() => onchange('')}>×</button>
|
|
70
|
+
{/if}
|
|
71
|
+
<input
|
|
72
|
+
bind:this={picker}
|
|
73
|
+
class="terminoir-date-field__picker"
|
|
74
|
+
type="date"
|
|
75
|
+
tabindex="-1"
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
{value}
|
|
78
|
+
onchange={(event) => onchange(event.currentTarget.value)}
|
|
79
|
+
/>
|
|
80
|
+
</span>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.terminoir-date-field {
|
|
84
|
+
position: relative;
|
|
85
|
+
display: inline-flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: var(--space-1);
|
|
88
|
+
}
|
|
89
|
+
.terminoir-date-field__trigger {
|
|
90
|
+
display: inline-flex;
|
|
91
|
+
align-items: center;
|
|
92
|
+
width: max-content;
|
|
93
|
+
min-height: 1.85rem;
|
|
94
|
+
padding: 0.15rem 0.35rem;
|
|
95
|
+
border: 0;
|
|
96
|
+
border-radius: var(--radius-control);
|
|
97
|
+
background: transparent;
|
|
98
|
+
color: inherit;
|
|
99
|
+
font: inherit;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
.terminoir-date-field__trigger:hover {
|
|
103
|
+
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
|
104
|
+
}
|
|
105
|
+
.terminoir-date-field__placeholder {
|
|
106
|
+
font-size: 0.875rem;
|
|
107
|
+
color: var(--muted-foreground);
|
|
108
|
+
opacity: 0.7;
|
|
109
|
+
}
|
|
110
|
+
.terminoir-date-field__clear {
|
|
111
|
+
width: 1.25rem;
|
|
112
|
+
height: 1.25rem;
|
|
113
|
+
padding: 0;
|
|
114
|
+
border: 0;
|
|
115
|
+
border-radius: 999px;
|
|
116
|
+
background: transparent;
|
|
117
|
+
color: var(--muted-foreground);
|
|
118
|
+
font: inherit;
|
|
119
|
+
line-height: 1;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
opacity: 0;
|
|
122
|
+
transition: opacity 120ms ease;
|
|
123
|
+
}
|
|
124
|
+
.terminoir-date-field:hover .terminoir-date-field__clear,
|
|
125
|
+
.terminoir-date-field__clear:focus-visible {
|
|
126
|
+
opacity: 1;
|
|
127
|
+
}
|
|
128
|
+
.terminoir-date-field__clear:hover {
|
|
129
|
+
color: var(--foreground);
|
|
130
|
+
background: color-mix(in srgb, var(--foreground) 10%, transparent);
|
|
131
|
+
}
|
|
132
|
+
/* Kept rendered (not display:none) so showPicker() can anchor the native picker under the trigger. */
|
|
133
|
+
.terminoir-date-field__picker {
|
|
134
|
+
position: absolute;
|
|
135
|
+
left: 0;
|
|
136
|
+
bottom: 0;
|
|
137
|
+
width: 1px;
|
|
138
|
+
height: 1px;
|
|
139
|
+
padding: 0;
|
|
140
|
+
border: 0;
|
|
141
|
+
opacity: 0;
|
|
142
|
+
pointer-events: none;
|
|
143
|
+
}
|
|
144
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** Accessible name, e.g. the property name. */
|
|
3
|
+
label: string;
|
|
4
|
+
/** `YYYY-MM-DD`, or empty for no date. */
|
|
5
|
+
value?: string;
|
|
6
|
+
/** Colour past dates as overdue and today as due today. */
|
|
7
|
+
deadline?: boolean;
|
|
8
|
+
placeholder?: string;
|
|
9
|
+
/** Called with `YYYY-MM-DD`, or an empty string when cleared. */
|
|
10
|
+
onchange: (value: string) => void;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const DateField: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
14
|
+
type DateField = ReturnType<typeof DateField>;
|
|
15
|
+
export default DateField;
|