@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,18 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Label as BitsLabel } from "bits-ui";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import { cn } from "../../utils/cn.js";
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
class: className = "",
|
|
8
|
+
children,
|
|
9
|
+
...restProps
|
|
10
|
+
}: BitsLabel.RootProps & { children?: Snippet } = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<BitsLabel.Root
|
|
14
|
+
class={cn("text-sm font-semibold text-stone-800", className)}
|
|
15
|
+
{...restProps}
|
|
16
|
+
>
|
|
17
|
+
{@render children?.()}
|
|
18
|
+
</BitsLabel.Root>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
label,
|
|
7
|
+
detail = '',
|
|
8
|
+
tone = 'neutral',
|
|
9
|
+
size = 'md',
|
|
10
|
+
children,
|
|
11
|
+
class: className = '',
|
|
12
|
+
}: {
|
|
13
|
+
label: string;
|
|
14
|
+
detail?: string;
|
|
15
|
+
tone?: 'neutral' | 'muted' | 'positive' | 'negative' | 'accent';
|
|
16
|
+
size?: 'md' | 'lg';
|
|
17
|
+
children: Snippet;
|
|
18
|
+
class?: string;
|
|
19
|
+
} = $props();
|
|
20
|
+
|
|
21
|
+
const valueTone = {
|
|
22
|
+
neutral: 'text-[var(--stor-ink)]',
|
|
23
|
+
muted: 'text-[var(--stor-muted-text)] !text-base !font-medium !tracking-normal',
|
|
24
|
+
positive: 'text-[var(--stor-positive)]',
|
|
25
|
+
negative: 'text-[var(--stor-negative)]',
|
|
26
|
+
accent: 'text-[var(--stor-accent-text)]',
|
|
27
|
+
};
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class={cn('stor-metric min-w-0', className)}>
|
|
31
|
+
<p class="stor-metric-label">{label}</p>
|
|
32
|
+
<p class={cn('stor-metric-value tabular-nums', size === 'md' && 'text-xl lg:text-2xl', valueTone[tone])}>{@render children()}</p>
|
|
33
|
+
{#if detail}<p class="stor-metric-support">{detail}</p>{/if}
|
|
34
|
+
</div>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Dialog, Popover } from 'bits-ui';
|
|
3
|
+
import IconArrowLeft from '~icons/mdi/arrow-left';
|
|
4
|
+
import IconArrowRight from '~icons/mdi/arrow-right';
|
|
5
|
+
import IconChevronDown from '~icons/mdi/chevron-down';
|
|
6
|
+
import IconClose from '~icons/mdi/close';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
year,
|
|
10
|
+
month,
|
|
11
|
+
currentYear,
|
|
12
|
+
currentMonth,
|
|
13
|
+
subtitle = '',
|
|
14
|
+
compact = false,
|
|
15
|
+
phoneArrows = true,
|
|
16
|
+
hrefForMonth,
|
|
17
|
+
onNavigate,
|
|
18
|
+
class: className = '',
|
|
19
|
+
}: {
|
|
20
|
+
year: number;
|
|
21
|
+
month: number;
|
|
22
|
+
currentYear: number;
|
|
23
|
+
currentMonth: number;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
compact?: boolean;
|
|
26
|
+
/** Off where a list already scrolls through the months: phones then keep only the month picker. */
|
|
27
|
+
phoneArrows?: boolean;
|
|
28
|
+
hrefForMonth?: (year: number, month: number) => string;
|
|
29
|
+
onNavigate: (year: number, month: number) => void | Promise<void>;
|
|
30
|
+
class?: string;
|
|
31
|
+
} = $props();
|
|
32
|
+
|
|
33
|
+
const monthNames = [
|
|
34
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
35
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
36
|
+
];
|
|
37
|
+
let desktopOpen = $state(false);
|
|
38
|
+
let mobileOpen = $state(false);
|
|
39
|
+
let pickerYear = $state(0);
|
|
40
|
+
let choosingYear = $state(false);
|
|
41
|
+
const monthLabel = $derived(`${monthNames[month - 1]} ${year}`);
|
|
42
|
+
const isCurrentMonth = $derived(year === currentYear && month === currentMonth);
|
|
43
|
+
const previous = $derived(shiftMonth(-1));
|
|
44
|
+
const next = $derived(shiftMonth(1));
|
|
45
|
+
const firstYear = $derived(Math.floor(pickerYear / 12) * 12);
|
|
46
|
+
// Phones have no room for Today in the month row and a second row would push the page down, so it
|
|
47
|
+
// stays off there: the lists below float their own Today, and the month picker offers the current month.
|
|
48
|
+
const todayActionClass = 'max-sm:hidden col-span-3 col-start-1 row-start-2 flex h-11 items-center justify-center justify-self-end rounded-xl px-3 text-sm font-semibold text-[var(--stor-accent-strong)] hover:bg-[var(--stor-accent-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] sm:col-span-1 sm:row-start-1 sm:w-20 sm:justify-self-stretch sm:bg-[var(--stor-surface-raised)] sm:shadow-[var(--stor-shadow-sm)]';
|
|
49
|
+
|
|
50
|
+
function shiftMonth(amount: number): { year: number; month: number } {
|
|
51
|
+
const date = new Date(year, month - 1 + amount, 1);
|
|
52
|
+
return { year: date.getFullYear(), month: date.getMonth() + 1 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setOpen(kind: 'desktop' | 'mobile', open: boolean): void {
|
|
56
|
+
if (kind === 'desktop') desktopOpen = open;
|
|
57
|
+
else mobileOpen = open;
|
|
58
|
+
if (open) {
|
|
59
|
+
pickerYear = year;
|
|
60
|
+
choosingYear = false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function navigateTo(targetYear: number, targetMonth: number): void {
|
|
65
|
+
desktopOpen = false;
|
|
66
|
+
mobileOpen = false;
|
|
67
|
+
void onNavigate(targetYear, targetMonth);
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
{#snippet pickerContent()}
|
|
72
|
+
<div data-month-picker>
|
|
73
|
+
<div class="flex items-center justify-between gap-3">
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
class="flex h-11 w-11 items-center justify-center rounded-xl text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
|
|
77
|
+
aria-label={choosingYear ? 'Previous 12 years' : 'Previous year'}
|
|
78
|
+
onclick={() => pickerYear -= choosingYear ? 12 : 1}
|
|
79
|
+
><IconArrowLeft class="h-5 w-5" /></button>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
class="flex h-11 items-center gap-1 rounded-xl px-3 text-lg font-semibold tabular-nums text-[var(--stor-ink)] transition hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
|
|
83
|
+
aria-label={`Choose year, ${pickerYear}`}
|
|
84
|
+
aria-expanded={choosingYear}
|
|
85
|
+
onclick={() => choosingYear = !choosingYear}
|
|
86
|
+
>{pickerYear}<IconChevronDown class="h-4 w-4" /></button>
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
class="flex h-11 w-11 items-center justify-center rounded-xl text-[var(--stor-muted-text)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
|
|
90
|
+
aria-label={choosingYear ? 'Next 12 years' : 'Next year'}
|
|
91
|
+
onclick={() => pickerYear += choosingYear ? 12 : 1}
|
|
92
|
+
><IconArrowRight class="h-5 w-5" /></button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="mt-3 grid grid-cols-3 gap-2" aria-label={choosingYear ? 'Years' : 'Months'}>
|
|
96
|
+
{#if choosingYear}
|
|
97
|
+
{#each Array.from({ length: 12 }, (_, index) => firstYear + index) as optionYear}
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
class="flex h-11 items-center justify-center rounded-xl text-sm font-semibold tabular-nums transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {optionYear === year ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)]' : 'bg-[var(--stor-surface-muted)] text-[var(--stor-ink)] hover:bg-[var(--stor-surface-hover)]'}"
|
|
101
|
+
aria-pressed={optionYear === year}
|
|
102
|
+
onclick={() => { pickerYear = optionYear; choosingYear = false; }}
|
|
103
|
+
>{optionYear}</button>
|
|
104
|
+
{/each}
|
|
105
|
+
{:else}
|
|
106
|
+
{#each monthNames as name, index}
|
|
107
|
+
{@const optionMonth = index + 1}
|
|
108
|
+
{@const selected = pickerYear === year && optionMonth === month}
|
|
109
|
+
{@const current = pickerYear === currentYear && optionMonth === currentMonth}
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
class="relative flex h-11 items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {selected ? 'bg-[var(--stor-action)] text-[var(--stor-action-text)]' : 'bg-[var(--stor-surface-muted)] text-[var(--stor-ink)] hover:bg-[var(--stor-surface-hover)]'} {current && !selected ? 'ring-1 ring-inset ring-[var(--stor-accent)]' : ''}"
|
|
113
|
+
aria-label={`${name} ${pickerYear}${current ? ', current month' : ''}`}
|
|
114
|
+
aria-pressed={selected}
|
|
115
|
+
onclick={() => navigateTo(pickerYear, optionMonth)}
|
|
116
|
+
>{name.slice(0, 3)}</button>
|
|
117
|
+
{/each}
|
|
118
|
+
{/if}
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{#if !isCurrentMonth}
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
class="mt-3 flex h-11 w-full items-center justify-center rounded-xl text-sm font-semibold text-[var(--stor-accent-strong)] transition hover:bg-[var(--stor-accent-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]"
|
|
125
|
+
onclick={() => navigateTo(currentYear, currentMonth)}
|
|
126
|
+
>Go to current month</button>
|
|
127
|
+
{:else}
|
|
128
|
+
<div class="mt-3 h-11" aria-hidden="true"></div>
|
|
129
|
+
{/if}
|
|
130
|
+
</div>
|
|
131
|
+
{/snippet}
|
|
132
|
+
|
|
133
|
+
<section data-month-navigation class="grid w-full grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-center gap-x-2 gap-y-1 {compact ? 'sm:grid-cols-[2.75rem_13rem_2.75rem_5rem] sm:justify-start' : 'sm:grid-cols-[minmax(0,1fr)_2.75rem_5rem_2.75rem]'} {phoneArrows ? '' : 'max-sm:flex max-sm:w-auto'} {className}" aria-label="Month navigation">
|
|
134
|
+
<div class="col-start-2 row-start-1 min-w-0 text-center {phoneArrows ? '' : 'max-sm:text-left'} {compact ? 'sm:col-start-2 sm:text-center' : 'sm:col-start-1 sm:text-left'}">
|
|
135
|
+
<h2 class="stor-serif-title text-lg text-[var(--stor-ink)] sm:text-[22px]">
|
|
136
|
+
<Popover.Root open={desktopOpen} onOpenChange={(open) => setOpen('desktop', open)}>
|
|
137
|
+
<Popover.Trigger
|
|
138
|
+
class="hidden min-h-11 max-w-full items-center gap-1.5 rounded-xl px-1 text-left transition hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] sm:inline-flex"
|
|
139
|
+
aria-label={`Choose month, ${monthLabel}`}
|
|
140
|
+
aria-expanded={desktopOpen}
|
|
141
|
+
><span class="truncate">{monthLabel}</span><IconChevronDown class="h-4 w-4 shrink-0" /></Popover.Trigger>
|
|
142
|
+
<Popover.Portal>
|
|
143
|
+
<Popover.Content side="bottom" align="start" sideOffset={8} class="stor-menu z-[var(--stor-layer-sheet)] hidden w-72 p-3 outline-none sm:block">
|
|
144
|
+
<p class="sr-only">Choose month</p>
|
|
145
|
+
{@render pickerContent()}
|
|
146
|
+
</Popover.Content>
|
|
147
|
+
</Popover.Portal>
|
|
148
|
+
</Popover.Root>
|
|
149
|
+
|
|
150
|
+
<Dialog.Root open={mobileOpen} onOpenChange={(open) => setOpen('mobile', open)}>
|
|
151
|
+
<Dialog.Trigger
|
|
152
|
+
class="inline-flex min-h-11 max-w-full items-center gap-1.5 rounded-xl px-1 transition hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] sm:hidden"
|
|
153
|
+
aria-label={`Choose month, ${monthLabel}`}
|
|
154
|
+
aria-expanded={mobileOpen}
|
|
155
|
+
><span class="truncate">{monthLabel}</span><IconChevronDown class="h-4 w-4 shrink-0" /></Dialog.Trigger>
|
|
156
|
+
<Dialog.Portal>
|
|
157
|
+
<Dialog.Overlay class="fixed inset-0 z-[var(--stor-layer-sheet)] bg-[var(--stor-overlay)] sm:hidden" />
|
|
158
|
+
<Dialog.Content class="stor-sheet fixed inset-x-0 bottom-0 z-[var(--stor-layer-sheet-content)] max-h-[calc(100dvh-env(safe-area-inset-top))] overflow-y-auto rounded-t-3xl px-4 pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] outline-none sm:hidden">
|
|
159
|
+
<div class="mx-auto mb-4 h-1 w-10 rounded-full bg-[var(--stor-border)]" aria-hidden="true"></div>
|
|
160
|
+
<div class="mb-2 flex items-center justify-between">
|
|
161
|
+
<Dialog.Title level={3} class="text-xl font-semibold text-[var(--stor-ink)]">Choose month</Dialog.Title>
|
|
162
|
+
<Dialog.Close class="flex h-11 w-11 items-center justify-center rounded-xl text-[var(--stor-muted-text)] hover:bg-[var(--stor-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" aria-label="Close month picker"><IconClose class="h-5 w-5" /></Dialog.Close>
|
|
163
|
+
</div>
|
|
164
|
+
{@render pickerContent()}
|
|
165
|
+
</Dialog.Content>
|
|
166
|
+
</Dialog.Portal>
|
|
167
|
+
</Dialog.Root>
|
|
168
|
+
</h2>
|
|
169
|
+
<!-- A phone keeps the row to the month alone; the page repeats the figure lower down. -->
|
|
170
|
+
{#if subtitle}<p class="mt-0.5 text-sm leading-5 text-[var(--stor-muted-text)] max-sm:hidden">{subtitle}</p>{/if}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
{#if hrefForMonth}
|
|
174
|
+
<a href={hrefForMonth(previous.year, previous.month)} class="{phoneArrows ? '' : 'max-sm:hidden'} col-start-1 row-start-1 flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--stor-surface-raised)] text-[var(--stor-muted-text)] shadow-[var(--stor-shadow-sm)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {compact ? 'sm:col-start-1' : 'sm:col-start-2'}" aria-label="Previous month"><IconArrowLeft class="h-5 w-5" /></a>
|
|
175
|
+
{:else}
|
|
176
|
+
<button type="button" onclick={() => navigateTo(previous.year, previous.month)} class="{phoneArrows ? '' : 'max-sm:hidden'} col-start-1 row-start-1 flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--stor-surface-raised)] text-[var(--stor-muted-text)] shadow-[var(--stor-shadow-sm)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] {compact ? 'sm:col-start-1' : 'sm:col-start-2'} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" aria-label="Previous month"><IconArrowLeft class="h-5 w-5" /></button>
|
|
177
|
+
{/if}
|
|
178
|
+
|
|
179
|
+
{#if !isCurrentMonth}
|
|
180
|
+
{#if hrefForMonth}
|
|
181
|
+
<a href={hrefForMonth(currentYear, currentMonth)} class={`${todayActionClass} ${compact ? 'sm:col-start-4' : 'sm:col-start-3'}`} aria-label="Go to current month">Today</a>
|
|
182
|
+
{:else}
|
|
183
|
+
<button type="button" onclick={() => navigateTo(currentYear, currentMonth)} class={`${todayActionClass} ${compact ? 'sm:col-start-4' : 'sm:col-start-3'}`} aria-label="Go to current month">Today</button>
|
|
184
|
+
{/if}
|
|
185
|
+
{:else}
|
|
186
|
+
<span class={`${todayActionClass} ${compact ? 'sm:col-start-4' : 'sm:col-start-3'} invisible`} aria-hidden="true">Today</span>
|
|
187
|
+
{/if}
|
|
188
|
+
|
|
189
|
+
{#if hrefForMonth}
|
|
190
|
+
<a href={hrefForMonth(next.year, next.month)} class="{phoneArrows ? '' : 'max-sm:hidden'} col-start-3 row-start-1 flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--stor-surface-raised)] text-[var(--stor-muted-text)] shadow-[var(--stor-shadow-sm)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] {compact ? 'sm:col-start-3' : 'sm:col-start-4'}" aria-label="Next month"><IconArrowRight class="h-5 w-5" /></a>
|
|
191
|
+
{:else}
|
|
192
|
+
<button type="button" onclick={() => navigateTo(next.year, next.month)} class="{phoneArrows ? '' : 'max-sm:hidden'} col-start-3 row-start-1 flex h-11 w-11 items-center justify-center rounded-xl bg-[var(--stor-surface-raised)] text-[var(--stor-muted-text)] shadow-[var(--stor-shadow-sm)] transition hover:bg-[var(--stor-surface-hover)] hover:text-[var(--stor-ink)] {compact ? 'sm:col-start-3' : 'sm:col-start-4'} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)]" aria-label="Next month"><IconArrowRight class="h-5 w-5" /></button>
|
|
193
|
+
{/if}
|
|
194
|
+
</section>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
title = '',
|
|
7
|
+
description = '',
|
|
8
|
+
action,
|
|
9
|
+
children,
|
|
10
|
+
padding = 'body',
|
|
11
|
+
label,
|
|
12
|
+
class: className = '',
|
|
13
|
+
}: {
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
action?: Snippet;
|
|
17
|
+
children: Snippet;
|
|
18
|
+
padding?: 'none' | 'body' | 'all';
|
|
19
|
+
label?: string;
|
|
20
|
+
class?: string;
|
|
21
|
+
} = $props();
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<section class={cn('stor-panel', padding === 'all' && 'p-[var(--stor-panel-padding)]', className)} aria-label={label}>
|
|
25
|
+
{#if title}
|
|
26
|
+
<div class={cn('stor-panel-header', padding === 'all' && '!p-0')}>
|
|
27
|
+
<div class="min-w-0">
|
|
28
|
+
<h2 class="text-base font-semibold tracking-tight text-[var(--stor-ink)]">{title}</h2>
|
|
29
|
+
{#if description}<p class="mt-0.5 text-xs text-[var(--stor-muted-text)]">{description}</p>{/if}
|
|
30
|
+
</div>
|
|
31
|
+
{@render action?.()}
|
|
32
|
+
</div>
|
|
33
|
+
{/if}
|
|
34
|
+
<div class={cn(padding === 'body' && 'stor-panel-body', padding === 'all' && title && 'mt-4')}>
|
|
35
|
+
{@render children()}
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick, type Component } from 'svelte';
|
|
3
|
+
import { cn } from '../../utils/cn.js';
|
|
4
|
+
import DataValue from '../data/DataValue.svelte';
|
|
5
|
+
import { tabIndicator } from './tab-indicator.js';
|
|
6
|
+
|
|
7
|
+
export interface RouteTab {
|
|
8
|
+
label: string;
|
|
9
|
+
href: string;
|
|
10
|
+
active: boolean;
|
|
11
|
+
icon?: Component;
|
|
12
|
+
count?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
label,
|
|
17
|
+
tabs,
|
|
18
|
+
variant = 'primary',
|
|
19
|
+
class: className = '',
|
|
20
|
+
}: {
|
|
21
|
+
label: string;
|
|
22
|
+
tabs: readonly RouteTab[];
|
|
23
|
+
variant?: 'primary' | 'section';
|
|
24
|
+
class?: string;
|
|
25
|
+
} = $props();
|
|
26
|
+
|
|
27
|
+
let nav = $state<HTMLElement | null>(null);
|
|
28
|
+
|
|
29
|
+
$effect(() => {
|
|
30
|
+
if (tabs.length === 0 || !nav) return;
|
|
31
|
+
void tick().then(() => {
|
|
32
|
+
const selected = nav?.querySelector<HTMLElement>('[aria-current="page"]');
|
|
33
|
+
if (selected && nav && nav.scrollWidth > nav.clientWidth) {
|
|
34
|
+
const selectedRect = selected.getBoundingClientRect();
|
|
35
|
+
const navRect = nav.getBoundingClientRect();
|
|
36
|
+
nav.scrollLeft += selectedRect.left - navRect.left - (nav.clientWidth - selectedRect.width) / 2;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<nav
|
|
43
|
+
bind:this={nav}
|
|
44
|
+
aria-label={label}
|
|
45
|
+
class={cn('stor-page-tabs mobile-scroll min-w-0 max-w-full items-center overflow-x-auto', variant === 'section' ? 'stor-section-tabs grid' : 'stor-primary-tabs flex', className)}
|
|
46
|
+
{@attach tabIndicator}
|
|
47
|
+
>
|
|
48
|
+
{#each tabs as tab (tab.href)}
|
|
49
|
+
<a href={tab.href} aria-current={tab.active ? 'page' : undefined} class="stor-page-tab">
|
|
50
|
+
{#if tab.icon}{@const Icon = tab.icon}<Icon class="h-5 w-5 shrink-0" />{/if}
|
|
51
|
+
<span>{tab.label}</span>
|
|
52
|
+
{#if tab.count !== undefined}
|
|
53
|
+
<span class="stor-tab-count"><DataValue>{tab.count}</DataValue></span>
|
|
54
|
+
{/if}
|
|
55
|
+
</a>
|
|
56
|
+
{/each}
|
|
57
|
+
</nav>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Select } from "bits-ui";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import { cn } from "../../utils/cn.js";
|
|
5
|
+
import IconCheck from "~icons/mdi/check";
|
|
6
|
+
import IconChevronDown from "~icons/mdi/chevron-down";
|
|
7
|
+
|
|
8
|
+
export interface SelectOption {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
value = "",
|
|
16
|
+
options = [],
|
|
17
|
+
placeholder = "Select",
|
|
18
|
+
disabled = false,
|
|
19
|
+
id = "",
|
|
20
|
+
name = "",
|
|
21
|
+
ariaLabel = "",
|
|
22
|
+
onValueChange = () => {},
|
|
23
|
+
class: className = "",
|
|
24
|
+
triggerClass = "",
|
|
25
|
+
contentClass = "",
|
|
26
|
+
children,
|
|
27
|
+
}: {
|
|
28
|
+
value?: string;
|
|
29
|
+
options: SelectOption[];
|
|
30
|
+
placeholder?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
id?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
ariaLabel?: string;
|
|
35
|
+
onValueChange?: (value: string) => void;
|
|
36
|
+
class?: string;
|
|
37
|
+
triggerClass?: string;
|
|
38
|
+
contentClass?: string;
|
|
39
|
+
children?: Snippet;
|
|
40
|
+
} = $props();
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<div class={className}>
|
|
44
|
+
<Select.Root
|
|
45
|
+
type="single"
|
|
46
|
+
{value}
|
|
47
|
+
items={options}
|
|
48
|
+
{disabled}
|
|
49
|
+
{name}
|
|
50
|
+
{onValueChange}
|
|
51
|
+
loop
|
|
52
|
+
>
|
|
53
|
+
<Select.Trigger
|
|
54
|
+
id={id || undefined}
|
|
55
|
+
aria-label={ariaLabel || undefined}
|
|
56
|
+
class={cn(
|
|
57
|
+
"flex w-full items-center justify-between gap-2 rounded-xl border border-[var(--stor-field-border)] stor-well focus:bg-[var(--stor-surface)] h-11 px-3 py-2.5 sm:h-9 sm:py-1.5 text-left text-sm text-[var(--stor-ink)] outline-none transition 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)]",
|
|
58
|
+
triggerClass,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
<Select.Value {placeholder} class="min-w-0 truncate" />
|
|
62
|
+
<IconChevronDown class="h-4 w-4 shrink-0 text-[var(--stor-muted-text)]" />
|
|
63
|
+
</Select.Trigger>
|
|
64
|
+
<Select.Portal>
|
|
65
|
+
<Select.Content
|
|
66
|
+
sideOffset={6}
|
|
67
|
+
class={cn(
|
|
68
|
+
"stor-menu z-[var(--stor-layer-menu)] max-h-72 min-w-[var(--bits-select-anchor-width)] overflow-hidden py-1",
|
|
69
|
+
contentClass,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<Select.Viewport>
|
|
73
|
+
{#each options as option (option.value)}
|
|
74
|
+
<Select.Item
|
|
75
|
+
value={option.value}
|
|
76
|
+
label={option.label}
|
|
77
|
+
disabled={option.disabled}
|
|
78
|
+
class="flex cursor-default items-center justify-between gap-3 px-3 py-2 text-sm text-[var(--stor-ink)] outline-none data-[highlighted]:bg-[var(--stor-surface-muted)] data-[disabled]:opacity-45"
|
|
79
|
+
>
|
|
80
|
+
{#snippet children({ selected })}
|
|
81
|
+
<span class="min-w-0 truncate">{option.label}</span>
|
|
82
|
+
{#if selected}
|
|
83
|
+
<IconCheck class="h-4 w-4 shrink-0" />
|
|
84
|
+
{/if}
|
|
85
|
+
{/snippet}
|
|
86
|
+
</Select.Item>
|
|
87
|
+
{/each}
|
|
88
|
+
{@render children?.()}
|
|
89
|
+
</Select.Viewport>
|
|
90
|
+
</Select.Content>
|
|
91
|
+
</Select.Portal>
|
|
92
|
+
</Select.Root>
|
|
93
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "../../utils/cn.js";
|
|
3
|
+
|
|
4
|
+
/** An on/off switch: one row with a label, an optional short hint, and the toggle at the end. */
|
|
5
|
+
let {
|
|
6
|
+
checked = false,
|
|
7
|
+
label,
|
|
8
|
+
hint = "",
|
|
9
|
+
disabled = false,
|
|
10
|
+
onCheckedChange,
|
|
11
|
+
ariaLabel,
|
|
12
|
+
class: className = "",
|
|
13
|
+
}: {
|
|
14
|
+
checked?: boolean;
|
|
15
|
+
label: string;
|
|
16
|
+
hint?: string;
|
|
17
|
+
disabled?: boolean;
|
|
18
|
+
onCheckedChange: (checked: boolean) => void;
|
|
19
|
+
/** When the label is not shown, e.g. a switch at the end of a list row. */
|
|
20
|
+
ariaLabel?: string;
|
|
21
|
+
class?: string;
|
|
22
|
+
} = $props();
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
role="switch"
|
|
28
|
+
aria-checked={checked}
|
|
29
|
+
aria-label={ariaLabel}
|
|
30
|
+
{disabled}
|
|
31
|
+
class={cn("flex min-h-12 w-full items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--stor-focus)] disabled:opacity-50", className)}
|
|
32
|
+
onclick={() => onCheckedChange(!checked)}
|
|
33
|
+
>
|
|
34
|
+
{#if label}<span class="min-w-0 flex-1">
|
|
35
|
+
<span class="block text-[15px] font-medium text-[var(--stor-ink)]">{label}</span>
|
|
36
|
+
{#if hint}<span class="block text-[12.5px] text-[var(--stor-muted-text)]">{hint}</span>{/if}
|
|
37
|
+
</span>{/if}
|
|
38
|
+
<span class="relative h-[30px] w-12 shrink-0 rounded-full transition-colors {checked ? 'bg-[var(--stor-action)]' : 'bg-[var(--stor-surface-well)] shadow-[inset_0_0_0_1px_var(--stor-border)]'}" aria-hidden="true">
|
|
39
|
+
<span class="absolute top-[3px] h-6 w-6 rounded-full bg-white shadow transition-[left] {checked ? 'left-[21px]' : 'left-[3px]'}"></span>
|
|
40
|
+
</span>
|
|
41
|
+
</button>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLTextareaAttributes } from "svelte/elements";
|
|
3
|
+
import { cn } from "../../utils/cn.js";
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
class: className = "",
|
|
7
|
+
value = $bindable(),
|
|
8
|
+
...restProps
|
|
9
|
+
}: HTMLTextareaAttributes = $props();
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<textarea
|
|
13
|
+
bind:value
|
|
14
|
+
class={cn(
|
|
15
|
+
"min-h-24 w-full rounded-xl border border-[var(--stor-field-border)] stor-well focus:bg-[var(--stor-surface)] px-3 py-2.5 text-base 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)]",
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
{...restProps}
|
|
19
|
+
></textarea>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Tooltip as BitsTooltip } from "bits-ui";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
import { cn } from "../../utils/cn.js";
|
|
5
|
+
|
|
6
|
+
type TooltipSide = "top" | "right" | "bottom" | "left";
|
|
7
|
+
type TooltipAlign = "start" | "center" | "end";
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
content,
|
|
11
|
+
side = "right",
|
|
12
|
+
align = "center",
|
|
13
|
+
sideOffset = 8,
|
|
14
|
+
delayDuration = 300,
|
|
15
|
+
disabled = false,
|
|
16
|
+
class: className = "",
|
|
17
|
+
trigger,
|
|
18
|
+
}: {
|
|
19
|
+
content: string;
|
|
20
|
+
side?: TooltipSide;
|
|
21
|
+
align?: TooltipAlign;
|
|
22
|
+
sideOffset?: number;
|
|
23
|
+
delayDuration?: number;
|
|
24
|
+
disabled?: boolean;
|
|
25
|
+
class?: string;
|
|
26
|
+
trigger: Snippet<[{ props: Record<string, unknown> }]>;
|
|
27
|
+
} = $props();
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<BitsTooltip.Provider {delayDuration}>
|
|
31
|
+
<BitsTooltip.Root disabled={disabled || content.trim().length === 0}>
|
|
32
|
+
<BitsTooltip.Trigger>
|
|
33
|
+
{#snippet child({ props })}
|
|
34
|
+
{@render trigger({ props })}
|
|
35
|
+
{/snippet}
|
|
36
|
+
</BitsTooltip.Trigger>
|
|
37
|
+
<BitsTooltip.Portal>
|
|
38
|
+
<BitsTooltip.Content
|
|
39
|
+
{side}
|
|
40
|
+
{align}
|
|
41
|
+
{sideOffset}
|
|
42
|
+
collisionPadding={8}
|
|
43
|
+
class={cn(
|
|
44
|
+
"z-[var(--stor-layer-tooltip)] rounded-lg bg-[var(--stor-tooltip)] px-2.5 py-1.5 text-xs font-semibold text-[var(--stor-tooltip-text)] shadow-[var(--stor-elevation-3)]",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{content}
|
|
49
|
+
<BitsTooltip.Arrow class="fill-[var(--stor-tooltip)]" />
|
|
50
|
+
</BitsTooltip.Content>
|
|
51
|
+
</BitsTooltip.Portal>
|
|
52
|
+
</BitsTooltip.Root>
|
|
53
|
+
</BitsTooltip.Provider>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const SELECTED_TAB =
|
|
2
|
+
'[data-state="active"], [aria-selected="true"], [aria-current="page"], [aria-current="true"]';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sliding selection pill for a tab row.
|
|
6
|
+
*
|
|
7
|
+
* Measures the selected trigger and animates a pill between tabs. The selected
|
|
8
|
+
* trigger keeps its own background until the pill is positioned, so the row
|
|
9
|
+
* never flashes unselected while the list is still laying out.
|
|
10
|
+
*
|
|
11
|
+
* Usage: `{@attach tabIndicator}` on a `.stor-page-tabs` row or a Bits UI
|
|
12
|
+
* `Tabs.List`. Motion is disabled by the global reduced-motion rule.
|
|
13
|
+
*/
|
|
14
|
+
export function tabIndicator(list: HTMLElement): () => void {
|
|
15
|
+
const pill = document.createElement('span');
|
|
16
|
+
pill.className = 'stor-tab-indicator';
|
|
17
|
+
pill.setAttribute('aria-hidden', 'true');
|
|
18
|
+
pill.setAttribute('role', 'presentation');
|
|
19
|
+
list.insertBefore(pill, list.firstChild);
|
|
20
|
+
|
|
21
|
+
let frame = 0;
|
|
22
|
+
let destroyed = false;
|
|
23
|
+
|
|
24
|
+
const place = (): boolean => {
|
|
25
|
+
const selected = list.querySelector<HTMLElement>(SELECTED_TAB);
|
|
26
|
+
if (!selected) return false;
|
|
27
|
+
const listRect = list.getBoundingClientRect();
|
|
28
|
+
const selectedRect = selected.getBoundingClientRect();
|
|
29
|
+
if (selectedRect.width === 0) return false;
|
|
30
|
+
pill.style.setProperty(
|
|
31
|
+
'--stor-tab-x',
|
|
32
|
+
`${selectedRect.left - listRect.left + list.scrollLeft - list.clientLeft}px`,
|
|
33
|
+
);
|
|
34
|
+
pill.style.setProperty('--stor-tab-w', `${selectedRect.width}px`);
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const update = (): void => {
|
|
39
|
+
if (!place()) {
|
|
40
|
+
list.classList.remove('stor-tabs-sliding');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
list.classList.add('stor-tabs-sliding', 'stor-tabs-ready');
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const schedule = (): void => {
|
|
47
|
+
if (destroyed) return;
|
|
48
|
+
cancelAnimationFrame(frame);
|
|
49
|
+
frame = requestAnimationFrame(update);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const sizes = new ResizeObserver(schedule);
|
|
53
|
+
const observeTabs = (): void => {
|
|
54
|
+
sizes.disconnect();
|
|
55
|
+
sizes.observe(list);
|
|
56
|
+
for (const child of list.children) {
|
|
57
|
+
if (child !== pill) sizes.observe(child);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const mutations = new MutationObserver(() => {
|
|
62
|
+
observeTabs();
|
|
63
|
+
update();
|
|
64
|
+
});
|
|
65
|
+
mutations.observe(list, {
|
|
66
|
+
attributes: true,
|
|
67
|
+
childList: true,
|
|
68
|
+
subtree: true,
|
|
69
|
+
attributeFilter: ['data-state', 'aria-selected', 'aria-current'],
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
observeTabs();
|
|
73
|
+
update();
|
|
74
|
+
void document.fonts.ready.then(schedule);
|
|
75
|
+
|
|
76
|
+
return () => {
|
|
77
|
+
destroyed = true;
|
|
78
|
+
cancelAnimationFrame(frame);
|
|
79
|
+
sizes.disconnect();
|
|
80
|
+
mutations.disconnect();
|
|
81
|
+
pill.remove();
|
|
82
|
+
list.classList.remove('stor-tabs-sliding', 'stor-tabs-ready');
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { derived, get } from 'svelte/store';
|
|
2
|
+
import { workspaceReadiness } from './readiness.js';
|
|
3
|
+
|
|
4
|
+
export interface CommandAvailability {
|
|
5
|
+
available: boolean;
|
|
6
|
+
unavailableReason: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const commandAvailability = derived(workspaceReadiness, (workspace): CommandAvailability => ({
|
|
10
|
+
available: workspace.ready,
|
|
11
|
+
unavailableReason: workspace.ready ? null : 'Wait for local data to open.',
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
export function runCommandAction(action: () => void): boolean {
|
|
15
|
+
if (!get(commandAvailability).available) return false;
|
|
16
|
+
action();
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function commandActionsAvailable(): boolean {
|
|
21
|
+
return get(commandAvailability).available;
|
|
22
|
+
}
|