terminoir 0.6.0 → 0.7.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 +3 -1
- 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/PropertyRow.svelte +68 -0
- package/dist/components/PropertyRow.svelte.d.ts +12 -0
- package/dist/components/SearchPicker.svelte +2 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -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/package.json +1 -1
package/README.md
CHANGED
|
@@ -152,7 +152,9 @@ 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 |
|
|
@@ -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;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils/cn.js';
|
|
4
|
+
import NavIcon from './NavIcon.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
label,
|
|
8
|
+
icon,
|
|
9
|
+
class: className = '',
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
label: string;
|
|
13
|
+
/** NavIcon name hinting at the value's kind, e.g. `calendar`. */
|
|
14
|
+
icon?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
/** One value control, rendered with its own label hidden. */
|
|
17
|
+
children?: Snippet;
|
|
18
|
+
} = $props();
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div class={cn('terminoir-property-row', className)}>
|
|
22
|
+
<span class="terminoir-property-row__label" title={label}>
|
|
23
|
+
{#if icon}<NavIcon name={icon} />{/if}
|
|
24
|
+
<span class="terminoir-property-row__text">{label}</span>
|
|
25
|
+
</span>
|
|
26
|
+
<div class="terminoir-property-row__value">{@render children?.()}</div>
|
|
27
|
+
</div>
|
|
28
|
+
|
|
29
|
+
<style>
|
|
30
|
+
.terminoir-property-row {
|
|
31
|
+
display: grid;
|
|
32
|
+
grid-template-columns: minmax(6.5rem, 9.5rem) minmax(0, 1fr);
|
|
33
|
+
align-items: center;
|
|
34
|
+
gap: var(--space-3);
|
|
35
|
+
min-height: 2.25rem;
|
|
36
|
+
padding-inline: var(--space-2);
|
|
37
|
+
margin-inline: calc(-1 * var(--space-2));
|
|
38
|
+
border-radius: var(--radius-control);
|
|
39
|
+
transition: background 120ms ease;
|
|
40
|
+
}
|
|
41
|
+
.terminoir-property-row:hover,
|
|
42
|
+
.terminoir-property-row:focus-within {
|
|
43
|
+
background: color-mix(in srgb, var(--foreground) 4%, transparent);
|
|
44
|
+
}
|
|
45
|
+
.terminoir-property-row__label {
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
gap: var(--space-2);
|
|
49
|
+
min-width: 0;
|
|
50
|
+
font-size: 0.8125rem;
|
|
51
|
+
color: var(--muted-foreground);
|
|
52
|
+
}
|
|
53
|
+
.terminoir-property-row__label :global(i) {
|
|
54
|
+
width: 1rem;
|
|
55
|
+
flex-shrink: 0;
|
|
56
|
+
text-align: center;
|
|
57
|
+
font-size: 0.75rem;
|
|
58
|
+
opacity: 0.75;
|
|
59
|
+
}
|
|
60
|
+
.terminoir-property-row__text {
|
|
61
|
+
overflow: hidden;
|
|
62
|
+
text-overflow: ellipsis;
|
|
63
|
+
white-space: nowrap;
|
|
64
|
+
}
|
|
65
|
+
.terminoir-property-row__value {
|
|
66
|
+
min-width: 0;
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
label: string;
|
|
4
|
+
/** NavIcon name hinting at the value's kind, e.g. `calendar`. */
|
|
5
|
+
icon?: string;
|
|
6
|
+
class?: string;
|
|
7
|
+
/** One value control, rendered with its own label hidden. */
|
|
8
|
+
children?: Snippet;
|
|
9
|
+
};
|
|
10
|
+
declare const PropertyRow: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type PropertyRow = ReturnType<typeof PropertyRow>;
|
|
12
|
+
export default PropertyRow;
|
|
@@ -136,6 +136,8 @@
|
|
|
136
136
|
</div>
|
|
137
137
|
|
|
138
138
|
<style>
|
|
139
|
+
/* In a row (e.g. after selected chips) the search box takes the remaining width. */
|
|
140
|
+
.terminoir-picker { flex: 1 1 8rem; min-width: 8rem; }
|
|
139
141
|
.terminoir-picker__anchor { position: relative; }
|
|
140
142
|
.terminoir-picker__list {
|
|
141
143
|
position: absolute;
|
package/dist/index.d.ts
CHANGED
|
@@ -65,6 +65,11 @@ export { default as ListItem } from './components/ListItem.svelte';
|
|
|
65
65
|
export { default as DescriptionList } from './components/DescriptionList.svelte';
|
|
66
66
|
export type { DescriptionItem } from './utils/componentTypes.js';
|
|
67
67
|
export { default as FormRow } from './components/FormRow.svelte';
|
|
68
|
+
export { default as PropertyRow } from './components/PropertyRow.svelte';
|
|
69
|
+
export { default as ChipSelect } from './components/ChipSelect.svelte';
|
|
70
|
+
export type { ChipSelectGroup, ChipSelectOption } from './utils/componentTypes.js';
|
|
71
|
+
export { default as DateField } from './components/DateField.svelte';
|
|
72
|
+
export { dayLabel, dayUrgency, parseYmd, type DayUrgency } from './utils/dayLabel.js';
|
|
68
73
|
export { default as SearchPicker } from './components/SearchPicker.svelte';
|
|
69
74
|
export { default as FileButton } from './components/FileButton.svelte';
|
|
70
75
|
export { default as TitleInput } from './components/TitleInput.svelte';
|
package/dist/index.js
CHANGED
|
@@ -60,7 +60,11 @@ export { default as List } from './components/List.svelte';
|
|
|
60
60
|
export { default as ListItem } from './components/ListItem.svelte';
|
|
61
61
|
export { default as DescriptionList } from './components/DescriptionList.svelte';
|
|
62
62
|
export { default as FormRow } from './components/FormRow.svelte';
|
|
63
|
+
export { default as PropertyRow } from './components/PropertyRow.svelte';
|
|
63
64
|
// Inputs
|
|
65
|
+
export { default as ChipSelect } from './components/ChipSelect.svelte';
|
|
66
|
+
export { default as DateField } from './components/DateField.svelte';
|
|
67
|
+
export { dayLabel, dayUrgency, parseYmd } from './utils/dayLabel.js';
|
|
64
68
|
export { default as SearchPicker } from './components/SearchPicker.svelte';
|
|
65
69
|
export { default as FileButton } from './components/FileButton.svelte';
|
|
66
70
|
export { default as TitleInput } from './components/TitleInput.svelte';
|
|
@@ -15,6 +15,18 @@ export type PickerOption = {
|
|
|
15
15
|
icon?: string;
|
|
16
16
|
iconColor?: string;
|
|
17
17
|
};
|
|
18
|
+
export type ChipSelectOption = {
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
/** Dot colour (palette name or CSS colour); grey when unset. */
|
|
22
|
+
color?: string;
|
|
23
|
+
/** Id of the `ChipSelectGroup` heading it sits under. */
|
|
24
|
+
group?: string;
|
|
25
|
+
};
|
|
26
|
+
export type ChipSelectGroup = {
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
};
|
|
18
30
|
export type RadioCardOption = {
|
|
19
31
|
value: string;
|
|
20
32
|
label: string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** How far a day is from today, for colouring deadlines. */
|
|
2
|
+
export type DayUrgency = 'past' | 'today' | 'future';
|
|
3
|
+
/** Local-midnight Date for `YYYY-MM-DD`, or null when the text is not a calendar date. */
|
|
4
|
+
export declare function parseYmd(ymd: string): Date | null;
|
|
5
|
+
/** "Today", "Tomorrow", "Yesterday", a weekday within the next week, else "Nov 12" (plus the year when it differs). */
|
|
6
|
+
export declare function dayLabel(date: Date, today?: Date): string;
|
|
7
|
+
export declare function dayUrgency(date: Date, today?: Date): DayUrgency;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { addDays, sameDay, startOfDay } from './calendar.js';
|
|
2
|
+
const DAYS_SHOWN_AS_WEEKDAY = 6;
|
|
3
|
+
const YMD = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
4
|
+
/** Local-midnight Date for `YYYY-MM-DD`, or null when the text is not a calendar date. */
|
|
5
|
+
export function parseYmd(ymd) {
|
|
6
|
+
const match = YMD.exec(ymd.trim());
|
|
7
|
+
if (!match)
|
|
8
|
+
return null;
|
|
9
|
+
const [, year, month, day] = match.map(Number);
|
|
10
|
+
const date = new Date(year, month - 1, day);
|
|
11
|
+
return date.getMonth() === month - 1 && date.getDate() === day ? date : null;
|
|
12
|
+
}
|
|
13
|
+
/** "Today", "Tomorrow", "Yesterday", a weekday within the next week, else "Nov 12" (plus the year when it differs). */
|
|
14
|
+
export function dayLabel(date, today = new Date()) {
|
|
15
|
+
const day = startOfDay(date);
|
|
16
|
+
const now = startOfDay(today);
|
|
17
|
+
if (sameDay(day, now))
|
|
18
|
+
return 'Today';
|
|
19
|
+
if (sameDay(day, addDays(now, 1)))
|
|
20
|
+
return 'Tomorrow';
|
|
21
|
+
if (sameDay(day, addDays(now, -1)))
|
|
22
|
+
return 'Yesterday';
|
|
23
|
+
if (day > now && day <= addDays(now, DAYS_SHOWN_AS_WEEKDAY)) {
|
|
24
|
+
return day.toLocaleDateString(undefined, { weekday: 'long' });
|
|
25
|
+
}
|
|
26
|
+
const withYear = day.getFullYear() !== now.getFullYear();
|
|
27
|
+
return day.toLocaleDateString(undefined, { month: 'short', day: 'numeric', ...(withYear && { year: 'numeric' }) });
|
|
28
|
+
}
|
|
29
|
+
export function dayUrgency(date, today = new Date()) {
|
|
30
|
+
const day = startOfDay(date);
|
|
31
|
+
const now = startOfDay(today);
|
|
32
|
+
if (sameDay(day, now))
|
|
33
|
+
return 'today';
|
|
34
|
+
return day < now ? 'past' : 'future';
|
|
35
|
+
}
|