@hearthlog/ui 0.0.0-stage → 0.1.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 +235 -0
- package/package.json +37 -3
- package/src/assets/fonts/fira-code-latin-ext-ui.woff2 +0 -0
- package/src/assets/fonts/fira-code-latin-ui.woff2 +0 -0
- package/src/assets/fonts/inter-latin-ext-ui.woff2 +0 -0
- package/src/assets/fonts/inter-latin-ui.woff2 +0 -0
- package/src/components/ConfirmDialog.svelte +104 -0
- package/src/components/Drawer.svelte +135 -0
- package/src/components/EmptyState.svelte +33 -0
- package/src/components/JsonViewer.svelte +113 -0
- package/src/components/Modal.svelte +76 -0
- package/src/components/NotificationCenter.svelte +106 -0
- package/src/components/PageHeader.svelte +26 -0
- package/src/components/commands/CommandConfirmDialog.svelte +32 -0
- package/src/components/commands/CommandDrawer.svelte +31 -0
- package/src/components/commands/CommandFieldset.svelte +18 -0
- package/src/components/commands/CommandModal.svelte +25 -0
- package/src/components/commands/CommandOnly.svelte +26 -0
- package/src/components/data/DataPlaceholder.svelte +8 -0
- package/src/components/data/DataRegion.svelte +8 -0
- package/src/components/data/DataValue.svelte +11 -0
- package/src/components/data-table/DataTable.svelte +139 -0
- package/src/components/data-table/DataTableHeader.svelte +78 -0
- package/src/components/data-table/DataTablePagination.svelte +91 -0
- package/src/components/data-table/table.ts +30 -0
- package/src/components/data-table/types.ts +63 -0
- package/src/components/endless/DayPager.svelte +355 -0
- package/src/components/endless/EndlessList.svelte +498 -0
- package/src/components/endless/bucket-store.svelte.ts +81 -0
- package/src/components/json-viewer.ts +49 -0
- package/src/components/ui/Amount.svelte +17 -0
- package/src/components/ui/Badge.svelte +40 -0
- package/src/components/ui/Button.svelte +53 -0
- package/src/components/ui/Checkbox.svelte +29 -0
- package/src/components/ui/ChoiceChips.svelte +53 -0
- package/src/components/ui/CollectionToolbar.svelte +29 -0
- package/src/components/ui/DatePicker.svelte +375 -0
- package/src/components/ui/DateTimePicker.svelte +16 -0
- package/src/components/ui/Field.svelte +52 -0
- package/src/components/ui/FoldLine.svelte +38 -0
- package/src/components/ui/Input.svelte +21 -0
- package/src/components/ui/Label.svelte +18 -0
- package/src/components/ui/Metric.svelte +34 -0
- package/src/components/ui/MonthNavigator.svelte +194 -0
- package/src/components/ui/Panel.svelte +37 -0
- package/src/components/ui/RouteTabs.svelte +57 -0
- package/src/components/ui/Select.svelte +93 -0
- package/src/components/ui/Switch.svelte +41 -0
- package/src/components/ui/Textarea.svelte +19 -0
- package/src/components/ui/Tooltip.svelte +53 -0
- package/src/components/ui/tab-indicator.ts +84 -0
- package/src/state/commands.ts +22 -0
- package/src/state/notifications.svelte.ts +49 -0
- package/src/state/page-context.ts +9 -0
- package/src/state/readiness.ts +20 -0
- package/src/state/sheet-stack.svelte.ts +49 -0
- package/src/styles/base.css +243 -0
- package/src/styles/index.css +13 -0
- package/src/styles/recipes.css +174 -0
- package/src/utils/cn.ts +6 -0
- package/src/utils/endless-axis.ts +153 -0
- package/src/utils/number-format.ts +82 -0
- package/README.md +0 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Button as BitsButton } from "bits-ui";
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
4
|
+
import type { Snippet } from "svelte";
|
|
5
|
+
import type { HTMLButtonAttributes } from "svelte/elements";
|
|
6
|
+
import { cn } from "../../utils/cn.js";
|
|
7
|
+
|
|
8
|
+
const buttonVariants = cva(
|
|
9
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-semibold transition-[background-color,color,box-shadow,transform,filter] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] duration-100 active:translate-y-px motion-reduce:transition-none motion-reduce:active:translate-y-0 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none",
|
|
10
|
+
{
|
|
11
|
+
variants: {
|
|
12
|
+
variant: {
|
|
13
|
+
default: "bg-[var(--stor-action)] text-[var(--stor-action-text)] hover:bg-[var(--stor-action-hover)] active:brightness-95",
|
|
14
|
+
secondary: "bg-[var(--stor-surface-raised)] shadow-[var(--stor-elevation-2)] text-[var(--stor-ink)] hover:shadow-[var(--stor-elevation-3)] active:brightness-95",
|
|
15
|
+
ghost: "text-[var(--stor-muted-text)] hover:bg-[var(--stor-accent-soft)] hover:text-[var(--stor-accent-strong)]",
|
|
16
|
+
destructive: "bg-[var(--stor-negative)] text-[var(--stor-negative-text)] hover:brightness-90 active:brightness-95",
|
|
17
|
+
},
|
|
18
|
+
size: {
|
|
19
|
+
sm: "h-11 px-3 sm:h-9",
|
|
20
|
+
md: "h-11 px-4 sm:h-9",
|
|
21
|
+
icon: "h-11 w-11 p-0 sm:h-9 sm:w-9",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
size: "md",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
type ButtonProps = HTMLButtonAttributes &
|
|
32
|
+
VariantProps<typeof buttonVariants> & {
|
|
33
|
+
children?: Snippet;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
class: className = "",
|
|
38
|
+
variant = "default",
|
|
39
|
+
size = "md",
|
|
40
|
+
type = "button",
|
|
41
|
+
children,
|
|
42
|
+
...restProps
|
|
43
|
+
}: ButtonProps = $props();
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<BitsButton.Root
|
|
47
|
+
{type}
|
|
48
|
+
data-stor-button-variant={variant}
|
|
49
|
+
class={cn(buttonVariants({ variant, size }), className)}
|
|
50
|
+
{...restProps}
|
|
51
|
+
>
|
|
52
|
+
{@render children?.()}
|
|
53
|
+
</BitsButton.Root>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Checkbox } from "bits-ui";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import { cn } from "../../utils/cn.js";
|
|
5
|
+
import IconCheck from "~icons/mdi/check";
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
checked = false,
|
|
9
|
+
onCheckedChange = () => {},
|
|
10
|
+
disabled = false,
|
|
11
|
+
class: className = "",
|
|
12
|
+
children,
|
|
13
|
+
...restProps
|
|
14
|
+
}: Checkbox.RootProps & { children?: Snippet } = $props();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<Checkbox.Root
|
|
18
|
+
{checked}
|
|
19
|
+
{onCheckedChange}
|
|
20
|
+
{disabled}
|
|
21
|
+
class={cn(
|
|
22
|
+
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-[var(--stor-field-border)] stor-well text-[var(--stor-action-text)] transition data-[state=checked]:border-[var(--stor-accent)] data-[state=checked]:bg-[var(--stor-action)] data-[state=checked]:shadow-[0_1px_2px_rgb(0_0_0/.18),inset_0_1px_0_rgb(255_255_255/.22)] disabled:cursor-not-allowed disabled:opacity-50 [&[data-state=checked]_.checkbox-check]:opacity-100",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...restProps}
|
|
26
|
+
>
|
|
27
|
+
<IconCheck class="checkbox-check h-3.5 w-3.5 opacity-0" />
|
|
28
|
+
{@render children?.()}
|
|
29
|
+
</Checkbox.Root>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends string | number">
|
|
2
|
+
import { cn } from "../../utils/cn.js";
|
|
3
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A short list of choices as chips; the chosen one is tinted. `more` adds a
|
|
7
|
+
* last chip (All, Other, Pick…) that the form answers, e.g. by opening the
|
|
8
|
+
* full list or a field.
|
|
9
|
+
*/
|
|
10
|
+
let {
|
|
11
|
+
options,
|
|
12
|
+
value,
|
|
13
|
+
label,
|
|
14
|
+
onChange,
|
|
15
|
+
more,
|
|
16
|
+
onMore,
|
|
17
|
+
moreActive = false,
|
|
18
|
+
class: className = "",
|
|
19
|
+
}: {
|
|
20
|
+
options: readonly { value: T; label: string }[];
|
|
21
|
+
value: T | null | undefined;
|
|
22
|
+
/** Names the group for assistive tech. */
|
|
23
|
+
label: string;
|
|
24
|
+
onChange: (value: T) => void;
|
|
25
|
+
more?: string;
|
|
26
|
+
onMore?: () => void;
|
|
27
|
+
moreActive?: boolean;
|
|
28
|
+
class?: string;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
const chip = (on: boolean) => `inline-flex h-9 items-center gap-1 rounded-full px-3.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] ${on ? "bg-[var(--stor-accent-soft)] font-semibold text-[var(--stor-accent-strong)]" : "bg-[var(--stor-surface-muted)] font-medium text-[var(--stor-ink)]"}`;
|
|
32
|
+
|
|
33
|
+
// Arrow keys move between chips so a row is one stop for Tab, like a radio group.
|
|
34
|
+
function moveFocus(event: KeyboardEvent): void {
|
|
35
|
+
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
|
36
|
+
if (!step) return;
|
|
37
|
+
const chips = [...(event.currentTarget as HTMLElement).querySelectorAll<HTMLButtonElement>("button")];
|
|
38
|
+
const next = chips[chips.indexOf(document.activeElement as HTMLButtonElement) + step];
|
|
39
|
+
if (!next) return;
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
next.focus();
|
|
42
|
+
}
|
|
43
|
+
</script>
|
|
44
|
+
|
|
45
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
46
|
+
<div class={cn("flex flex-wrap gap-2", className)} role="group" aria-label={label} onkeydown={moveFocus}>
|
|
47
|
+
{#each options as option (option.value)}
|
|
48
|
+
<button type="button" class={chip(value === option.value)} aria-pressed={value === option.value} onclick={() => onChange(option.value)}>{option.label}</button>
|
|
49
|
+
{/each}
|
|
50
|
+
{#if more && onMore}
|
|
51
|
+
<button type="button" class={chip(moreActive)} aria-expanded={moreActive} onclick={onMore}>{more}<IconChevronDown class="h-4 w-4" /></button>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
import Button from './Button.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
count,
|
|
8
|
+
noun,
|
|
9
|
+
clearVisible = false,
|
|
10
|
+
onClear = () => {},
|
|
11
|
+
children,
|
|
12
|
+
class: className = '',
|
|
13
|
+
}: {
|
|
14
|
+
count: number | null;
|
|
15
|
+
noun: string;
|
|
16
|
+
clearVisible?: boolean;
|
|
17
|
+
onClear?: () => void;
|
|
18
|
+
children: Snippet;
|
|
19
|
+
class?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<section class={cn('stor-panel p-[var(--stor-panel-padding)]', className)} aria-label={`${noun} filters`}>
|
|
24
|
+
<div class="flex min-w-0 flex-wrap items-center gap-2 [&>*]:max-w-full">{@render children()}</div>
|
|
25
|
+
<div class="mt-2 flex min-h-9 flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-[var(--stor-muted-text)]">
|
|
26
|
+
<span role="status">{count === null ? 'Loading results…' : `Showing ${count} ${noun}${count === 1 ? '' : 's'}`}</span>
|
|
27
|
+
{#if clearVisible}<Button variant="ghost" size="sm" onclick={onClear}>Clear filters</Button>{/if}
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
DatePicker as BitsDatePicker,
|
|
4
|
+
TimeField as BitsTimeField,
|
|
5
|
+
} from "bits-ui";
|
|
6
|
+
import {
|
|
7
|
+
CalendarDateTime,
|
|
8
|
+
Time,
|
|
9
|
+
parseDate,
|
|
10
|
+
parseDateTime,
|
|
11
|
+
today,
|
|
12
|
+
getLocalTimeZone,
|
|
13
|
+
type DateValue,
|
|
14
|
+
} from "@internationalized/date";
|
|
15
|
+
import { cn } from "../../utils/cn.js";
|
|
16
|
+
import IconCalendar from "~icons/mdi/calendar-month-outline";
|
|
17
|
+
import IconChevronLeft from "~icons/mdi/chevron-left";
|
|
18
|
+
import IconChevronRight from "~icons/mdi/chevron-right";
|
|
19
|
+
import IconClose from "~icons/mdi/close";
|
|
20
|
+
import IconMinus from "~icons/mdi/minus";
|
|
21
|
+
import IconPlus from "~icons/mdi/plus";
|
|
22
|
+
|
|
23
|
+
type PickerGranularity = "day" | "minute";
|
|
24
|
+
|
|
25
|
+
let {
|
|
26
|
+
value = $bindable(""),
|
|
27
|
+
id = "",
|
|
28
|
+
name = "",
|
|
29
|
+
placeholder = "",
|
|
30
|
+
disabled = false,
|
|
31
|
+
clearable = true,
|
|
32
|
+
granularity = "day",
|
|
33
|
+
locale = "en-GB",
|
|
34
|
+
class: className = "",
|
|
35
|
+
triggerClass = "",
|
|
36
|
+
contentClass = "",
|
|
37
|
+
onValueChange = () => {},
|
|
38
|
+
}: {
|
|
39
|
+
value?: string;
|
|
40
|
+
id?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
clearable?: boolean;
|
|
45
|
+
granularity?: PickerGranularity;
|
|
46
|
+
locale?: string;
|
|
47
|
+
class?: string;
|
|
48
|
+
triggerClass?: string;
|
|
49
|
+
contentClass?: string;
|
|
50
|
+
onValueChange?: (value: string) => void;
|
|
51
|
+
} = $props();
|
|
52
|
+
|
|
53
|
+
let pickerValue = $derived(parsePickerValue(value, granularity));
|
|
54
|
+
let placeholderValue = $derived(
|
|
55
|
+
pickerValue ?? createPlaceholderValue(granularity),
|
|
56
|
+
);
|
|
57
|
+
let timeValue = $derived(getTimeValue(pickerValue));
|
|
58
|
+
let hasValue = $derived(value.trim().length > 0);
|
|
59
|
+
const timePlaceholder = new Time(0, 0, 0);
|
|
60
|
+
|
|
61
|
+
function parsePickerValue(
|
|
62
|
+
rawValue: string,
|
|
63
|
+
pickerGranularity: PickerGranularity,
|
|
64
|
+
): DateValue | undefined {
|
|
65
|
+
const normalized = rawValue.trim();
|
|
66
|
+
if (!normalized) return undefined;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
if (pickerGranularity === "minute") {
|
|
70
|
+
const dateTime = normalized.includes("T")
|
|
71
|
+
? normalized.slice(0, 16)
|
|
72
|
+
: normalized.replace(" ", "T").slice(0, 16);
|
|
73
|
+
return parseDateTime(dateTime);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return parseDate(normalized.slice(0, 10));
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pad(value: number) {
|
|
83
|
+
return String(value).padStart(2, "0");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function createPlaceholderValue(pickerGranularity: PickerGranularity) {
|
|
87
|
+
const currentDate = today(getLocalTimeZone());
|
|
88
|
+
if (pickerGranularity === "minute") {
|
|
89
|
+
return new CalendarDateTime(
|
|
90
|
+
currentDate.year,
|
|
91
|
+
currentDate.month,
|
|
92
|
+
currentDate.day,
|
|
93
|
+
0,
|
|
94
|
+
0,
|
|
95
|
+
0,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return currentDate;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toInputValue(nextValue: DateValue | undefined) {
|
|
102
|
+
if (!nextValue) return "";
|
|
103
|
+
if (granularity === "minute" && nextValue instanceof CalendarDateTime) {
|
|
104
|
+
return `${nextValue.year}-${pad(nextValue.month)}-${pad(nextValue.day)}T${pad(nextValue.hour)}:${pad(nextValue.minute)}`;
|
|
105
|
+
}
|
|
106
|
+
return nextValue.toString().slice(0, 10);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getTimeValue(nextValue: DateValue | undefined) {
|
|
110
|
+
if (nextValue instanceof CalendarDateTime) {
|
|
111
|
+
return new Time(nextValue.hour, nextValue.minute, nextValue.second);
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getDateTimeBase() {
|
|
117
|
+
const parsedValue = parsePickerValue(value, "minute");
|
|
118
|
+
if (parsedValue instanceof CalendarDateTime) return parsedValue;
|
|
119
|
+
return createPlaceholderValue("minute") as CalendarDateTime;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function setTime(nextTime: Time | undefined) {
|
|
123
|
+
if (!nextTime) return;
|
|
124
|
+
const base = getDateTimeBase();
|
|
125
|
+
handleValueChange(
|
|
126
|
+
new CalendarDateTime(
|
|
127
|
+
base.year,
|
|
128
|
+
base.month,
|
|
129
|
+
base.day,
|
|
130
|
+
nextTime.hour,
|
|
131
|
+
nextTime.minute,
|
|
132
|
+
nextTime.second,
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function adjustTime(minutes: number) {
|
|
138
|
+
const currentTime = timeValue ?? timePlaceholder;
|
|
139
|
+
const currentMinutes = currentTime.hour * 60 + currentTime.minute;
|
|
140
|
+
const nextMinutes = (currentMinutes + minutes + 1440) % 1440;
|
|
141
|
+
setTime(
|
|
142
|
+
new Time(
|
|
143
|
+
Math.floor(nextMinutes / 60),
|
|
144
|
+
nextMinutes % 60,
|
|
145
|
+
currentTime.second,
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function handleValueChange(nextValue: DateValue | undefined) {
|
|
151
|
+
const nextInputValue = toInputValue(nextValue);
|
|
152
|
+
value = nextInputValue;
|
|
153
|
+
onValueChange(nextInputValue);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function clearValue() {
|
|
157
|
+
value = "";
|
|
158
|
+
onValueChange("");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function segmentClass(part: string) {
|
|
162
|
+
return cn(
|
|
163
|
+
"rounded px-0.5 tabular-nums outline-none",
|
|
164
|
+
part === "literal"
|
|
165
|
+
? "text-[var(--stor-muted-text)]"
|
|
166
|
+
: "text-stone-950 focus:bg-stone-200",
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<BitsDatePicker.Root
|
|
172
|
+
value={pickerValue}
|
|
173
|
+
placeholder={placeholderValue}
|
|
174
|
+
onValueChange={handleValueChange}
|
|
175
|
+
{disabled}
|
|
176
|
+
{granularity}
|
|
177
|
+
hourCycle={24}
|
|
178
|
+
hideTimeZone
|
|
179
|
+
closeOnDateSelect={granularity !== "minute"}
|
|
180
|
+
{locale}
|
|
181
|
+
weekStartsOn={1}
|
|
182
|
+
fixedWeeks
|
|
183
|
+
calendarLabel={placeholder || "Choose date"}
|
|
184
|
+
>
|
|
185
|
+
<div
|
|
186
|
+
class={cn(
|
|
187
|
+
"flex min-w-0 w-full items-center rounded-xl border border-[var(--stor-field-border)] stor-well focus-within:bg-[var(--stor-surface)] text-base sm:text-sm text-stone-950 transition focus-within:border-[var(--stor-focus)] focus-within:ring-2 focus-within:ring-[var(--stor-selected)]",
|
|
188
|
+
disabled && "cursor-not-allowed bg-[var(--stor-surface-muted)] text-[var(--stor-muted-text)]",
|
|
189
|
+
className,
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<BitsDatePicker.Input
|
|
193
|
+
id={id || undefined}
|
|
194
|
+
{name}
|
|
195
|
+
data-date-value={value}
|
|
196
|
+
class={cn(
|
|
197
|
+
"flex h-11 sm:h-9 min-w-0 flex-1 items-center gap-0.5 px-3 py-2.5 sm:py-1.5 outline-none",
|
|
198
|
+
granularity === "minute"
|
|
199
|
+
? "flex-wrap gap-y-0.5"
|
|
200
|
+
: "overflow-hidden whitespace-nowrap",
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
{#snippet children({ segments })}
|
|
204
|
+
{#each segments as segment}
|
|
205
|
+
<BitsDatePicker.Segment
|
|
206
|
+
part={segment.part}
|
|
207
|
+
class={segmentClass(segment.part)}
|
|
208
|
+
>
|
|
209
|
+
{segment.value}
|
|
210
|
+
</BitsDatePicker.Segment>
|
|
211
|
+
{/each}
|
|
212
|
+
{/snippet}
|
|
213
|
+
</BitsDatePicker.Input>
|
|
214
|
+
|
|
215
|
+
{#if hasValue && !disabled && clearable}
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class="mr-1 flex h-10 w-10 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-700"
|
|
219
|
+
aria-label={granularity === "minute"
|
|
220
|
+
? "Clear date and time"
|
|
221
|
+
: "Clear date"}
|
|
222
|
+
onclick={clearValue}
|
|
223
|
+
>
|
|
224
|
+
<IconClose class="h-4 w-4" />
|
|
225
|
+
</button>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
<BitsDatePicker.Trigger
|
|
229
|
+
class={cn(
|
|
230
|
+
"mr-1 flex h-10 w-10 sm:h-8 sm:w-8 shrink-0 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 disabled:cursor-not-allowed disabled:opacity-50",
|
|
231
|
+
triggerClass,
|
|
232
|
+
)}
|
|
233
|
+
aria-label={granularity === "minute"
|
|
234
|
+
? "Open date and time picker"
|
|
235
|
+
: "Open date picker"}
|
|
236
|
+
>
|
|
237
|
+
<IconCalendar class="h-4 w-4" />
|
|
238
|
+
</BitsDatePicker.Trigger>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<BitsDatePicker.Portal>
|
|
242
|
+
<BitsDatePicker.Content
|
|
243
|
+
sideOffset={6}
|
|
244
|
+
collisionPadding={8}
|
|
245
|
+
class={cn(
|
|
246
|
+
"stor-menu z-[var(--stor-layer-dialog)] p-3 outline-none",
|
|
247
|
+
contentClass,
|
|
248
|
+
)}
|
|
249
|
+
>
|
|
250
|
+
<BitsDatePicker.Calendar>
|
|
251
|
+
{#snippet children({ months, weekdays })}
|
|
252
|
+
<BitsDatePicker.Header class="mb-2 flex items-center justify-between gap-2">
|
|
253
|
+
<BitsDatePicker.PrevButton class="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 disabled:opacity-40">
|
|
254
|
+
<IconChevronLeft class="h-4 w-4" />
|
|
255
|
+
</BitsDatePicker.PrevButton>
|
|
256
|
+
<BitsDatePicker.Heading class="text-sm font-semibold text-stone-900" />
|
|
257
|
+
<BitsDatePicker.NextButton class="flex h-8 w-8 items-center justify-center rounded-lg text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 disabled:opacity-40">
|
|
258
|
+
<IconChevronRight class="h-4 w-4" />
|
|
259
|
+
</BitsDatePicker.NextButton>
|
|
260
|
+
</BitsDatePicker.Header>
|
|
261
|
+
|
|
262
|
+
{#each months as month (month.value.toString())}
|
|
263
|
+
<BitsDatePicker.Grid class="w-full border-separate border-spacing-1">
|
|
264
|
+
<BitsDatePicker.GridHead>
|
|
265
|
+
<BitsDatePicker.GridRow>
|
|
266
|
+
{#each weekdays as day}
|
|
267
|
+
<BitsDatePicker.HeadCell class="h-7 w-8 text-center text-[10.5px] font-semibold uppercase text-[var(--stor-muted-text)]">
|
|
268
|
+
{day}
|
|
269
|
+
</BitsDatePicker.HeadCell>
|
|
270
|
+
{/each}
|
|
271
|
+
</BitsDatePicker.GridRow>
|
|
272
|
+
</BitsDatePicker.GridHead>
|
|
273
|
+
<BitsDatePicker.GridBody>
|
|
274
|
+
{#each month.weeks as week}
|
|
275
|
+
<BitsDatePicker.GridRow>
|
|
276
|
+
{#each week as date}
|
|
277
|
+
<BitsDatePicker.Cell
|
|
278
|
+
{date}
|
|
279
|
+
month={month.value}
|
|
280
|
+
class="p-0 text-center"
|
|
281
|
+
>
|
|
282
|
+
<BitsDatePicker.Day
|
|
283
|
+
class="flex h-8 w-8 items-center justify-center rounded-lg text-sm font-medium text-[var(--stor-muted-text)] outline-none transition hover:bg-[var(--stor-accent-soft)] focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] data-[disabled]:pointer-events-none data-[disabled]:opacity-30 data-[outside-month]:text-[var(--stor-muted-text)] data-[selected]:bg-[var(--stor-action)] data-[selected]:text-[var(--stor-action-text)] data-[today]:ring-1 data-[today]:ring-[var(--stor-focus)] data-[unavailable]:text-[var(--stor-negative)]"
|
|
284
|
+
/>
|
|
285
|
+
</BitsDatePicker.Cell>
|
|
286
|
+
{/each}
|
|
287
|
+
</BitsDatePicker.GridRow>
|
|
288
|
+
{/each}
|
|
289
|
+
</BitsDatePicker.GridBody>
|
|
290
|
+
</BitsDatePicker.Grid>
|
|
291
|
+
{/each}
|
|
292
|
+
{/snippet}
|
|
293
|
+
</BitsDatePicker.Calendar>
|
|
294
|
+
|
|
295
|
+
{#if granularity === "minute"}
|
|
296
|
+
<div class="mt-3 border-t border-[var(--stor-border)] pt-3">
|
|
297
|
+
<div class="mb-2 flex items-center justify-between gap-3">
|
|
298
|
+
<span class="text-xs font-semibold uppercase text-[var(--stor-muted-text)]">Time</span>
|
|
299
|
+
<BitsDatePicker.Close class="rounded-lg px-2.5 py-1.5 text-xs font-semibold text-stone-600 transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400">
|
|
300
|
+
Done
|
|
301
|
+
</BitsDatePicker.Close>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
<div class="mb-2">
|
|
305
|
+
<BitsTimeField.Root
|
|
306
|
+
value={timeValue}
|
|
307
|
+
placeholder={timePlaceholder}
|
|
308
|
+
onValueChange={setTime}
|
|
309
|
+
granularity="minute"
|
|
310
|
+
hourCycle={24}
|
|
311
|
+
hideTimeZone
|
|
312
|
+
{locale}
|
|
313
|
+
{disabled}
|
|
314
|
+
>
|
|
315
|
+
<BitsTimeField.Input
|
|
316
|
+
aria-label="Selected time"
|
|
317
|
+
class="flex min-h-9 items-center gap-0.5 rounded-lg border border-[var(--stor-border)] bg-[var(--stor-surface-muted)] px-3 py-2 text-sm text-stone-950 outline-none transition focus-within:border-stone-400 focus-within:bg-white focus-within:ring-2 focus-within:ring-stone-200"
|
|
318
|
+
>
|
|
319
|
+
{#snippet children({ segments })}
|
|
320
|
+
{#each segments as segment}
|
|
321
|
+
<BitsTimeField.Segment
|
|
322
|
+
part={segment.part}
|
|
323
|
+
class={segmentClass(segment.part)}
|
|
324
|
+
>
|
|
325
|
+
{segment.value}
|
|
326
|
+
</BitsTimeField.Segment>
|
|
327
|
+
{/each}
|
|
328
|
+
{/snippet}
|
|
329
|
+
</BitsTimeField.Input>
|
|
330
|
+
</BitsTimeField.Root>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<div class="grid grid-cols-4 gap-1.5">
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
class="flex h-8 items-center justify-center gap-1 rounded-2xl bg-[var(--stor-surface-raised)] px-2 text-xs font-medium text-stone-600 transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400"
|
|
337
|
+
aria-label="Decrease time by one hour"
|
|
338
|
+
onclick={() => adjustTime(-60)}
|
|
339
|
+
>
|
|
340
|
+
<IconMinus class="h-3.5 w-3.5" />
|
|
341
|
+
1h
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
class="flex h-8 items-center justify-center gap-1 rounded-2xl bg-[var(--stor-surface-raised)] px-2 text-xs font-medium text-stone-600 transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400"
|
|
346
|
+
aria-label="Increase time by one hour"
|
|
347
|
+
onclick={() => adjustTime(60)}
|
|
348
|
+
>
|
|
349
|
+
<IconPlus class="h-3.5 w-3.5" />
|
|
350
|
+
1h
|
|
351
|
+
</button>
|
|
352
|
+
<button
|
|
353
|
+
type="button"
|
|
354
|
+
class="flex h-8 items-center justify-center gap-1 rounded-2xl bg-[var(--stor-surface-raised)] px-2 text-xs font-medium text-stone-600 transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400"
|
|
355
|
+
aria-label="Decrease time by five minutes"
|
|
356
|
+
onclick={() => adjustTime(-5)}
|
|
357
|
+
>
|
|
358
|
+
<IconMinus class="h-3.5 w-3.5" />
|
|
359
|
+
5m
|
|
360
|
+
</button>
|
|
361
|
+
<button
|
|
362
|
+
type="button"
|
|
363
|
+
class="flex h-8 items-center justify-center gap-1 rounded-2xl bg-[var(--stor-surface-raised)] px-2 text-xs font-medium text-stone-600 transition hover:bg-[var(--stor-surface-muted)] hover:text-stone-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400"
|
|
364
|
+
aria-label="Increase time by five minutes"
|
|
365
|
+
onclick={() => adjustTime(5)}
|
|
366
|
+
>
|
|
367
|
+
<IconPlus class="h-3.5 w-3.5" />
|
|
368
|
+
5m
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
{/if}
|
|
373
|
+
</BitsDatePicker.Content>
|
|
374
|
+
</BitsDatePicker.Portal>
|
|
375
|
+
</BitsDatePicker.Root>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import DatePicker from "./DatePicker.svelte";
|
|
3
|
+
|
|
4
|
+
let {
|
|
5
|
+
value = $bindable(""),
|
|
6
|
+
placeholder = "Choose date and time",
|
|
7
|
+
...restProps
|
|
8
|
+
} = $props();
|
|
9
|
+
</script>
|
|
10
|
+
|
|
11
|
+
<DatePicker
|
|
12
|
+
bind:value
|
|
13
|
+
{placeholder}
|
|
14
|
+
granularity="minute"
|
|
15
|
+
{...restProps}
|
|
16
|
+
/>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import Label from "./Label.svelte";
|
|
5
|
+
import { cn } from "../../utils/cn.js";
|
|
6
|
+
|
|
7
|
+
type FieldProps = HTMLAttributes<HTMLDivElement> & {
|
|
8
|
+
label?: string;
|
|
9
|
+
htmlFor?: string;
|
|
10
|
+
hint?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
labelClass?: string;
|
|
14
|
+
hintClass?: string;
|
|
15
|
+
children?: Snippet;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
class: className = "",
|
|
20
|
+
label = "",
|
|
21
|
+
htmlFor = "",
|
|
22
|
+
hint = "",
|
|
23
|
+
error = "",
|
|
24
|
+
required = false,
|
|
25
|
+
labelClass = "",
|
|
26
|
+
hintClass = "",
|
|
27
|
+
children,
|
|
28
|
+
...restProps
|
|
29
|
+
}: FieldProps = $props();
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<div class={cn("space-y-1.5", className)} {...restProps}>
|
|
33
|
+
{#if label}
|
|
34
|
+
<Label
|
|
35
|
+
for={htmlFor || undefined}
|
|
36
|
+
class={cn("ml-0.5 block", labelClass)}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
{#if required}
|
|
40
|
+
<span class="text-[var(--stor-negative)]">*</span>
|
|
41
|
+
{/if}
|
|
42
|
+
</Label>
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
45
|
+
{@render children?.()}
|
|
46
|
+
|
|
47
|
+
{#if error}
|
|
48
|
+
<p class="ml-0.5 text-xs font-medium text-[var(--stor-negative)]">{error}</p>
|
|
49
|
+
{:else if hint}
|
|
50
|
+
<p class={cn("ml-0.5 text-xs text-[var(--stor-muted-text)]", hintClass)}>{hint}</p>
|
|
51
|
+
{/if}
|
|
52
|
+
</div>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import IconChevronRight from "~icons/mdi/chevron-right";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What a form fills in for you, or what it folds away, as one muted line.
|
|
7
|
+
* Tapping it opens the fields underneath; nothing in it is hidden or guessed.
|
|
8
|
+
*/
|
|
9
|
+
let {
|
|
10
|
+
open = $bindable(false),
|
|
11
|
+
summary,
|
|
12
|
+
tone = "muted",
|
|
13
|
+
children,
|
|
14
|
+
}: {
|
|
15
|
+
open?: boolean;
|
|
16
|
+
summary: string;
|
|
17
|
+
/** `muted` for a line of defaults, `link` for a plain fold. */
|
|
18
|
+
tone?: "muted" | "link";
|
|
19
|
+
/** The fields it opens; without them the line only flips `open` for the form to show its own fields. */
|
|
20
|
+
children?: Snippet;
|
|
21
|
+
} = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
{#if open}
|
|
25
|
+
{#if children}<div class="flex flex-col gap-3">{@render children()}</div>{/if}
|
|
26
|
+
{:else}
|
|
27
|
+
<button
|
|
28
|
+
type="button"
|
|
29
|
+
class="flex min-h-11 w-full items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {tone === 'muted'
|
|
30
|
+
? 'rounded-xl bg-[var(--stor-surface-muted)] px-3 py-2.5 text-[13px] font-medium leading-snug text-[var(--stor-muted-text)]'
|
|
31
|
+
: 'text-sm font-semibold text-[var(--stor-accent-text)]'}"
|
|
32
|
+
aria-expanded="false"
|
|
33
|
+
onclick={() => (open = true)}
|
|
34
|
+
>
|
|
35
|
+
<span class="min-w-0 flex-1">{summary}</span>
|
|
36
|
+
<IconChevronRight class="h-[18px] w-[18px] shrink-0" />
|
|
37
|
+
</button>
|
|
38
|
+
{/if}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLInputAttributes } from "svelte/elements";
|
|
3
|
+
import { cn } from "../../utils/cn.js";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
class: className = "",
|
|
7
|
+
type = "text",
|
|
8
|
+
value = $bindable(),
|
|
9
|
+
...restProps
|
|
10
|
+
}: HTMLInputAttributes = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<input
|
|
14
|
+
{type}
|
|
15
|
+
bind:value
|
|
16
|
+
class={cn(
|
|
17
|
+
"w-full rounded-xl border border-[var(--stor-field-border)] stor-well focus:bg-[var(--stor-surface)] h-11 px-3 py-2.5 text-base sm:h-9 sm:py-1.5 sm:text-sm text-[var(--stor-ink)] outline-none transition placeholder:text-[var(--stor-muted-text)] focus:border-[var(--stor-focus)] focus:ring-2 focus:ring-[var(--stor-selected)] disabled:cursor-not-allowed disabled:bg-[var(--stor-surface-muted)] disabled:text-[var(--stor-muted-text)]",
|
|
18
|
+
className,
|
|
19
|
+
)}
|
|
20
|
+
{...restProps}
|
|
21
|
+
/>
|