@hearthlog/ui 0.0.0-stage → 0.1.1

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.
Files changed (63) hide show
  1. package/LICENSE +235 -0
  2. package/package.json +37 -3
  3. package/src/assets/fonts/fira-code-latin-ext-ui.woff2 +0 -0
  4. package/src/assets/fonts/fira-code-latin-ui.woff2 +0 -0
  5. package/src/assets/fonts/inter-latin-ext-ui.woff2 +0 -0
  6. package/src/assets/fonts/inter-latin-ui.woff2 +0 -0
  7. package/src/components/ConfirmDialog.svelte +104 -0
  8. package/src/components/Drawer.svelte +135 -0
  9. package/src/components/EmptyState.svelte +33 -0
  10. package/src/components/JsonViewer.svelte +113 -0
  11. package/src/components/Modal.svelte +76 -0
  12. package/src/components/NotificationCenter.svelte +106 -0
  13. package/src/components/PageHeader.svelte +26 -0
  14. package/src/components/commands/CommandConfirmDialog.svelte +32 -0
  15. package/src/components/commands/CommandDrawer.svelte +31 -0
  16. package/src/components/commands/CommandFieldset.svelte +18 -0
  17. package/src/components/commands/CommandModal.svelte +25 -0
  18. package/src/components/commands/CommandOnly.svelte +26 -0
  19. package/src/components/data/DataPlaceholder.svelte +8 -0
  20. package/src/components/data/DataRegion.svelte +8 -0
  21. package/src/components/data/DataValue.svelte +11 -0
  22. package/src/components/data-table/DataTable.svelte +139 -0
  23. package/src/components/data-table/DataTableHeader.svelte +78 -0
  24. package/src/components/data-table/DataTablePagination.svelte +91 -0
  25. package/src/components/data-table/table.ts +30 -0
  26. package/src/components/data-table/types.ts +63 -0
  27. package/src/components/endless/DayPager.svelte +355 -0
  28. package/src/components/endless/EndlessList.svelte +498 -0
  29. package/src/components/endless/bucket-store.svelte.ts +81 -0
  30. package/src/components/json-viewer.ts +49 -0
  31. package/src/components/ui/Amount.svelte +17 -0
  32. package/src/components/ui/Badge.svelte +40 -0
  33. package/src/components/ui/Button.svelte +53 -0
  34. package/src/components/ui/Checkbox.svelte +29 -0
  35. package/src/components/ui/ChoiceChips.svelte +53 -0
  36. package/src/components/ui/CollectionToolbar.svelte +29 -0
  37. package/src/components/ui/DatePicker.svelte +375 -0
  38. package/src/components/ui/DateTimePicker.svelte +16 -0
  39. package/src/components/ui/Field.svelte +52 -0
  40. package/src/components/ui/FoldLine.svelte +38 -0
  41. package/src/components/ui/Input.svelte +21 -0
  42. package/src/components/ui/Label.svelte +18 -0
  43. package/src/components/ui/Metric.svelte +34 -0
  44. package/src/components/ui/MonthNavigator.svelte +194 -0
  45. package/src/components/ui/Panel.svelte +37 -0
  46. package/src/components/ui/RouteTabs.svelte +57 -0
  47. package/src/components/ui/Select.svelte +93 -0
  48. package/src/components/ui/Switch.svelte +41 -0
  49. package/src/components/ui/Textarea.svelte +19 -0
  50. package/src/components/ui/Tooltip.svelte +53 -0
  51. package/src/components/ui/tab-indicator.ts +84 -0
  52. package/src/state/commands.ts +22 -0
  53. package/src/state/notifications.svelte.ts +49 -0
  54. package/src/state/page-context.ts +9 -0
  55. package/src/state/readiness.ts +20 -0
  56. package/src/state/sheet-stack.svelte.ts +49 -0
  57. package/src/styles/base.css +243 -0
  58. package/src/styles/index.css +13 -0
  59. package/src/styles/recipes.css +174 -0
  60. package/src/utils/cn.ts +6 -0
  61. package/src/utils/endless-axis.ts +153 -0
  62. package/src/utils/number-format.ts +82 -0
  63. 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
+ />