@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,79 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Button from './Button.svelte';
|
|
3
|
+
import Popover from './Popover.svelte';
|
|
4
|
+
import TimeList from './TimeList.svelte';
|
|
5
|
+
import { pad } from '../date.js';
|
|
6
|
+
import { formatTime, parseTime } from '../time.js';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
value = $bindable(),
|
|
10
|
+
label,
|
|
11
|
+
ariaLabel,
|
|
12
|
+
hour12 = false,
|
|
13
|
+
step = 5,
|
|
14
|
+
disabled = false,
|
|
15
|
+
error,
|
|
16
|
+
placeholder
|
|
17
|
+
}: {
|
|
18
|
+
/** 24h "HH:MM" */
|
|
19
|
+
value?: string;
|
|
20
|
+
label?: string;
|
|
21
|
+
ariaLabel?: string;
|
|
22
|
+
hour12?: boolean;
|
|
23
|
+
/** Minute increment shown in the list (typed entry accepts any minute). */
|
|
24
|
+
step?: number;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
error?: string;
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
const id = $props.id();
|
|
31
|
+
let open = $state(false);
|
|
32
|
+
let text = $state('');
|
|
33
|
+
let bad = $state(false);
|
|
34
|
+
let input: HTMLInputElement;
|
|
35
|
+
|
|
36
|
+
$effect(() => { text = value ? formatTime(value, hour12) : ''; bad = false; });
|
|
37
|
+
|
|
38
|
+
function commit() {
|
|
39
|
+
const t = text.trim();
|
|
40
|
+
if (!t) { value = undefined; bad = false; return; }
|
|
41
|
+
const v = parseTime(t);
|
|
42
|
+
bad = !v;
|
|
43
|
+
if (v) { value = v; text = formatTime(v, hour12); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nowTime() { const d = new Date(); value = `${pad(d.getHours())}:${pad(d.getMinutes())}`; }
|
|
47
|
+
const invalid = $derived(!!error || bad);
|
|
48
|
+
const msg = $derived(error?.trim() || (bad ? 'Enter a time like 14:30 or 2:30 pm' : undefined));
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<div class="tr-field">
|
|
52
|
+
{#if label}<label class="tr-label" for={id}>{label}</label>{/if}
|
|
53
|
+
<div class="tr-anchor">
|
|
54
|
+
<div class="tr-control" data-trail="true" data-invalid={invalid} data-disabled={disabled}>
|
|
55
|
+
<input
|
|
56
|
+
class="tr-bare" {id} bind:this={input} bind:value={text} {disabled} autocomplete="off"
|
|
57
|
+
placeholder={placeholder ?? (hour12 ? 'h:mm AM' : 'HH:MM')} aria-haspopup="dialog" aria-expanded={open}
|
|
58
|
+
aria-label={label ? undefined : ariaLabel} aria-invalid={invalid || undefined} onblur={commit}
|
|
59
|
+
onkeydown={(e) => { if (e.key === 'Enter') commit(); else if (e.key === 'ArrowDown' && e.altKey) open = true; }}
|
|
60
|
+
/>
|
|
61
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="clock" aria-label="Choose time" {disabled} onclick={() => (open = !open)} />
|
|
62
|
+
</div>
|
|
63
|
+
<Popover bind:open onclose={(r) => r && input.focus()}>
|
|
64
|
+
<div class="panel">
|
|
65
|
+
<TimeList bind:value {hour12} {step} label={ariaLabel ?? label ?? 'Time'} />
|
|
66
|
+
<div class="foot">
|
|
67
|
+
<Button variant="minimal" size="sm" onclick={nowTime}>Now</Button>
|
|
68
|
+
<Button variant="tonal" size="sm" onclick={() => { open = false; input.focus(); }}>Done</Button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</Popover>
|
|
72
|
+
</div>
|
|
73
|
+
{#if msg}<div class="tr-hint" data-error="true">{msg}</div>{/if}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<style>
|
|
77
|
+
.panel { padding: 8px; }
|
|
78
|
+
.foot { display: flex; justify-content: space-between; margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--tr-outline-variant); }
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** 24h "HH:MM" */
|
|
3
|
+
value?: string;
|
|
4
|
+
label?: string;
|
|
5
|
+
ariaLabel?: string;
|
|
6
|
+
hour12?: boolean;
|
|
7
|
+
/** Minute increment shown in the list (typed entry accepts any minute). */
|
|
8
|
+
step?: number;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
error?: string;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
};
|
|
13
|
+
declare const TimePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
14
|
+
type TimePicker = ReturnType<typeof TimePicker>;
|
|
15
|
+
export default TimePicker;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import TimePicker from './TimePicker.svelte';
|
|
3
|
+
import Icon from '../icons/Icon.svelte';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
value = $bindable({}),
|
|
7
|
+
label,
|
|
8
|
+
hour12 = false,
|
|
9
|
+
step = 5,
|
|
10
|
+
disabled = false,
|
|
11
|
+
error,
|
|
12
|
+
allowOvernight = false
|
|
13
|
+
}: {
|
|
14
|
+
/** 24h "HH:MM" strings. */
|
|
15
|
+
value?: { start?: string; end?: string };
|
|
16
|
+
label?: string;
|
|
17
|
+
hour12?: boolean;
|
|
18
|
+
step?: number;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
error?: string;
|
|
21
|
+
/** Permit end earlier than start (spans midnight). */
|
|
22
|
+
allowOvernight?: boolean;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
const id = $props.id();
|
|
26
|
+
const overnight = $derived(!!value.start && !!value.end && value.end < value.start);
|
|
27
|
+
const msg = $derived(error ?? (overnight && !allowOvernight ? 'End time must be after start time' : undefined));
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="tr-field">
|
|
31
|
+
{#if label}<span class="tr-label" id="{id}-label">{label}</span>{/if}
|
|
32
|
+
<div class="pair" role="group" aria-labelledby={label ? `${id}-label` : undefined}>
|
|
33
|
+
<TimePicker ariaLabel="Start time" {hour12} {step} {disabled} error={msg ? ' ' : undefined} bind:value={() => value.start, (v) => (value = { ...value, start: v })} />
|
|
34
|
+
<Icon name="chevron-right" />
|
|
35
|
+
<TimePicker ariaLabel="End time" {hour12} {step} {disabled} error={msg ? ' ' : undefined} bind:value={() => value.end, (v) => (value = { ...value, end: v })} />
|
|
36
|
+
</div>
|
|
37
|
+
{#if msg}<div class="tr-hint" data-error="true">{msg}</div>
|
|
38
|
+
{:else if overnight}<div class="tr-hint">Ends the next day</div>{/if}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.pair { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: start; gap: 8px; }
|
|
43
|
+
.pair :global(svg) { margin-top: 8px; color: var(--tr-on-surface-variant); }
|
|
44
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
/** 24h "HH:MM" strings. */
|
|
3
|
+
value?: {
|
|
4
|
+
start?: string;
|
|
5
|
+
end?: string;
|
|
6
|
+
};
|
|
7
|
+
label?: string;
|
|
8
|
+
hour12?: boolean;
|
|
9
|
+
step?: number;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
/** Permit end earlier than start (spans midnight). */
|
|
13
|
+
allowOvernight?: boolean;
|
|
14
|
+
};
|
|
15
|
+
declare const TimeRangePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
16
|
+
type TimeRangePicker = ReturnType<typeof TimeRangePicker>;
|
|
17
|
+
export default TimeRangePicker;
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
type Item = { id: string; time: string; title: string; description?: string; kind?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger'; icon?: IconName };
|
|
7
|
+
let { items, body }: { items: Item[]; /** Custom body under each title. */ body?: Snippet<[item: Item]> } = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<ol class="tl" aria-label="Timeline">
|
|
11
|
+
{#each items as it (it.id)}
|
|
12
|
+
<li data-kind={it.kind ?? 'neutral'}>
|
|
13
|
+
<time class="when">{it.time}</time>
|
|
14
|
+
<span class="mk">{#if it.icon}<Icon name={it.icon} />{/if}</span>
|
|
15
|
+
<div class="what">
|
|
16
|
+
<div class="t">{it.title}</div>
|
|
17
|
+
{#if it.description}<div class="d">{it.description}</div>{/if}
|
|
18
|
+
{#if body}{@render body(it)}{/if}
|
|
19
|
+
</div>
|
|
20
|
+
</li>
|
|
21
|
+
{/each}
|
|
22
|
+
</ol>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.tl { margin: 0; padding: 0; list-style: none; }
|
|
26
|
+
li { --c: var(--tr-outline); position: relative; display: grid; grid-template-columns: 96px 20px minmax(0, 1fr); gap: 12px; padding-bottom: 18px; }
|
|
27
|
+
li[data-kind='primary'] { --c: var(--tr-primary); }
|
|
28
|
+
li[data-kind='success'] { --c: var(--tr-success); }
|
|
29
|
+
li[data-kind='warning'] { --c: var(--tr-warning); }
|
|
30
|
+
li[data-kind='danger'] { --c: var(--tr-danger); }
|
|
31
|
+
li:not(:last-child)::before { content: ''; position: absolute; left: calc(96px + 12px + 9px); top: 20px; bottom: 0; width: 2px; background: var(--tr-outline-variant); }
|
|
32
|
+
.when { text-align: right; padding-top: 1px; font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); font-variant-numeric: tabular-nums; }
|
|
33
|
+
.mk { z-index: 1; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--c); border: 3px solid var(--tr-surface); box-shadow: 0 0 0 1px var(--c); color: var(--tr-surface); }
|
|
34
|
+
.mk :global(svg) { width: 12px; height: 12px; stroke-width: 2; }
|
|
35
|
+
.t { font-weight: 600; }
|
|
36
|
+
.d { color: var(--tr-on-surface-variant); font-size: var(--tr-text-sm); margin-top: 2px; }
|
|
37
|
+
@media (max-width: 520px) { li { grid-template-columns: 20px minmax(0, 1fr); } .when { grid-column: 2; text-align: left; order: 3; } li:not(:last-child)::before { left: 9px; } }
|
|
38
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { IconName } from '../icons/paths.js';
|
|
3
|
+
type Item = {
|
|
4
|
+
id: string;
|
|
5
|
+
time: string;
|
|
6
|
+
title: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
kind?: 'neutral' | 'primary' | 'success' | 'warning' | 'danger';
|
|
9
|
+
icon?: IconName;
|
|
10
|
+
};
|
|
11
|
+
type $$ComponentProps = {
|
|
12
|
+
items: Item[]; /** Custom body under each title. */
|
|
13
|
+
body?: Snippet<[item: Item]>;
|
|
14
|
+
};
|
|
15
|
+
declare const Timeline: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
|
+
type Timeline = ReturnType<typeof Timeline>;
|
|
17
|
+
export default Timeline;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { fly } from 'svelte/transition';
|
|
3
|
+
import { toasts } from '../toast.svelte.js';
|
|
4
|
+
import Icon from '../icons/Icon.svelte';
|
|
5
|
+
import Button from './Button.svelte';
|
|
6
|
+
|
|
7
|
+
const glyph = { primary: 'info', success: 'success', warning: 'warning', danger: 'error' } as const;
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<div class="tr-toaster" role="region" aria-label="Notifications">
|
|
11
|
+
{#each toasts.items as t (t.id)}
|
|
12
|
+
<div class="toast" data-intent={t.intent} role={t.intent === 'danger' ? 'alert' : 'status'} transition:fly={{ y: 16, duration: 160 }}>
|
|
13
|
+
<span class="glyph"><Icon name={glyph[t.intent]} size={20} /></span>
|
|
14
|
+
<span class="msg">{t.message}</span>
|
|
15
|
+
<Button variant="minimal" intent="neutral" size="sm" icon="cross" aria-label="Dismiss" onclick={() => toasts.dismiss(t.id)} />
|
|
16
|
+
</div>
|
|
17
|
+
{/each}
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
.tr-toaster { position: fixed; z-index: 100; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none; width: max-content; max-width: calc(100vw - 32px); }
|
|
22
|
+
.toast {
|
|
23
|
+
--c: var(--tr-primary);
|
|
24
|
+
pointer-events: auto; display: flex; align-items: center; gap: 10px; min-height: 40px; padding: 4px 4px 4px 6px;
|
|
25
|
+
background: var(--tr-surface-container-highest); color: var(--tr-on-surface);
|
|
26
|
+
border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-pill);
|
|
27
|
+
box-shadow: var(--tr-elevation-3);
|
|
28
|
+
}
|
|
29
|
+
.toast[data-intent='success'] { --c: var(--tr-success); }
|
|
30
|
+
.toast[data-intent='warning'] { --c: var(--tr-warning); }
|
|
31
|
+
.toast[data-intent='danger'] { --c: var(--tr-danger); }
|
|
32
|
+
/* Bold intent indicator: solid intent disc inside the pill */
|
|
33
|
+
.glyph { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--c); color: var(--tr-surface); }
|
|
34
|
+
.msg { padding-right: 4px; font-weight: 500; }
|
|
35
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
|
|
2
|
+
new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
|
|
3
|
+
$$bindings?: Bindings;
|
|
4
|
+
} & Exports;
|
|
5
|
+
(internal: unknown, props: {
|
|
6
|
+
$$events?: Events;
|
|
7
|
+
$$slots?: Slots;
|
|
8
|
+
}): Exports & {
|
|
9
|
+
$set?: any;
|
|
10
|
+
$on?: any;
|
|
11
|
+
};
|
|
12
|
+
z_$$bindings?: Bindings;
|
|
13
|
+
}
|
|
14
|
+
declare const Toaster: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
|
|
15
|
+
[evt: string]: CustomEvent<any>;
|
|
16
|
+
}, {}, {}, string>;
|
|
17
|
+
type Toaster = InstanceType<typeof Toaster>;
|
|
18
|
+
export default Toaster;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
text,
|
|
6
|
+
placement = 'top',
|
|
7
|
+
delay = 350,
|
|
8
|
+
children
|
|
9
|
+
}: {
|
|
10
|
+
text: string;
|
|
11
|
+
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
12
|
+
/** ms before showing on hover (focus shows immediately). */
|
|
13
|
+
delay?: number;
|
|
14
|
+
children: Snippet;
|
|
15
|
+
} = $props();
|
|
16
|
+
|
|
17
|
+
const id = $props.id();
|
|
18
|
+
let open = $state(false);
|
|
19
|
+
let ready = $state(false);
|
|
20
|
+
let x = $state(0);
|
|
21
|
+
let y = $state(0);
|
|
22
|
+
let host: HTMLSpanElement;
|
|
23
|
+
let tip = $state<HTMLDivElement>();
|
|
24
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
25
|
+
|
|
26
|
+
const show = (now = false) => { clearTimeout(timer); timer = setTimeout(() => (open = true), now ? 0 : delay); };
|
|
27
|
+
const hide = () => { clearTimeout(timer); open = false; ready = false; };
|
|
28
|
+
$effect(() => () => clearTimeout(timer));
|
|
29
|
+
|
|
30
|
+
$effect(() => {
|
|
31
|
+
if (!open || !tip) return;
|
|
32
|
+
const r = host.getBoundingClientRect();
|
|
33
|
+
const t = tip.getBoundingClientRect();
|
|
34
|
+
let p = placement;
|
|
35
|
+
if (p === 'top' && r.top < t.height + 10) p = 'bottom';
|
|
36
|
+
else if (p === 'bottom' && r.bottom + t.height + 10 > innerHeight) p = 'top';
|
|
37
|
+
else if (p === 'left' && r.left < t.width + 10) p = 'right';
|
|
38
|
+
else if (p === 'right' && r.right + t.width + 10 > innerWidth) p = 'left';
|
|
39
|
+
const cx = r.left + r.width / 2 - t.width / 2, cy = r.top + r.height / 2 - t.height / 2;
|
|
40
|
+
const pos = { top: [cx, r.top - t.height - 6], bottom: [cx, r.bottom + 6], left: [r.left - t.width - 6, cy], right: [r.right + 6, cy] }[p];
|
|
41
|
+
x = Math.min(Math.max(4, pos[0]), innerWidth - t.width - 4);
|
|
42
|
+
y = Math.max(4, pos[1]);
|
|
43
|
+
ready = true;
|
|
44
|
+
});
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<svelte:window onkeydown={(e) => e.key === 'Escape' && open && hide()} />
|
|
48
|
+
|
|
49
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
50
|
+
<span class="host" bind:this={host} onpointerenter={() => show()} onpointerleave={hide} onfocusin={() => show(true)} onfocusout={hide} onpointerdown={hide} aria-describedby={open ? id : undefined}>
|
|
51
|
+
{@render children()}
|
|
52
|
+
</span>
|
|
53
|
+
{#if open}
|
|
54
|
+
<div class="tip" role="tooltip" {id} bind:this={tip} style:left="{x}px" style:top="{y}px" data-ready={ready}>{text}</div>
|
|
55
|
+
{/if}
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.host { display: inline-flex; }
|
|
59
|
+
.tip {
|
|
60
|
+
position: fixed; z-index: 200; max-width: 260px; padding: 4px 8px; pointer-events: none; visibility: hidden;
|
|
61
|
+
background: var(--tr-on-surface); color: var(--tr-surface); border-radius: var(--tr-radius-sm);
|
|
62
|
+
font: 500 var(--tr-text-sm) / 1.35 var(--tr-font-sans); box-shadow: var(--tr-elevation-2);
|
|
63
|
+
}
|
|
64
|
+
.tip[data-ready='true'] { visibility: visible; animation: fade var(--tr-dur-fast) var(--tr-ease); }
|
|
65
|
+
@keyframes fade { from { opacity: 0; } }
|
|
66
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
text: string;
|
|
4
|
+
placement?: 'top' | 'bottom' | 'left' | 'right';
|
|
5
|
+
/** ms before showing on hover (focus shows immediately). */
|
|
6
|
+
delay?: number;
|
|
7
|
+
children: Snippet;
|
|
8
|
+
};
|
|
9
|
+
declare const Tooltip: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type Tooltip = ReturnType<typeof Tooltip>;
|
|
11
|
+
export default Tooltip;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { SvelteMap } from 'svelte/reactivity';
|
|
4
|
+
import Icon from '../icons/Icon.svelte';
|
|
5
|
+
import Checkbox from './Checkbox.svelte';
|
|
6
|
+
import Spinner from './Spinner.svelte';
|
|
7
|
+
import type { TreeNode } from '../types.js';
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
nodes,
|
|
11
|
+
expanded = $bindable([]),
|
|
12
|
+
selected = $bindable(),
|
|
13
|
+
checkable = false,
|
|
14
|
+
checked = $bindable([]),
|
|
15
|
+
loadChildren,
|
|
16
|
+
label = 'Tree',
|
|
17
|
+
onselect
|
|
18
|
+
}: {
|
|
19
|
+
nodes: TreeNode[];
|
|
20
|
+
expanded?: string[];
|
|
21
|
+
selected?: string;
|
|
22
|
+
/** Tri-state checkboxes. `checked` holds leaf ids. */
|
|
23
|
+
checkable?: boolean;
|
|
24
|
+
checked?: string[];
|
|
25
|
+
/** Lazy children for nodes with `hasChildren`. */
|
|
26
|
+
loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
|
|
27
|
+
label?: string;
|
|
28
|
+
onselect?: (node: TreeNode) => void;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
const loaded = new SvelteMap<string, TreeNode[]>();
|
|
32
|
+
let loading = $state<string[]>([]);
|
|
33
|
+
let focusId = $state<string>();
|
|
34
|
+
let root: HTMLUListElement;
|
|
35
|
+
|
|
36
|
+
const kids = (n: TreeNode) => n.children ?? loaded.get(n.id);
|
|
37
|
+
const expandable = (n: TreeNode) => !!kids(n)?.length || (!!n.hasChildren && !kids(n));
|
|
38
|
+
|
|
39
|
+
type Row = { node: TreeNode; depth: number; parent?: TreeNode; pos: number; size: number };
|
|
40
|
+
const rows = $derived.by(() => {
|
|
41
|
+
const out: Row[] = [];
|
|
42
|
+
const walk = (list: TreeNode[], depth: number, parent?: TreeNode) =>
|
|
43
|
+
list.forEach((node, i) => {
|
|
44
|
+
out.push({ node, depth, parent, pos: i + 1, size: list.length });
|
|
45
|
+
if (expanded.includes(node.id) && kids(node)) walk(kids(node)!, depth + 1, node);
|
|
46
|
+
});
|
|
47
|
+
walk(nodes, 0);
|
|
48
|
+
return out;
|
|
49
|
+
});
|
|
50
|
+
const tabbable = $derived(rows.some((r) => r.node.id === focusId) ? focusId : rows[0]?.node.id);
|
|
51
|
+
|
|
52
|
+
async function expand(n: TreeNode) {
|
|
53
|
+
if (!expanded.includes(n.id)) expanded = [...expanded, n.id];
|
|
54
|
+
if (loadChildren && n.hasChildren && !kids(n) && !loading.includes(n.id)) {
|
|
55
|
+
loading = [...loading, n.id];
|
|
56
|
+
try { loaded.set(n.id, await loadChildren(n)); }
|
|
57
|
+
catch { expanded = expanded.filter((x) => x !== n.id); }
|
|
58
|
+
finally { loading = loading.filter((x) => x !== n.id); }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const collapse = (n: TreeNode) => (expanded = expanded.filter((x) => x !== n.id));
|
|
62
|
+
const toggle = (n: TreeNode) => (expanded.includes(n.id) ? collapse(n) : expand(n));
|
|
63
|
+
|
|
64
|
+
function leaves(n: TreeNode): string[] {
|
|
65
|
+
const k = kids(n);
|
|
66
|
+
return k?.length ? k.flatMap(leaves) : [n.id];
|
|
67
|
+
}
|
|
68
|
+
function checkState(n: TreeNode): 'all' | 'some' | 'none' {
|
|
69
|
+
const l = leaves(n);
|
|
70
|
+
const c = l.filter((id) => checked.includes(id)).length;
|
|
71
|
+
return c === 0 ? 'none' : c === l.length ? 'all' : 'some';
|
|
72
|
+
}
|
|
73
|
+
function toggleCheck(n: TreeNode) {
|
|
74
|
+
const l = leaves(n);
|
|
75
|
+
checked = checkState(n) === 'all' ? checked.filter((id) => !l.includes(id)) : [...new Set([...checked, ...l])];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function focusRow(id: string) {
|
|
79
|
+
focusId = id;
|
|
80
|
+
await tick();
|
|
81
|
+
root.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)?.focus();
|
|
82
|
+
}
|
|
83
|
+
function pick(n: TreeNode) {
|
|
84
|
+
if (n.disabled) return;
|
|
85
|
+
selected = n.id;
|
|
86
|
+
onselect?.(n);
|
|
87
|
+
}
|
|
88
|
+
function onKey(e: KeyboardEvent, i: number) {
|
|
89
|
+
const r = rows[i], n = r.node;
|
|
90
|
+
if (e.target !== e.currentTarget) return;
|
|
91
|
+
if (e.key === 'ArrowDown') rows[i + 1] && focusRow(rows[i + 1].node.id);
|
|
92
|
+
else if (e.key === 'ArrowUp') rows[i - 1] && focusRow(rows[i - 1].node.id);
|
|
93
|
+
else if (e.key === 'Home') focusRow(rows[0].node.id);
|
|
94
|
+
else if (e.key === 'End') focusRow(rows.at(-1)!.node.id);
|
|
95
|
+
else if (e.key === 'ArrowRight') {
|
|
96
|
+
if (expandable(n) && !expanded.includes(n.id)) expand(n);
|
|
97
|
+
else if (expanded.includes(n.id) && rows[i + 1]?.parent === n) focusRow(rows[i + 1].node.id);
|
|
98
|
+
} else if (e.key === 'ArrowLeft') {
|
|
99
|
+
if (expanded.includes(n.id)) collapse(n);
|
|
100
|
+
else if (r.parent) focusRow(r.parent.id);
|
|
101
|
+
} else if (e.key === 'Enter') pick(n);
|
|
102
|
+
else if (e.key === ' ') { checkable ? toggleCheck(n) : pick(n); }
|
|
103
|
+
else return;
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<ul class="tree" role="tree" aria-label={label} aria-multiselectable={checkable || undefined} bind:this={root}>
|
|
109
|
+
{#each rows as r, i (r.node.id)}
|
|
110
|
+
{@const n = r.node}
|
|
111
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
112
|
+
<li
|
|
113
|
+
role="treeitem" data-id={n.id} aria-level={r.depth + 1} aria-setsize={r.size} aria-posinset={r.pos}
|
|
114
|
+
aria-expanded={expandable(n) ? expanded.includes(n.id) : undefined} aria-selected={selected === n.id}
|
|
115
|
+
aria-disabled={n.disabled} tabindex={n.id === tabbable ? 0 : -1} data-selected={selected === n.id}
|
|
116
|
+
style:padding-inline-start="{r.depth * 18 + 4}px"
|
|
117
|
+
onkeydown={(e) => onKey(e, i)} onfocus={() => (focusId = n.id)} onclick={() => { focusId = n.id; pick(n); }} ondblclick={() => expandable(n) && toggle(n)}
|
|
118
|
+
>
|
|
119
|
+
{#if expandable(n)}
|
|
120
|
+
<button type="button" class="tw" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggle(n); }}>
|
|
121
|
+
{#if loading.includes(n.id)}<Spinner size={14} />{:else}<Icon name={expanded.includes(n.id) ? 'chevron-down' : 'chevron-right'} />{/if}
|
|
122
|
+
</button>
|
|
123
|
+
{:else}<span class="tw"></span>{/if}
|
|
124
|
+
{#if checkable}
|
|
125
|
+
<span class="ck" onclick={(e) => e.stopPropagation()} role="presentation">
|
|
126
|
+
<Checkbox tabindex={-1} aria-label="Select {n.label}" disabled={n.disabled} checked={checkState(n) === 'all'} indeterminate={checkState(n) === 'some'} onchange={() => toggleCheck(n)} />
|
|
127
|
+
</span>
|
|
128
|
+
{/if}
|
|
129
|
+
{#if n.icon}<Icon name={n.icon} />{:else if expandable(n) || kids(n)}<Icon name="folder" />{:else}<Icon name="file" />{/if}
|
|
130
|
+
<span class="lbl">{n.label}</span>
|
|
131
|
+
</li>
|
|
132
|
+
{/each}
|
|
133
|
+
</ul>
|
|
134
|
+
|
|
135
|
+
<style>
|
|
136
|
+
.tree { margin: 0; padding: 4px; list-style: none; }
|
|
137
|
+
li { display: flex; align-items: center; gap: 6px; height: 28px; padding-inline-end: 8px; border-radius: var(--tr-radius-sm); color: var(--tr-on-surface); cursor: default; outline: none; }
|
|
138
|
+
li:hover { background: var(--tr-surface-container-high); }
|
|
139
|
+
li[data-selected='true'] { background: color-mix(in srgb, var(--tr-primary-container) 60%, transparent); box-shadow: inset 3px 0 0 var(--tr-primary); }
|
|
140
|
+
li:focus-visible { box-shadow: inset 0 0 0 2px var(--tr-primary); }
|
|
141
|
+
li[aria-disabled='true'] { opacity: 0.45; }
|
|
142
|
+
.tw { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); cursor: pointer; }
|
|
143
|
+
button.tw:hover { background: var(--tr-surface-container-highest); }
|
|
144
|
+
.ck { display: inline-flex; }
|
|
145
|
+
li > :global(svg) { color: var(--tr-on-surface-variant); }
|
|
146
|
+
.lbl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
147
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TreeNode } from '../types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
nodes: TreeNode[];
|
|
4
|
+
expanded?: string[];
|
|
5
|
+
selected?: string;
|
|
6
|
+
/** Tri-state checkboxes. `checked` holds leaf ids. */
|
|
7
|
+
checkable?: boolean;
|
|
8
|
+
checked?: string[];
|
|
9
|
+
/** Lazy children for nodes with `hasChildren`. */
|
|
10
|
+
loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
|
|
11
|
+
label?: string;
|
|
12
|
+
onselect?: (node: TreeNode) => void;
|
|
13
|
+
};
|
|
14
|
+
declare const Tree: import("svelte").Component<$$ComponentProps, {}, "checked" | "expanded" | "selected">;
|
|
15
|
+
type Tree = ReturnType<typeof Tree>;
|
|
16
|
+
export default Tree;
|
package/dist/date.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Dependency-free local-date helpers. Dates travel as ISO strings (YYYY-MM-DD) so they sort lexicographically. */
|
|
2
|
+
export declare const pad: (n: number) => string;
|
|
3
|
+
export declare const toISO: (d: Date) => string;
|
|
4
|
+
export declare function parseISO(s: string): Date | null;
|
|
5
|
+
export declare const today: () => string;
|
|
6
|
+
export declare const addDays: (d: Date, n: number) => Date;
|
|
7
|
+
export declare const startOfMonth: (d: Date) => Date;
|
|
8
|
+
/** Month arithmetic that clamps the day (Jan 31 + 1 month = Feb 28/29). */
|
|
9
|
+
export declare function addMonths(d: Date, n: number): Date;
|
|
10
|
+
/** Six weeks of ISO dates covering the month, starting on `weekStart` (0 = Sunday). */
|
|
11
|
+
export declare function monthGrid(year: number, month: number, weekStart?: number): string[][];
|
|
12
|
+
/** Accepts YYYY-MM-DD, YYYY/MM/DD and M/D/YYYY. Returns ISO or null. */
|
|
13
|
+
export declare function parseDateInput(text: string): string | null;
|
|
14
|
+
/** "YYYY-MM-DDTHH:MM" helpers (local, no timezone). */
|
|
15
|
+
export declare const joinDateTime: (date: string, time: string) => string;
|
|
16
|
+
export declare const splitDateTime: (v?: string) => {
|
|
17
|
+
date: string | undefined;
|
|
18
|
+
time: string | undefined;
|
|
19
|
+
};
|
package/dist/date.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Dependency-free local-date helpers. Dates travel as ISO strings (YYYY-MM-DD) so they sort lexicographically. */
|
|
2
|
+
export const pad = (n) => String(n).padStart(2, '0');
|
|
3
|
+
export const toISO = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
4
|
+
export function parseISO(s) {
|
|
5
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
6
|
+
if (!m)
|
|
7
|
+
return null;
|
|
8
|
+
const d = new Date(+m[1], +m[2] - 1, +m[3]);
|
|
9
|
+
return toISO(d) === s ? d : null;
|
|
10
|
+
}
|
|
11
|
+
export const today = () => toISO(new Date());
|
|
12
|
+
export const addDays = (d, n) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
13
|
+
export const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1);
|
|
14
|
+
/** Month arithmetic that clamps the day (Jan 31 + 1 month = Feb 28/29). */
|
|
15
|
+
export function addMonths(d, n) {
|
|
16
|
+
const t = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
17
|
+
const last = new Date(t.getFullYear(), t.getMonth() + 1, 0).getDate();
|
|
18
|
+
t.setDate(Math.min(d.getDate(), last));
|
|
19
|
+
return t;
|
|
20
|
+
}
|
|
21
|
+
/** Six weeks of ISO dates covering the month, starting on `weekStart` (0 = Sunday). */
|
|
22
|
+
export function monthGrid(year, month, weekStart = 0) {
|
|
23
|
+
const first = new Date(year, month, 1);
|
|
24
|
+
const start = addDays(first, -((first.getDay() - weekStart + 7) % 7));
|
|
25
|
+
return Array.from({ length: 6 }, (_, w) => Array.from({ length: 7 }, (_, d) => toISO(addDays(start, w * 7 + d))));
|
|
26
|
+
}
|
|
27
|
+
/** Accepts YYYY-MM-DD, YYYY/MM/DD and M/D/YYYY. Returns ISO or null. */
|
|
28
|
+
export function parseDateInput(text) {
|
|
29
|
+
const t = text.trim();
|
|
30
|
+
let m = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(t);
|
|
31
|
+
let y, mo, d;
|
|
32
|
+
if (m)
|
|
33
|
+
[y, mo, d] = [+m[1], +m[2], +m[3]];
|
|
34
|
+
else if ((m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(t)))
|
|
35
|
+
[y, mo, d] = [+m[3], +m[1], +m[2]];
|
|
36
|
+
else
|
|
37
|
+
return null;
|
|
38
|
+
const iso = `${String(y).padStart(4, '0')}-${pad(mo)}-${pad(d)}`;
|
|
39
|
+
return parseISO(iso) ? iso : null;
|
|
40
|
+
}
|
|
41
|
+
/** "YYYY-MM-DDTHH:MM" helpers (local, no timezone). */
|
|
42
|
+
export const joinDateTime = (date, time) => `${date}T${time}`;
|
|
43
|
+
export const splitDateTime = (v) => ({ date: v?.slice(0, 10), time: v && v.length >= 16 ? v.slice(11, 16) : undefined });
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type DurationUnit = 'd' | 'h' | 'm' | 's';
|
|
2
|
+
export declare const UNIT_SECONDS: Record<DurationUnit, number>;
|
|
3
|
+
export declare const UNIT_NAMES: Record<DurationUnit, string>;
|
|
4
|
+
/**
|
|
5
|
+
* Parses "1h 30m", "2d 3h", "90m", "1:30" (h:mm), "1:30:15" (h:mm:ss), ISO "PT1H30M".
|
|
6
|
+
* A bare number counts in the smallest enabled unit. Returns seconds or null.
|
|
7
|
+
*/
|
|
8
|
+
export declare function parseDuration(text: string, units?: DurationUnit[]): number | null;
|
|
9
|
+
/** Splits seconds across the enabled units (largest first); remainder below the smallest unit is dropped. */
|
|
10
|
+
export declare function decompose(seconds: number, units: DurationUnit[]): Record<DurationUnit, number>;
|
|
11
|
+
export declare function formatDuration(seconds: number, units?: DurationUnit[]): string;
|
package/dist/duration.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const UNIT_SECONDS = { d: 86400, h: 3600, m: 60, s: 1 };
|
|
2
|
+
export const UNIT_NAMES = { d: 'days', h: 'hours', m: 'minutes', s: 'seconds' };
|
|
3
|
+
const ORDER = ['d', 'h', 'm', 's'];
|
|
4
|
+
const sorted = (units) => ORDER.filter((u) => units.includes(u));
|
|
5
|
+
/**
|
|
6
|
+
* Parses "1h 30m", "2d 3h", "90m", "1:30" (h:mm), "1:30:15" (h:mm:ss), ISO "PT1H30M".
|
|
7
|
+
* A bare number counts in the smallest enabled unit. Returns seconds or null.
|
|
8
|
+
*/
|
|
9
|
+
export function parseDuration(text, units = ['d', 'h', 'm']) {
|
|
10
|
+
const t = text.trim().toLowerCase();
|
|
11
|
+
if (!t)
|
|
12
|
+
return null;
|
|
13
|
+
const iso = /^p(?:(\d+)d)?(?:t(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?)?$/.exec(t);
|
|
14
|
+
if (iso && t.length > 1)
|
|
15
|
+
return (+(iso[1] ?? 0)) * 86400 + (+(iso[2] ?? 0)) * 3600 + (+(iso[3] ?? 0)) * 60 + Math.round(+(iso[4] ?? 0));
|
|
16
|
+
if (/^\d+(:\d{1,2}){1,2}$/.test(t)) {
|
|
17
|
+
const p = t.split(':').map(Number);
|
|
18
|
+
return p.length === 2 ? p[0] * 3600 + p[1] * 60 : p[0] * 3600 + p[1] * 60 + p[2];
|
|
19
|
+
}
|
|
20
|
+
if (/^\d+(\.\d+)?$/.test(t))
|
|
21
|
+
return Math.round(+t * UNIT_SECONDS[sorted(units).at(-1) ?? 'm']);
|
|
22
|
+
if (!/^(?:\s*\d+(?:\.\d+)?\s*[dhms]\s*,?)+\s*$/.test(t))
|
|
23
|
+
return null;
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (const m of t.matchAll(/(\d+(?:\.\d+)?)\s*([dhms])/g))
|
|
26
|
+
total += +m[1] * UNIT_SECONDS[m[2]];
|
|
27
|
+
return Math.round(total);
|
|
28
|
+
}
|
|
29
|
+
/** Splits seconds across the enabled units (largest first); remainder below the smallest unit is dropped. */
|
|
30
|
+
export function decompose(seconds, units) {
|
|
31
|
+
const out = { d: 0, h: 0, m: 0, s: 0 };
|
|
32
|
+
let rest = Math.max(0, Math.round(seconds));
|
|
33
|
+
for (const u of sorted(units)) {
|
|
34
|
+
out[u] = Math.floor(rest / UNIT_SECONDS[u]);
|
|
35
|
+
rest -= out[u] * UNIT_SECONDS[u];
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
export function formatDuration(seconds, units = ['d', 'h', 'm']) {
|
|
40
|
+
const us = sorted(units);
|
|
41
|
+
const parts = decompose(seconds, us);
|
|
42
|
+
const s = us.filter((u) => parts[u]).map((u) => `${parts[u]}${u}`).join(' ');
|
|
43
|
+
return s || `0${us.at(-1) ?? 'm'}`;
|
|
44
|
+
}
|