@ruckc/truss-ui 0.0.0-stage → 1.0.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/LICENSE +24 -0
- package/README.md +91 -2
- package/dist/color.d.ts +21 -0
- package/dist/color.js +32 -0
- package/dist/components/Accordion.svelte +70 -0
- package/dist/components/Accordion.svelte.d.ts +15 -0
- package/dist/components/Alert.svelte +46 -0
- package/dist/components/Alert.svelte.d.ts +15 -0
- package/dist/components/Avatar.svelte +32 -0
- package/dist/components/Avatar.svelte.d.ts +9 -0
- package/dist/components/AvatarGroup.svelte +20 -0
- package/dist/components/AvatarGroup.svelte.d.ts +11 -0
- package/dist/components/Badge.svelte +27 -0
- package/dist/components/Badge.svelte.d.ts +9 -0
- package/dist/components/Breadcrumbs.svelte +38 -0
- package/dist/components/Breadcrumbs.svelte.d.ts +12 -0
- package/dist/components/Button.svelte +119 -0
- package/dist/components/Button.svelte.d.ts +16 -0
- package/dist/components/ButtonGroup.svelte +23 -0
- package/dist/components/ButtonGroup.svelte.d.ts +9 -0
- package/dist/components/Calendar.svelte +166 -0
- package/dist/components/Calendar.svelte.d.ts +19 -0
- package/dist/components/Callout.svelte +50 -0
- package/dist/components/Callout.svelte.d.ts +14 -0
- package/dist/components/Card.svelte +50 -0
- package/dist/components/Card.svelte.d.ts +15 -0
- package/dist/components/Checkbox.svelte +37 -0
- package/dist/components/Checkbox.svelte.d.ts +16 -0
- package/dist/components/ColorPicker.svelte +164 -0
- package/dist/components/ColorPicker.svelte.d.ts +14 -0
- package/dist/components/Combobox.svelte +131 -0
- package/dist/components/Combobox.svelte.d.ts +19 -0
- package/dist/components/ContextMenu.svelte +51 -0
- package/dist/components/ContextMenu.svelte.d.ts +10 -0
- package/dist/components/DataTable.svelte +657 -0
- package/dist/components/DataTable.svelte.d.ts +76 -0
- package/dist/components/DatePicker.svelte +70 -0
- package/dist/components/DatePicker.svelte.d.ts +15 -0
- package/dist/components/DateRangePicker.svelte +92 -0
- package/dist/components/DateRangePicker.svelte.d.ts +15 -0
- package/dist/components/DateTimePicker.svelte +62 -0
- package/dist/components/DateTimePicker.svelte.d.ts +19 -0
- package/dist/components/DateTimeRangePicker.svelte +167 -0
- package/dist/components/DateTimeRangePicker.svelte.d.ts +22 -0
- package/dist/components/Dialog.svelte +70 -0
- package/dist/components/Dialog.svelte.d.ts +14 -0
- package/dist/components/Divider.svelte +16 -0
- package/dist/components/Divider.svelte.d.ts +8 -0
- package/dist/components/Drawer.svelte +64 -0
- package/dist/components/Drawer.svelte.d.ts +13 -0
- package/dist/components/DurationInput.svelte +112 -0
- package/dist/components/DurationInput.svelte.d.ts +19 -0
- package/dist/components/EditableText.svelte +73 -0
- package/dist/components/EditableText.svelte.d.ts +14 -0
- package/dist/components/EmptyState.svelte +30 -0
- package/dist/components/EmptyState.svelte.d.ts +12 -0
- package/dist/components/FileUpload.svelte +143 -0
- package/dist/components/FileUpload.svelte.d.ts +18 -0
- package/dist/components/FormGroup.svelte +27 -0
- package/dist/components/FormGroup.svelte.d.ts +13 -0
- package/dist/components/Input.svelte +62 -0
- package/dist/components/Input.svelte.d.ts +15 -0
- package/dist/components/InputAddon.svelte +21 -0
- package/dist/components/InputAddon.svelte.d.ts +9 -0
- package/dist/components/InputGroup.svelte +70 -0
- package/dist/components/InputGroup.svelte.d.ts +16 -0
- package/dist/components/Menu.svelte +95 -0
- package/dist/components/Menu.svelte.d.ts +15 -0
- package/dist/components/MenuButton.svelte +45 -0
- package/dist/components/MenuButton.svelte.d.ts +16 -0
- package/dist/components/MultiSelect.svelte +129 -0
- package/dist/components/MultiSelect.svelte.d.ts +18 -0
- package/dist/components/Navbar.svelte +17 -0
- package/dist/components/Navbar.svelte.d.ts +10 -0
- package/dist/components/NumberInput.svelte +66 -0
- package/dist/components/NumberInput.svelte.d.ts +16 -0
- package/dist/components/OptionList.svelte +74 -0
- package/dist/components/OptionList.svelte.d.ts +15 -0
- package/dist/components/Popover.svelte +87 -0
- package/dist/components/Popover.svelte.d.ts +14 -0
- package/dist/components/ProgressBar.svelte +35 -0
- package/dist/components/ProgressBar.svelte.d.ts +10 -0
- package/dist/components/RadioGroup.svelte +39 -0
- package/dist/components/RadioGroup.svelte.d.ts +14 -0
- package/dist/components/Section.svelte +56 -0
- package/dist/components/Section.svelte.d.ts +15 -0
- package/dist/components/SegmentedControl.svelte +56 -0
- package/dist/components/SegmentedControl.svelte.d.ts +17 -0
- package/dist/components/Select.svelte +204 -0
- package/dist/components/Select.svelte.d.ts +41 -0
- package/dist/components/Shell.svelte +143 -0
- package/dist/components/Shell.svelte.d.ts +36 -0
- package/dist/components/Sidebar.svelte +84 -0
- package/dist/components/Sidebar.svelte.d.ts +24 -0
- package/dist/components/Skeleton.svelte +25 -0
- package/dist/components/Skeleton.svelte.d.ts +9 -0
- package/dist/components/Slider.svelte +121 -0
- package/dist/components/Slider.svelte.d.ts +16 -0
- package/dist/components/Spinner.svelte +30 -0
- package/dist/components/Spinner.svelte.d.ts +9 -0
- package/dist/components/SplitButton.svelte +113 -0
- package/dist/components/SplitButton.svelte.d.ts +14 -0
- package/dist/components/SplitPane.svelte +106 -0
- package/dist/components/SplitPane.svelte.d.ts +16 -0
- package/dist/components/Stepper.svelte +73 -0
- package/dist/components/Stepper.svelte.d.ts +26 -0
- package/dist/components/Switch.svelte +21 -0
- package/dist/components/Switch.svelte.d.ts +10 -0
- package/dist/components/Tabs.svelte +74 -0
- package/dist/components/Tabs.svelte.d.ts +18 -0
- package/dist/components/Tag.svelte +49 -0
- package/dist/components/Tag.svelte.d.ts +16 -0
- package/dist/components/TagInput.svelte +72 -0
- package/dist/components/TagInput.svelte.d.ts +17 -0
- package/dist/components/Textarea.svelte +37 -0
- package/dist/components/Textarea.svelte.d.ts +11 -0
- package/dist/components/TimeList.svelte +76 -0
- package/dist/components/TimeList.svelte.d.ts +10 -0
- package/dist/components/TimePicker.svelte +79 -0
- package/dist/components/TimePicker.svelte.d.ts +15 -0
- package/dist/components/TimeRangePicker.svelte +44 -0
- package/dist/components/TimeRangePicker.svelte.d.ts +17 -0
- package/dist/components/Timeline.svelte +38 -0
- package/dist/components/Timeline.svelte.d.ts +17 -0
- package/dist/components/Toaster.svelte +35 -0
- package/dist/components/Toaster.svelte.d.ts +18 -0
- package/dist/components/Tooltip.svelte +66 -0
- package/dist/components/Tooltip.svelte.d.ts +11 -0
- package/dist/components/Tree.svelte +147 -0
- package/dist/components/Tree.svelte.d.ts +16 -0
- package/dist/date.d.ts +19 -0
- package/dist/date.js +43 -0
- package/dist/duration.d.ts +11 -0
- package/dist/duration.js +44 -0
- package/dist/fonts/LICENSE-Inter.txt +93 -0
- package/dist/fonts/LICENSE-JetBrainsMono.txt +93 -0
- package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/dist/icons/Icon.svelte +30 -0
- package/dist/icons/Icon.svelte.d.ts +9 -0
- package/dist/icons/paths.d.ts +39 -0
- package/dist/icons/paths.js +43 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +65 -0
- package/dist/styles/base.css +71 -0
- package/dist/styles/fonts.css +21 -0
- package/dist/styles/index.css +3 -0
- package/dist/styles/tokens.css +143 -0
- package/dist/time.d.ts +7 -0
- package/dist/time.js +30 -0
- package/dist/toast.svelte.d.ts +22 -0
- package/dist/toast.svelte.js +19 -0
- package/dist/types.d.ts +70 -0
- package/dist/types.js +1 -0
- package/package.json +76 -5
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getContext, setContext, type Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
orientation = 'horizontal',
|
|
6
|
+
size = $bindable(50),
|
|
7
|
+
minFirst = 120,
|
|
8
|
+
minSecond = 120,
|
|
9
|
+
defaultSize = 50,
|
|
10
|
+
label = 'Resize panes',
|
|
11
|
+
first,
|
|
12
|
+
second
|
|
13
|
+
}: {
|
|
14
|
+
/** horizontal = side by side (vertical divider); vertical = stacked. */
|
|
15
|
+
orientation?: 'horizontal' | 'vertical';
|
|
16
|
+
/** Size of the first pane as a percentage (0–100). */
|
|
17
|
+
size?: number;
|
|
18
|
+
minFirst?: number;
|
|
19
|
+
minSecond?: number;
|
|
20
|
+
defaultSize?: number;
|
|
21
|
+
label?: string;
|
|
22
|
+
first: Snippet;
|
|
23
|
+
second: Snippet;
|
|
24
|
+
} = $props();
|
|
25
|
+
|
|
26
|
+
// Nested inside another SplitPane: drop our own frame so panes don't double up borders/corners.
|
|
27
|
+
const nested = getContext<boolean>('tr-split') === true;
|
|
28
|
+
setContext('tr-split', true);
|
|
29
|
+
|
|
30
|
+
let root: HTMLDivElement;
|
|
31
|
+
let dragging = $state(false);
|
|
32
|
+
const horiz = $derived(orientation === 'horizontal');
|
|
33
|
+
|
|
34
|
+
function clamp(pct: number) {
|
|
35
|
+
const total = (horiz ? root?.clientWidth : root?.clientHeight) || 0;
|
|
36
|
+
if (!total) return pct;
|
|
37
|
+
const lo = (minFirst / total) * 100;
|
|
38
|
+
const hi = 100 - (minSecond / total) * 100;
|
|
39
|
+
return Math.min(Math.max(pct, lo), Math.max(lo, hi));
|
|
40
|
+
}
|
|
41
|
+
// keep the size within the minimums when the container (or the minimums) change
|
|
42
|
+
$effect(() => {
|
|
43
|
+
if (!root) return;
|
|
44
|
+
minFirst; minSecond; horiz;
|
|
45
|
+
const fit = () => { const n = clamp(size); if (Math.abs(n - size) > 0.01) size = n; };
|
|
46
|
+
fit();
|
|
47
|
+
const ro = new ResizeObserver(fit);
|
|
48
|
+
ro.observe(root);
|
|
49
|
+
return () => ro.disconnect();
|
|
50
|
+
});
|
|
51
|
+
function onDown(e: PointerEvent) {
|
|
52
|
+
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
53
|
+
dragging = true;
|
|
54
|
+
}
|
|
55
|
+
function onMove(e: PointerEvent) {
|
|
56
|
+
if (!dragging) return;
|
|
57
|
+
const r = root.getBoundingClientRect();
|
|
58
|
+
size = clamp(horiz ? ((e.clientX - r.left) / r.width) * 100 : ((e.clientY - r.top) / r.height) * 100);
|
|
59
|
+
}
|
|
60
|
+
function onKey(e: KeyboardEvent) {
|
|
61
|
+
const dec = horiz ? 'ArrowLeft' : 'ArrowUp';
|
|
62
|
+
const inc = horiz ? 'ArrowRight' : 'ArrowDown';
|
|
63
|
+
const step = e.shiftKey ? 10 : 2;
|
|
64
|
+
if (e.key === dec) size = clamp(size - step);
|
|
65
|
+
else if (e.key === inc) size = clamp(size + step);
|
|
66
|
+
else if (e.key === 'Home') size = clamp(0);
|
|
67
|
+
else if (e.key === 'End') size = clamp(100);
|
|
68
|
+
else if (e.key === 'Enter') size = clamp(defaultSize);
|
|
69
|
+
else return;
|
|
70
|
+
e.preventDefault();
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<div class="split" bind:this={root} data-orientation={orientation} data-dragging={dragging} data-nested={nested} style:--size="{size}%">
|
|
75
|
+
<div class="pane first">{@render first()}</div>
|
|
76
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
77
|
+
<div
|
|
78
|
+
class="bar" role="separator" tabindex="0" aria-label={label} aria-orientation={horiz ? 'vertical' : 'horizontal'}
|
|
79
|
+
aria-valuenow={Math.round(size)} aria-valuemin={0} aria-valuemax={100}
|
|
80
|
+
onpointerdown={onDown} onpointermove={onMove} onpointerup={() => (dragging = false)} onpointercancel={() => (dragging = false)}
|
|
81
|
+
ondblclick={() => (size = clamp(defaultSize))} onkeydown={onKey}
|
|
82
|
+
></div>
|
|
83
|
+
<div class="pane second">{@render second()}</div>
|
|
84
|
+
</div>
|
|
85
|
+
|
|
86
|
+
<style>
|
|
87
|
+
.split { display: grid; width: 100%; height: 100%; min-height: 0; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); overflow: hidden; background: var(--tr-surface); }
|
|
88
|
+
.split[data-nested='true'] { border: 0; border-radius: 0; background: transparent; }
|
|
89
|
+
.split[data-orientation='horizontal'] { grid-template-columns: var(--size) 1px minmax(0, 1fr); }
|
|
90
|
+
.split[data-orientation='vertical'] { grid-template-rows: var(--size) 1px minmax(0, 1fr); }
|
|
91
|
+
.pane { min-width: 0; min-height: 0; overflow: auto; }
|
|
92
|
+
|
|
93
|
+
/* 1px hairline with a generous invisible hit area; accent line appears on hover/focus/drag */
|
|
94
|
+
.bar { position: relative; z-index: 2; background: var(--tr-outline-variant); touch-action: none; outline: none; }
|
|
95
|
+
.bar::before { content: ''; position: absolute; }
|
|
96
|
+
.bar::after { content: ''; position: absolute; background: var(--tr-primary); border-radius: 2px; opacity: 0; transition: opacity var(--tr-dur) var(--tr-ease); pointer-events: none; }
|
|
97
|
+
[data-orientation='horizontal'] > .bar { cursor: col-resize; }
|
|
98
|
+
[data-orientation='horizontal'] > .bar::before { inset: 0 -4px; }
|
|
99
|
+
[data-orientation='horizontal'] > .bar::after { top: 0; bottom: 0; left: -0.5px; width: 2px; }
|
|
100
|
+
[data-orientation='vertical'] > .bar { cursor: row-resize; }
|
|
101
|
+
[data-orientation='vertical'] > .bar::before { inset: -4px 0; }
|
|
102
|
+
[data-orientation='vertical'] > .bar::after { left: 0; right: 0; top: -0.5px; height: 2px; }
|
|
103
|
+
.bar:hover::after, .bar:focus-visible::after, .split[data-dragging='true'] > .bar::after { opacity: 1; }
|
|
104
|
+
.bar:hover::after { transition-delay: 120ms; }
|
|
105
|
+
.split[data-dragging='true'] { user-select: none; }
|
|
106
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** horizontal = side by side (vertical divider); vertical = stacked. */
|
|
4
|
+
orientation?: 'horizontal' | 'vertical';
|
|
5
|
+
/** Size of the first pane as a percentage (0–100). */
|
|
6
|
+
size?: number;
|
|
7
|
+
minFirst?: number;
|
|
8
|
+
minSecond?: number;
|
|
9
|
+
defaultSize?: number;
|
|
10
|
+
label?: string;
|
|
11
|
+
first: Snippet;
|
|
12
|
+
second: Snippet;
|
|
13
|
+
};
|
|
14
|
+
declare const SplitPane: import("svelte").Component<$$ComponentProps, {}, "size">;
|
|
15
|
+
type SplitPane = ReturnType<typeof SplitPane>;
|
|
16
|
+
export default SplitPane;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Icon from '../icons/Icon.svelte';
|
|
4
|
+
|
|
5
|
+
type Step = { id: string; label: string; description?: string; optional?: boolean; error?: boolean };
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
steps,
|
|
9
|
+
current = $bindable(0),
|
|
10
|
+
orientation = 'horizontal',
|
|
11
|
+
clickable = true,
|
|
12
|
+
panel
|
|
13
|
+
}: {
|
|
14
|
+
steps: Step[];
|
|
15
|
+
/** Zero-based active step. */
|
|
16
|
+
current?: number;
|
|
17
|
+
orientation?: 'horizontal' | 'vertical';
|
|
18
|
+
/** Allow jumping back to completed steps. */
|
|
19
|
+
clickable?: boolean;
|
|
20
|
+
/** Content for the active step, with navigation helpers. */
|
|
21
|
+
panel?: Snippet<[index: number, api: { next: () => void; prev: () => void; first: boolean; last: boolean }]>;
|
|
22
|
+
} = $props();
|
|
23
|
+
|
|
24
|
+
const next = () => (current = Math.min(steps.length - 1, current + 1));
|
|
25
|
+
const prev = () => (current = Math.max(0, current - 1));
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div class="st" data-orientation={orientation}>
|
|
29
|
+
<ol aria-label="Progress">
|
|
30
|
+
{#each steps as s, i (s.id)}
|
|
31
|
+
{@const state = s.error ? 'error' : i < current ? 'done' : i === current ? 'active' : 'todo'}
|
|
32
|
+
<li data-state={state} aria-current={i === current ? 'step' : undefined}>
|
|
33
|
+
<button type="button" disabled={!(clickable && i < current)} onclick={() => (current = i)}>
|
|
34
|
+
<span class="dot">
|
|
35
|
+
{#if state === 'done'}<Icon name="tick" />{:else if state === 'error'}<Icon name="cross" />{:else}{i + 1}{/if}
|
|
36
|
+
</span>
|
|
37
|
+
<span class="tx">
|
|
38
|
+
<span class="lb">{s.label}{#if s.optional}<em> · optional</em>{/if}</span>
|
|
39
|
+
{#if s.description}<span class="ds">{s.description}</span>{/if}
|
|
40
|
+
</span>
|
|
41
|
+
</button>
|
|
42
|
+
{#if i < steps.length - 1}<span class="ln" aria-hidden="true"></span>{/if}
|
|
43
|
+
</li>
|
|
44
|
+
{/each}
|
|
45
|
+
</ol>
|
|
46
|
+
{#if panel}<div class="panel" role="region" aria-live="polite">{@render panel(current, { next, prev, first: current === 0, last: current === steps.length - 1 })}</div>{/if}
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<style>
|
|
50
|
+
ol { display: flex; margin: 0; padding: 0; list-style: none; }
|
|
51
|
+
[data-orientation='vertical'] ol { flex-direction: column; }
|
|
52
|
+
[data-orientation='horizontal'] li { display: flex; align-items: flex-start; flex: 1; }
|
|
53
|
+
[data-orientation='horizontal'] li:last-child { flex: none; }
|
|
54
|
+
[data-orientation='horizontal'] .ln { flex: 1; min-width: 16px; height: 2px; margin: 15px 12px 0; background: var(--tr-outline-variant); }
|
|
55
|
+
[data-orientation='vertical'] li { position: relative; }
|
|
56
|
+
[data-orientation='vertical'] li:not(:last-child) { padding-bottom: 20px; }
|
|
57
|
+
[data-orientation='vertical'] .ln { position: absolute; left: 15px; top: 36px; bottom: 4px; width: 2px; background: var(--tr-outline-variant); }
|
|
58
|
+
li[data-state='done'] .ln { background: var(--tr-primary); }
|
|
59
|
+
button { display: flex; align-items: flex-start; gap: 10px; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; }
|
|
60
|
+
button:not(:disabled) { cursor: pointer; }
|
|
61
|
+
button:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
|
|
62
|
+
.dot { display: grid; place-items: center; flex: none; width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--tr-outline); background: var(--tr-surface); color: var(--tr-on-surface-variant); font-weight: 700; font-variant-numeric: tabular-nums; }
|
|
63
|
+
.dot :global(svg) { width: 16px; height: 16px; }
|
|
64
|
+
[data-state='active'] .dot { border-color: var(--tr-primary); background: var(--tr-primary); color: var(--tr-on-primary); }
|
|
65
|
+
[data-state='done'] .dot { border-color: var(--tr-primary); background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
|
|
66
|
+
[data-state='error'] .dot { border-color: var(--tr-danger); background: var(--tr-danger-container); color: var(--tr-on-danger-container); }
|
|
67
|
+
.tx { display: flex; flex-direction: column; padding-top: 5px; line-height: 1.25; }
|
|
68
|
+
.lb { font-weight: 600; color: var(--tr-on-surface); }
|
|
69
|
+
[data-state='todo'] .lb { color: var(--tr-on-surface-variant); }
|
|
70
|
+
.lb em { font-weight: 400; font-style: normal; color: var(--tr-on-surface-variant); }
|
|
71
|
+
.ds { font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); }
|
|
72
|
+
.panel { margin-top: 16px; padding: 16px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); background: var(--tr-surface-container-low); }
|
|
73
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type Step = {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
optional?: boolean;
|
|
7
|
+
error?: boolean;
|
|
8
|
+
};
|
|
9
|
+
type $$ComponentProps = {
|
|
10
|
+
steps: Step[];
|
|
11
|
+
/** Zero-based active step. */
|
|
12
|
+
current?: number;
|
|
13
|
+
orientation?: 'horizontal' | 'vertical';
|
|
14
|
+
/** Allow jumping back to completed steps. */
|
|
15
|
+
clickable?: boolean;
|
|
16
|
+
/** Content for the active step, with navigation helpers. */
|
|
17
|
+
panel?: Snippet<[index: number, api: {
|
|
18
|
+
next: () => void;
|
|
19
|
+
prev: () => void;
|
|
20
|
+
first: boolean;
|
|
21
|
+
last: boolean;
|
|
22
|
+
}]>;
|
|
23
|
+
};
|
|
24
|
+
declare const Stepper: import("svelte").Component<$$ComponentProps, {}, "current">;
|
|
25
|
+
type Stepper = ReturnType<typeof Stepper>;
|
|
26
|
+
export default Stepper;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let { checked = $bindable(false), disabled = false, children, ...rest }: { checked?: boolean; disabled?: boolean; children?: Snippet; 'aria-label'?: string } = $props();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<label class="sw" data-disabled={disabled}>
|
|
8
|
+
<input type="checkbox" role="switch" class="tr-visually-hidden" bind:checked {disabled} {...rest} />
|
|
9
|
+
<span class="track" data-on={checked}><span class="thumb"></span></span>
|
|
10
|
+
{#if children}<span>{@render children()}</span>{/if}
|
|
11
|
+
</label>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.sw { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; cursor: pointer; }
|
|
15
|
+
.sw[data-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
|
|
16
|
+
.track { position: relative; flex: none; width: 32px; height: 18px; border: 1px solid var(--tr-outline); border-radius: var(--tr-radius-pill); background: var(--tr-surface-container-highest); transition: background-color var(--tr-dur) var(--tr-ease); }
|
|
17
|
+
.thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--tr-outline); transition: transform var(--tr-dur) var(--tr-ease), background-color var(--tr-dur) var(--tr-ease); }
|
|
18
|
+
.track[data-on='true'] { background: var(--tr-primary); border-color: var(--tr-primary); }
|
|
19
|
+
.track[data-on='true'] .thumb { transform: translateX(14px); background: var(--tr-on-primary); }
|
|
20
|
+
input:focus-visible + .track { box-shadow: var(--tr-focus-ring); }
|
|
21
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
checked?: boolean;
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
children?: Snippet;
|
|
6
|
+
'aria-label'?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const Switch: import("svelte").Component<$$ComponentProps, {}, "checked">;
|
|
9
|
+
type Switch = ReturnType<typeof Switch>;
|
|
10
|
+
export default Switch;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Icon from '../icons/Icon.svelte';
|
|
4
|
+
import type { IconName } from '../icons/paths.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
tabs,
|
|
8
|
+
value = $bindable(),
|
|
9
|
+
panel,
|
|
10
|
+
label = 'Sections',
|
|
11
|
+
variant = 'line'
|
|
12
|
+
}: {
|
|
13
|
+
tabs: { id: string; label: string; icon?: IconName; disabled?: boolean }[];
|
|
14
|
+
value?: string;
|
|
15
|
+
/** Rendered for the active tab only (lazy). */
|
|
16
|
+
panel: Snippet<[id: string]>;
|
|
17
|
+
label?: string;
|
|
18
|
+
variant?: 'line' | 'pill';
|
|
19
|
+
} = $props();
|
|
20
|
+
|
|
21
|
+
const uid = $props.id();
|
|
22
|
+
const current = $derived(value ?? tabs.find((t) => !t.disabled)?.id);
|
|
23
|
+
let list: HTMLDivElement;
|
|
24
|
+
|
|
25
|
+
function onKey(e: KeyboardEvent) {
|
|
26
|
+
const btns = [...list.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)')];
|
|
27
|
+
const i = btns.indexOf(document.activeElement as HTMLButtonElement);
|
|
28
|
+
const to = e.key === 'ArrowRight' ? (i + 1) % btns.length : e.key === 'ArrowLeft' ? (i - 1 + btns.length) % btns.length : e.key === 'Home' ? 0 : e.key === 'End' ? btns.length - 1 : -1;
|
|
29
|
+
if (to < 0) return;
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
btns[to].focus();
|
|
32
|
+
value = btns[to].dataset.id; // automatic activation
|
|
33
|
+
}
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<div class="tabs" data-variant={variant}>
|
|
37
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
38
|
+
<div class="list" role="tablist" aria-label={label} bind:this={list} onkeydown={onKey}>
|
|
39
|
+
{#each tabs as t (t.id)}
|
|
40
|
+
<button
|
|
41
|
+
type="button" role="tab" id="{uid}-tab-{t.id}" data-id={t.id} disabled={t.disabled}
|
|
42
|
+
aria-selected={t.id === current} aria-controls="{uid}-panel-{t.id}" tabindex={t.id === current ? 0 : -1}
|
|
43
|
+
onclick={() => (value = t.id)}
|
|
44
|
+
>
|
|
45
|
+
{#if t.icon}<Icon name={t.icon} />{/if}{t.label}
|
|
46
|
+
</button>
|
|
47
|
+
{/each}
|
|
48
|
+
</div>
|
|
49
|
+
{#if current}
|
|
50
|
+
<div class="panel" role="tabpanel" id="{uid}-panel-{current}" aria-labelledby="{uid}-tab-{current}" tabindex="0">
|
|
51
|
+
{@render panel(current)}
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<style>
|
|
57
|
+
.list { display: flex; gap: 2px; border-bottom: 1px solid var(--tr-outline-variant); overflow-x: auto; }
|
|
58
|
+
button {
|
|
59
|
+
position: relative; display: inline-flex; align-items: center; gap: 6px; height: 36px; padding: 0 14px; border: 0; background: none;
|
|
60
|
+
color: var(--tr-on-surface-variant); font: 600 var(--tr-text-md) var(--tr-font-sans); white-space: nowrap; cursor: pointer;
|
|
61
|
+
border-radius: var(--tr-radius-sm) var(--tr-radius-sm) 0 0; transition: background-color var(--tr-dur) var(--tr-ease), color var(--tr-dur) var(--tr-ease);
|
|
62
|
+
}
|
|
63
|
+
button:hover:not(:disabled) { background: var(--tr-surface-container-high); color: var(--tr-on-surface); }
|
|
64
|
+
button:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
65
|
+
button:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
|
|
66
|
+
button[aria-selected='true'] { color: var(--tr-primary); }
|
|
67
|
+
button[aria-selected='true']::after { content: ''; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 3px; border-radius: 3px 3px 0 0; background: var(--tr-primary); }
|
|
68
|
+
.panel { padding: 16px 4px; outline: none; }
|
|
69
|
+
.panel:focus-visible { box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
|
|
70
|
+
[data-variant='pill'] .list { border: 0; gap: 4px; }
|
|
71
|
+
[data-variant='pill'] button { height: var(--tr-control-h); border-radius: var(--tr-radius-pill); }
|
|
72
|
+
[data-variant='pill'] button[aria-selected='true'] { background: var(--tr-primary-container); color: var(--tr-on-primary-container); }
|
|
73
|
+
[data-variant='pill'] button[aria-selected='true']::after { display: none; }
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { IconName } from '../icons/paths.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
tabs: {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
icon?: IconName;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}[];
|
|
10
|
+
value?: string;
|
|
11
|
+
/** Rendered for the active tab only (lazy). */
|
|
12
|
+
panel: Snippet<[id: string]>;
|
|
13
|
+
label?: string;
|
|
14
|
+
variant?: 'line' | 'pill';
|
|
15
|
+
};
|
|
16
|
+
declare const Tabs: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
17
|
+
type Tabs = ReturnType<typeof Tabs>;
|
|
18
|
+
export default Tabs;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Icon from '../icons/Icon.svelte';
|
|
4
|
+
import type { IconName } from '../icons/paths.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
intent = 'neutral',
|
|
8
|
+
variant = 'tonal',
|
|
9
|
+
icon,
|
|
10
|
+
size = 'md',
|
|
11
|
+
onremove,
|
|
12
|
+
onclick,
|
|
13
|
+
children
|
|
14
|
+
}: {
|
|
15
|
+
intent?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
|
16
|
+
variant?: 'tonal' | 'outlined';
|
|
17
|
+
icon?: IconName;
|
|
18
|
+
size?: 'sm' | 'md';
|
|
19
|
+
/** Shows a remove button. */
|
|
20
|
+
onremove?: () => void;
|
|
21
|
+
/** Makes the whole tag a button. */
|
|
22
|
+
onclick?: (e: MouseEvent) => void;
|
|
23
|
+
children: Snippet;
|
|
24
|
+
} = $props();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<span class="tag" data-intent={intent} data-variant={variant} data-size={size}>
|
|
28
|
+
{#snippet inner()}{#if icon}<Icon name={icon} />{/if}<span class="t">{@render children()}</span>{/snippet}
|
|
29
|
+
{#if onclick}<button type="button" class="main" {onclick}>{@render inner()}</button>{:else}{@render inner()}{/if}
|
|
30
|
+
{#if onremove}
|
|
31
|
+
<button type="button" class="x" aria-label="Remove" onclick={(e) => { e.stopPropagation(); onremove(); }}><Icon name="cross" /></button>
|
|
32
|
+
{/if}
|
|
33
|
+
</span>
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
.tag { --c: var(--tr-on-surface-variant); --cc: var(--tr-surface-container-high); --on: var(--tr-on-surface); display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 8px; border-radius: var(--tr-radius-sm); background: var(--cc); color: var(--on); font: 600 var(--tr-text-sm) var(--tr-font-sans); white-space: nowrap; }
|
|
37
|
+
.tag[data-size='sm'] { height: 20px; padding: 0 6px; font-size: var(--tr-text-xs); }
|
|
38
|
+
.tag[data-intent='primary'] { --c: var(--tr-primary); --cc: var(--tr-primary-container); --on: var(--tr-on-primary-container); }
|
|
39
|
+
.tag[data-intent='success'] { --c: var(--tr-success); --cc: var(--tr-success-container); --on: var(--tr-on-success-container); }
|
|
40
|
+
.tag[data-intent='warning'] { --c: var(--tr-warning); --cc: var(--tr-warning-container); --on: var(--tr-on-warning-container); }
|
|
41
|
+
.tag[data-intent='danger'] { --c: var(--tr-danger); --cc: var(--tr-danger-container); --on: var(--tr-on-danger-container); }
|
|
42
|
+
.tag[data-variant='outlined'] { background: transparent; box-shadow: inset 0 0 0 1px var(--c); color: var(--c); }
|
|
43
|
+
.main { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; }
|
|
44
|
+
.tag:has(.main:hover) { background: color-mix(in srgb, var(--on) 10%, var(--cc)); }
|
|
45
|
+
.main:focus-visible, .x:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); border-radius: var(--tr-radius-sm); }
|
|
46
|
+
.x { display: grid; place-items: center; width: 16px; height: 16px; margin-right: -4px; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; }
|
|
47
|
+
.x :global(svg) { width: 12px; height: 12px; }
|
|
48
|
+
.x:hover { background: color-mix(in srgb, currentColor 18%, transparent); }
|
|
49
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { IconName } from '../icons/paths.js';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
intent?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
|
5
|
+
variant?: 'tonal' | 'outlined';
|
|
6
|
+
icon?: IconName;
|
|
7
|
+
size?: 'sm' | 'md';
|
|
8
|
+
/** Shows a remove button. */
|
|
9
|
+
onremove?: () => void;
|
|
10
|
+
/** Makes the whole tag a button. */
|
|
11
|
+
onclick?: (e: MouseEvent) => void;
|
|
12
|
+
children: Snippet;
|
|
13
|
+
};
|
|
14
|
+
declare const Tag: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
15
|
+
type Tag = ReturnType<typeof Tag>;
|
|
16
|
+
export default Tag;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Tag from './Tag.svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable([]),
|
|
6
|
+
label,
|
|
7
|
+
ariaLabel,
|
|
8
|
+
placeholder = 'Add a tag…',
|
|
9
|
+
separators = [',', 'Enter'],
|
|
10
|
+
allowDuplicates = false,
|
|
11
|
+
validate,
|
|
12
|
+
disabled = false,
|
|
13
|
+
error
|
|
14
|
+
}: {
|
|
15
|
+
value?: string[];
|
|
16
|
+
label?: string;
|
|
17
|
+
/** Accessible name when no visible `label` is shown. */
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
/** Keys (or typed characters) that commit the current text. Pasted text is also split on commas/newlines. */
|
|
21
|
+
separators?: string[];
|
|
22
|
+
allowDuplicates?: boolean;
|
|
23
|
+
/** Return an error string to reject a tag. */
|
|
24
|
+
validate?: (tag: string) => string | undefined;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
} = $props();
|
|
28
|
+
|
|
29
|
+
const id = $props.id();
|
|
30
|
+
let text = $state('');
|
|
31
|
+
let local = $state<string>();
|
|
32
|
+
let input: HTMLInputElement;
|
|
33
|
+
const msg = $derived(error ?? local);
|
|
34
|
+
|
|
35
|
+
function add(raw: string) {
|
|
36
|
+
const parts = raw.split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
|
|
37
|
+
let next = [...value];
|
|
38
|
+
for (const t of parts) {
|
|
39
|
+
const bad = validate?.(t);
|
|
40
|
+
if (bad) { local = bad; continue; }
|
|
41
|
+
if (!allowDuplicates && next.includes(t)) { local = `“${t}” is already added`; continue; }
|
|
42
|
+
local = undefined;
|
|
43
|
+
next = [...next, t];
|
|
44
|
+
}
|
|
45
|
+
value = next;
|
|
46
|
+
text = '';
|
|
47
|
+
}
|
|
48
|
+
function onKey(e: KeyboardEvent) {
|
|
49
|
+
if (separators.includes(e.key) && text.trim()) { e.preventDefault(); add(text); }
|
|
50
|
+
else if (e.key === 'Backspace' && !text && value.length) value = value.slice(0, -1);
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<div class="tr-field">
|
|
55
|
+
{#if label}<label class="tr-label" for={id}>{label}</label>{/if}
|
|
56
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
57
|
+
<div class="tr-control ti" data-invalid={!!msg} data-disabled={disabled} onclick={() => input.focus()}>
|
|
58
|
+
{#each value as t, i (`${i}:${t}`)}<Tag intent="primary" onremove={() => (value = value.filter((_, k) => k !== i))}>{t}</Tag>{/each}
|
|
59
|
+
<input
|
|
60
|
+
class="tr-bare" {id} aria-label={label ? undefined : ariaLabel} bind:this={input} bind:value={text} {disabled} placeholder={value.length ? '' : placeholder} autocomplete="off"
|
|
61
|
+
oninput={() => { local = undefined; if (separators.includes(text.slice(-1)) && text.trim().length > 1) add(text.slice(0, -1)); }}
|
|
62
|
+
onkeydown={onKey} onblur={() => text.trim() && add(text)}
|
|
63
|
+
onpaste={(e) => { const t = e.clipboardData?.getData('text'); if (t && /[,\n]/.test(t)) { e.preventDefault(); add(t); } }}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
{#if msg}<div class="tr-hint" data-error="true">{msg}</div>{/if}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<style>
|
|
70
|
+
.ti { flex-wrap: wrap; padding: 3px 6px; gap: 4px; cursor: text; }
|
|
71
|
+
.ti .tr-bare { flex: 1 1 80px; height: 24px; }
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
value?: string[];
|
|
3
|
+
label?: string;
|
|
4
|
+
/** Accessible name when no visible `label` is shown. */
|
|
5
|
+
ariaLabel?: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
/** Keys (or typed characters) that commit the current text. Pasted text is also split on commas/newlines. */
|
|
8
|
+
separators?: string[];
|
|
9
|
+
allowDuplicates?: boolean;
|
|
10
|
+
/** Return an error string to reject a tag. */
|
|
11
|
+
validate?: (tag: string) => string | undefined;
|
|
12
|
+
disabled?: boolean;
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
declare const TagInput: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
16
|
+
type TagInput = ReturnType<typeof TagInput>;
|
|
17
|
+
export default TagInput;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable(''),
|
|
6
|
+
label,
|
|
7
|
+
hint,
|
|
8
|
+
error,
|
|
9
|
+
rows = 3,
|
|
10
|
+
autosize = false,
|
|
11
|
+
class: klass = '',
|
|
12
|
+
...rest
|
|
13
|
+
}: HTMLTextareaAttributes & { value?: string; label?: string; hint?: string; error?: string; autosize?: boolean } = $props();
|
|
14
|
+
|
|
15
|
+
const uid = $props.id();
|
|
16
|
+
const id = $derived(rest.id ?? uid);
|
|
17
|
+
let el: HTMLTextAreaElement;
|
|
18
|
+
|
|
19
|
+
$effect(() => {
|
|
20
|
+
value;
|
|
21
|
+
if (autosize && el) { el.style.height = 'auto'; el.style.height = `${el.scrollHeight}px`; }
|
|
22
|
+
});
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="tr-field {klass}">
|
|
26
|
+
{#if label}<label class="tr-label" for={id}>{label}</label>{/if}
|
|
27
|
+
<div class="tr-control" data-invalid={!!error} data-disabled={rest.disabled}>
|
|
28
|
+
<textarea {...rest} {id} {rows} bind:this={el} bind:value aria-invalid={!!error || undefined} style:resize={autosize ? 'none' : 'vertical'}></textarea>
|
|
29
|
+
</div>
|
|
30
|
+
{#if error || hint}<div class="tr-hint" data-error={!!error}>{error ?? hint}</div>{/if}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.tr-control { align-items: flex-start; padding: 6px 10px; }
|
|
35
|
+
textarea { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; background: none; color: var(--tr-on-surface); font: inherit; line-height: 1.4; }
|
|
36
|
+
textarea::placeholder { color: var(--tr-placeholder); }
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
|
2
|
+
type $$ComponentProps = HTMLTextareaAttributes & {
|
|
3
|
+
value?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
hint?: string;
|
|
6
|
+
error?: string;
|
|
7
|
+
autosize?: boolean;
|
|
8
|
+
};
|
|
9
|
+
declare const Textarea: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
10
|
+
type Textarea = ReturnType<typeof Textarea>;
|
|
11
|
+
export default Textarea;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, tick } from 'svelte';
|
|
3
|
+
import { pad } from '../date.js';
|
|
4
|
+
import { splitTime } from '../time.js';
|
|
5
|
+
|
|
6
|
+
/** Inline hour / minute (/ AM-PM) columns. Used inside popovers by the time pickers. */
|
|
7
|
+
let {
|
|
8
|
+
value = $bindable(),
|
|
9
|
+
hour12 = false,
|
|
10
|
+
step = 5,
|
|
11
|
+
label = 'Time'
|
|
12
|
+
}: {
|
|
13
|
+
/** 24h "HH:MM" */
|
|
14
|
+
value?: string;
|
|
15
|
+
hour12?: boolean;
|
|
16
|
+
step?: number;
|
|
17
|
+
label?: string;
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
let root: HTMLDivElement;
|
|
21
|
+
const cur = $derived(splitTime(value));
|
|
22
|
+
const hours = $derived(hour12 ? Array.from({ length: 12 }, (_, i) => i + 1) : Array.from({ length: 24 }, (_, i) => i));
|
|
23
|
+
const minutes = $derived(Array.from({ length: Math.ceil(60 / step) }, (_, i) => i * step));
|
|
24
|
+
const shownHour = $derived(hour12 ? cur.h % 12 || 12 : cur.h);
|
|
25
|
+
const pm = $derived(cur.h >= 12);
|
|
26
|
+
|
|
27
|
+
const set = (h24: number, m: number) => (value = `${pad(h24)}:${pad(m)}`);
|
|
28
|
+
const pickHour = (h: number) => set(hour12 ? (h % 12) + (pm ? 12 : 0) : h, cur.m);
|
|
29
|
+
const pickMinute = (m: number) => set(cur.h, m);
|
|
30
|
+
const pickPeriod = (isPm: boolean) => set((cur.h % 12) + (isPm ? 12 : 0), cur.m);
|
|
31
|
+
|
|
32
|
+
function colKey(e: KeyboardEvent) {
|
|
33
|
+
const btns = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('button')];
|
|
34
|
+
const i = btns.indexOf(document.activeElement as HTMLElement);
|
|
35
|
+
const to = e.key === 'ArrowDown' ? i + 1 : e.key === 'ArrowUp' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? btns.length - 1 : -1;
|
|
36
|
+
if (to < 0) return;
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
btns[Math.max(0, Math.min(btns.length - 1, to))].focus();
|
|
39
|
+
}
|
|
40
|
+
function reveal() {
|
|
41
|
+
root?.querySelectorAll<HTMLElement>('.col').forEach((col) => {
|
|
42
|
+
const sel = col.querySelector<HTMLElement>('[aria-selected="true"]');
|
|
43
|
+
if (sel) col.scrollTop = sel.offsetTop - col.clientHeight / 2 + sel.offsetHeight / 2;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
onMount(() => { tick().then(reveal); });
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<div class="cols" bind:this={root} role="group" aria-label={label}>
|
|
50
|
+
<div class="col" role="listbox" aria-label="{label} hours" tabindex="-1" onkeydown={colKey}>
|
|
51
|
+
{#each hours as h}
|
|
52
|
+
<button type="button" role="option" aria-selected={!!value && h === shownHour} onclick={() => pickHour(h)}>{pad(h)}</button>
|
|
53
|
+
{/each}
|
|
54
|
+
</div>
|
|
55
|
+
<div class="col" role="listbox" aria-label="{label} minutes" tabindex="-1" onkeydown={colKey}>
|
|
56
|
+
{#each minutes as m}
|
|
57
|
+
<button type="button" role="option" aria-selected={!!value && m === cur.m} onclick={() => pickMinute(m)}>{pad(m)}</button>
|
|
58
|
+
{/each}
|
|
59
|
+
</div>
|
|
60
|
+
{#if hour12}
|
|
61
|
+
<div class="col short" role="listbox" aria-label="{label} AM or PM" tabindex="-1" onkeydown={colKey}>
|
|
62
|
+
<button type="button" role="option" aria-selected={!!value && !pm} onclick={() => pickPeriod(false)}>AM</button>
|
|
63
|
+
<button type="button" role="option" aria-selected={!!value && pm} onclick={() => pickPeriod(true)}>PM</button>
|
|
64
|
+
</div>
|
|
65
|
+
{/if}
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<style>
|
|
69
|
+
.cols { display: flex; gap: 4px; }
|
|
70
|
+
.col { position: relative; display: flex; flex-direction: column; gap: 1px; width: 52px; height: 168px; overflow-y: auto; padding: 2px; border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-sm); }
|
|
71
|
+
.col.short { height: auto; align-self: flex-start; }
|
|
72
|
+
.col button { flex: none; height: 26px; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface); font: inherit; font-variant-numeric: tabular-nums; cursor: pointer; }
|
|
73
|
+
.col button:hover { background: var(--tr-surface-container-highest); }
|
|
74
|
+
.col button[aria-selected='true'] { background: var(--tr-primary); color: var(--tr-on-primary); font-weight: 700; }
|
|
75
|
+
.col button:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** 24h "HH:MM" */
|
|
3
|
+
value?: string;
|
|
4
|
+
hour12?: boolean;
|
|
5
|
+
step?: number;
|
|
6
|
+
label?: string;
|
|
7
|
+
};
|
|
8
|
+
declare const TimeList: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
9
|
+
type TimeList = ReturnType<typeof TimeList>;
|
|
10
|
+
export default TimeList;
|