@viniaraujo68/plinth 0.3.1 → 0.6.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/README.md +197 -19
- package/dist/components/DateRangePicker.svelte +93 -57
- package/dist/components/DateRangePicker.svelte.d.ts +32 -15
- package/dist/components/ErrorDisplay.svelte +30 -16
- package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
- package/dist/components/JsonView.svelte +43 -0
- package/dist/components/JsonView.svelte.d.ts +4 -0
- package/dist/components/JsonViewFrame.svelte +654 -0
- package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
- package/dist/components/Paginator.svelte +34 -8
- package/dist/components/Paginator.svelte.d.ts +10 -0
- package/dist/components/RequestErrorDisplay.svelte +43 -0
- package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
- package/dist/components/Tabs.svelte +204 -0
- package/dist/components/Tabs.svelte.d.ts +76 -0
- package/dist/components/VirtualJsonView.svelte +67 -0
- package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
- package/dist/components/date-range.d.ts +82 -14
- package/dist/components/date-range.js +86 -5
- package/dist/components/index.d.ts +9 -2
- package/dist/components/index.js +15 -9
- package/dist/components/json-view.d.ts +70 -0
- package/dist/components/json-view.js +302 -0
- package/dist/components/request-error.d.ts +8 -0
- package/dist/components/request-error.js +55 -0
- package/dist/components/tabs.d.ts +5 -0
- package/dist/components/tabs.js +1 -0
- package/dist/http.d.ts +10 -5
- package/dist/http.js +34 -6
- package/dist/i18n/LocaleToggle.svelte +45 -0
- package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
- package/dist/i18n/i18n.svelte.d.ts +56 -0
- package/dist/i18n/i18n.svelte.js +94 -0
- package/dist/i18n/index.d.ts +3 -0
- package/dist/i18n/index.js +7 -0
- package/dist/i18n/translate.d.ts +30 -0
- package/dist/i18n/translate.js +38 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/redirect.d.ts +40 -0
- package/dist/redirect.js +59 -0
- package/dist/routing.svelte.d.ts +1 -0
- package/dist/routing.svelte.js +3 -0
- package/dist/shell/AppShell.svelte +265 -93
- package/dist/shell/AppShell.svelte.d.ts +3 -2
- package/dist/shell/NavigationProgress.svelte +85 -0
- package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
- package/dist/shell/TopBar.svelte +191 -0
- package/dist/shell/TopBar.svelte.d.ts +41 -0
- package/dist/shell/UserAvatar.svelte +50 -0
- package/dist/shell/UserAvatar.svelte.d.ts +14 -0
- package/dist/shell/index.d.ts +2 -0
- package/dist/shell/index.js +7 -3
- package/dist/shell/initials.d.ts +6 -0
- package/dist/shell/initials.js +13 -0
- package/dist/table/DataTable.svelte +199 -9
- package/dist/table/DataTable.svelte.d.ts +28 -5
- package/dist/table/FilterBar.svelte +417 -0
- package/dist/table/FilterBar.svelte.d.ts +34 -0
- package/dist/table/FilterRuleEditor.svelte +290 -0
- package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
- package/dist/table/filter-labels.d.ts +23 -0
- package/dist/table/filter-labels.js +30 -0
- package/dist/table/filter.d.ts +27 -0
- package/dist/table/filter.js +279 -0
- package/dist/table/index.d.ts +4 -1
- package/dist/table/index.js +5 -2
- package/dist/table/types.d.ts +49 -0
- package/dist/toast/Toaster.svelte +7 -3
- package/dist/toast/index.d.ts +1 -1
- package/dist/toast/toast-manager.svelte.d.ts +29 -1
- package/dist/toast/toast-manager.svelte.js +61 -1
- package/dist/url-state/codec.d.ts +15 -0
- package/dist/url-state/codec.js +1 -0
- package/dist/url-state/index.d.ts +5 -0
- package/dist/url-state/index.js +4 -0
- package/dist/url-state/params-codec.d.ts +73 -0
- package/dist/url-state/params-codec.js +116 -0
- package/dist/url-state/search-params.d.ts +4 -0
- package/dist/url-state/search-params.js +28 -0
- package/dist/url-state/synced-state.svelte.d.ts +20 -0
- package/dist/url-state/synced-state.svelte.js +88 -0
- package/dist/url-state/url-store.svelte.d.ts +12 -0
- package/dist/url-state/url-store.svelte.js +56 -0
- package/dist/virtual/DataList.svelte +215 -0
- package/dist/virtual/DataList.svelte.d.ts +52 -0
- package/dist/virtual/VirtualList.svelte +150 -0
- package/dist/virtual/VirtualList.svelte.d.ts +42 -0
- package/dist/virtual/index.d.ts +7 -0
- package/dist/virtual/index.js +6 -0
- package/dist/virtual/keyed-cache.d.ts +7 -0
- package/dist/virtual/keyed-cache.js +20 -0
- package/dist/virtual/labels.d.ts +12 -0
- package/dist/virtual/labels.js +6 -0
- package/dist/virtual/paging.d.ts +5 -0
- package/dist/virtual/paging.js +11 -0
- package/dist/virtual/range.d.ts +9 -0
- package/dist/virtual/range.js +31 -0
- package/dist/virtual/size-tree.d.ts +10 -0
- package/dist/virtual/size-tree.js +56 -0
- package/dist/virtual/virtualizer.svelte.d.ts +38 -0
- package/dist/virtual/virtualizer.svelte.js +295 -0
- package/dist/virtual/windowed-data.svelte.d.ts +38 -0
- package/dist/virtual/windowed-data.svelte.js +139 -0
- package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
- package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
- package/package.json +13 -1
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface PaginatorLabels {
|
|
3
|
+
navigation: string;
|
|
4
|
+
previous: string;
|
|
5
|
+
next: string;
|
|
6
|
+
page: (page: number) => string;
|
|
7
|
+
pageSize: string;
|
|
8
|
+
summary: (first: number, last: number, total: number) => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_PAGINATOR_LABELS: PaginatorLabels = {
|
|
12
|
+
navigation: "Pagination",
|
|
13
|
+
previous: "Previous page",
|
|
14
|
+
next: "Next page",
|
|
15
|
+
page: (page) => `Page ${page}`,
|
|
16
|
+
pageSize: "Items per page",
|
|
17
|
+
summary: (first, last, total) => `Showing ${first} to ${last} of ${total} items`,
|
|
18
|
+
};
|
|
19
|
+
</script>
|
|
20
|
+
|
|
1
21
|
<script lang="ts">
|
|
2
22
|
import type { ClassValue } from "svelte/elements";
|
|
3
23
|
|
|
@@ -32,6 +52,7 @@
|
|
|
32
52
|
* and the reset back to page 1, since a new size invalidates the old page arithmetic.
|
|
33
53
|
*/
|
|
34
54
|
onPageSizeChange?: (size: number) => void;
|
|
55
|
+
labels?: Partial<PaginatorLabels>;
|
|
35
56
|
} & (CountedPages | UncountedPages);
|
|
36
57
|
|
|
37
58
|
let {
|
|
@@ -44,8 +65,11 @@
|
|
|
44
65
|
totalPages,
|
|
45
66
|
totalItems,
|
|
46
67
|
hasNextPage,
|
|
68
|
+
labels,
|
|
47
69
|
}: Props = $props();
|
|
48
70
|
|
|
71
|
+
const text = $derived({ ...DEFAULT_PAGINATOR_LABELS, ...labels });
|
|
72
|
+
|
|
49
73
|
const range = (start: number, end: number): number[] =>
|
|
50
74
|
Array.from({ length: Math.max(0, end - start + 1) }, (_, i) => start + i);
|
|
51
75
|
|
|
@@ -123,7 +147,7 @@ that means. Sizing is in `em`, so the whole strip scales with the font size arou
|
|
|
123
147
|
class="btn h-[1.75em] min-w-[1.75em] px-2 tabular-nums btn-primary aria-[current=false]:btn-ghost"
|
|
124
148
|
onclick={() => setPage(page)}
|
|
125
149
|
aria-current={page === pageNumber}
|
|
126
|
-
aria-label={
|
|
150
|
+
aria-label={text.page(page)}
|
|
127
151
|
>
|
|
128
152
|
{page}
|
|
129
153
|
</button>
|
|
@@ -133,7 +157,7 @@ that means. Sizing is in `em`, so the whole strip scales with the font size arou
|
|
|
133
157
|
<button
|
|
134
158
|
type="button"
|
|
135
159
|
class="btn size-[1.75em] btn-ghost px-1"
|
|
136
|
-
aria-label=
|
|
160
|
+
aria-label={text.previous}
|
|
137
161
|
disabled={pageNumber <= 1}
|
|
138
162
|
onclick={() => setPage(pageNumber - 1)}
|
|
139
163
|
>
|
|
@@ -153,7 +177,7 @@ that means. Sizing is in `em`, so the whole strip scales with the font size arou
|
|
|
153
177
|
<button
|
|
154
178
|
type="button"
|
|
155
179
|
class="btn size-[1.75em] btn-ghost px-1"
|
|
156
|
-
aria-label=
|
|
180
|
+
aria-label={text.next}
|
|
157
181
|
disabled={!canGoNext}
|
|
158
182
|
onclick={() => setPage(pageNumber + 1)}
|
|
159
183
|
>
|
|
@@ -162,19 +186,19 @@ that means. Sizing is in `em`, so the whole strip scales with the font size arou
|
|
|
162
186
|
{/snippet}
|
|
163
187
|
|
|
164
188
|
{#if totalItems === undefined || pageSize === undefined}
|
|
165
|
-
<nav class={["flex place-content-center gap-px", cls]} aria-label=
|
|
189
|
+
<nav class={["flex place-content-center gap-px", cls]} aria-label={text.navigation}>
|
|
166
190
|
{@render pageSelector()}
|
|
167
191
|
</nav>
|
|
168
192
|
{:else}
|
|
169
|
-
<nav class={["@container", cls]} aria-label=
|
|
193
|
+
<nav class={["@container", cls]} aria-label={text.navigation}>
|
|
170
194
|
<div class="grid items-center justify-items-center gap-1 @min-[45rem]:grid-cols-3">
|
|
171
195
|
<!-- Narrow, the summary and the size picker fall below the buttons: the control the user
|
|
172
196
|
came for stays at the top of the block rather than being pushed off by chrome. -->
|
|
173
197
|
<div class="flex items-center gap-1 @max-[45rem]:order-last @min-[45rem]:mr-auto">
|
|
174
|
-
<span class="text-nowrap">
|
|
198
|
+
<span class="text-nowrap">{text.pageSize}:</span>
|
|
175
199
|
<select
|
|
176
200
|
class="select box-content w-[3ch] select-xs"
|
|
177
|
-
aria-label=
|
|
201
|
+
aria-label={text.pageSize}
|
|
178
202
|
value={pageSize}
|
|
179
203
|
onchange={({ currentTarget }) => onPageSizeChange?.(Number(currentTarget.value))}
|
|
180
204
|
>
|
|
@@ -185,7 +209,9 @@ that means. Sizing is in `em`, so the whole strip scales with the font size arou
|
|
|
185
209
|
</div>
|
|
186
210
|
|
|
187
211
|
<div class="text-center tabular-nums" data-testid="paginator-summary">
|
|
188
|
-
|
|
212
|
+
{#if firstShown !== undefined && lastShown !== undefined}
|
|
213
|
+
{text.summary(firstShown, lastShown, totalItems)}
|
|
214
|
+
{/if}
|
|
189
215
|
</div>
|
|
190
216
|
|
|
191
217
|
<div class="flex items-center gap-px @max-[45rem]:order-first @min-[45rem]:ml-auto">
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
export interface PaginatorLabels {
|
|
2
|
+
navigation: string;
|
|
3
|
+
previous: string;
|
|
4
|
+
next: string;
|
|
5
|
+
page: (page: number) => string;
|
|
6
|
+
pageSize: string;
|
|
7
|
+
summary: (first: number, last: number, total: number) => string;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_PAGINATOR_LABELS: PaginatorLabels;
|
|
1
10
|
import type { ClassValue } from "svelte/elements";
|
|
2
11
|
/** The server answered with a count: the last page is reachable and the summary is exact. */
|
|
3
12
|
interface CountedPages {
|
|
@@ -28,6 +37,7 @@ type Props = {
|
|
|
28
37
|
* and the reset back to page 1, since a new size invalidates the old page arithmetic.
|
|
29
38
|
*/
|
|
30
39
|
onPageSizeChange?: (size: number) => void;
|
|
40
|
+
labels?: Partial<PaginatorLabels>;
|
|
31
41
|
} & (CountedPages | UncountedPages);
|
|
32
42
|
/**
|
|
33
43
|
* Page selector for a list, in two flavours picked by which props are passed: `totalPages`
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { classifyError } from "../http.js";
|
|
3
|
+
import ErrorDisplay from "./ErrorDisplay.svelte";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_REQUEST_ERROR_COPY,
|
|
6
|
+
REQUEST_ERROR_ICONS,
|
|
7
|
+
type RequestErrorLabels,
|
|
8
|
+
} from "./request-error.js";
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
error: unknown;
|
|
12
|
+
reset?: () => void;
|
|
13
|
+
labels?: RequestErrorLabels;
|
|
14
|
+
eyebrow?: string;
|
|
15
|
+
retryLabel?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let { error, reset, labels, eyebrow = "Error", retryLabel = "Try again" }: Props = $props();
|
|
19
|
+
|
|
20
|
+
const kind = $derived(classifyError(error));
|
|
21
|
+
const copy = $derived({ ...DEFAULT_REQUEST_ERROR_COPY[kind], ...labels?.[kind] });
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<div class="contents" data-kind={kind}>
|
|
25
|
+
<ErrorDisplay {reset} title={copy.title} message={copy.message} {eyebrow} {retryLabel}>
|
|
26
|
+
{#snippet icon()}
|
|
27
|
+
<svg
|
|
28
|
+
class="size-12 text-base-content/40"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
fill="none"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
stroke-width="1.5"
|
|
33
|
+
stroke-linecap="round"
|
|
34
|
+
stroke-linejoin="round"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
>
|
|
37
|
+
{#each REQUEST_ERROR_ICONS[kind] as d (d)}
|
|
38
|
+
<path {d} />
|
|
39
|
+
{/each}
|
|
40
|
+
</svg>
|
|
41
|
+
{/snippet}
|
|
42
|
+
</ErrorDisplay>
|
|
43
|
+
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type RequestErrorLabels } from "./request-error.js";
|
|
2
|
+
interface Props {
|
|
3
|
+
error: unknown;
|
|
4
|
+
reset?: () => void;
|
|
5
|
+
labels?: RequestErrorLabels;
|
|
6
|
+
eyebrow?: string;
|
|
7
|
+
retryLabel?: string;
|
|
8
|
+
}
|
|
9
|
+
declare const RequestErrorDisplay: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type RequestErrorDisplay = ReturnType<typeof RequestErrorDisplay>;
|
|
11
|
+
export default RequestErrorDisplay;
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
<script lang="ts" generics="Id extends string">
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import type { TabItem } from "./tabs.js";
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
/** The tabs, in the order they are shown. `id` is the value, so it has to be unique. */
|
|
8
|
+
tabs: readonly TabItem<Id>[];
|
|
9
|
+
/** The selected tab's `id`. Bindable, and also reported through {@link Props.onchange}. */
|
|
10
|
+
value: Id;
|
|
11
|
+
/** Accessible name of the tab list. A prop because the library ships no translations. */
|
|
12
|
+
label: string;
|
|
13
|
+
/**
|
|
14
|
+
* The `id` of the panel the tabs drive. One panel whose content changes with the selection,
|
|
15
|
+
* which is how a page with a tab per section is usually built; leave it out when there is no
|
|
16
|
+
* single panel to point at.
|
|
17
|
+
*/
|
|
18
|
+
controls?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Prefix of each tab's element id: the tab for `ranking` is `${idPrefix}-ranking`, which is
|
|
21
|
+
* what the panel's `aria-labelledby` points at. Unique per instance by default; pass one when
|
|
22
|
+
* the panel has to name its tab.
|
|
23
|
+
*/
|
|
24
|
+
idPrefix?: string;
|
|
25
|
+
/** Centres the tabs while they fit. Once they overflow they scroll from the first one. */
|
|
26
|
+
center?: boolean;
|
|
27
|
+
/** Classes for the wrapper — spacing, mostly. */
|
|
28
|
+
class?: ClassValue;
|
|
29
|
+
/** Called with the new `id` only when it actually changes; re-picking the current tab is silent. */
|
|
30
|
+
onchange?: (id: Id) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const uid = $props.id();
|
|
34
|
+
|
|
35
|
+
let {
|
|
36
|
+
tabs,
|
|
37
|
+
value = $bindable(),
|
|
38
|
+
label,
|
|
39
|
+
controls,
|
|
40
|
+
idPrefix = uid,
|
|
41
|
+
center = false,
|
|
42
|
+
class: className,
|
|
43
|
+
onchange,
|
|
44
|
+
}: Props = $props();
|
|
45
|
+
|
|
46
|
+
const tabId = (id: Id) => `${idPrefix}-${id}`;
|
|
47
|
+
|
|
48
|
+
const select = (id: Id) => {
|
|
49
|
+
if (id === value) return;
|
|
50
|
+
value = id;
|
|
51
|
+
onchange?.(id);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const STEPS: Record<string, (from: number, count: number) => number> = {
|
|
55
|
+
ArrowRight: (from, count) => (from + 1) % count,
|
|
56
|
+
ArrowLeft: (from, count) => (from - 1 + count) % count,
|
|
57
|
+
Home: () => 0,
|
|
58
|
+
End: (_, count) => count - 1,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const onkeydown = async (event: KeyboardEvent) => {
|
|
62
|
+
const step = STEPS[event.key];
|
|
63
|
+
if (!step || tabs.length === 0) return;
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
const from = Math.max(
|
|
66
|
+
tabs.findIndex((tab) => tab.id === value),
|
|
67
|
+
0,
|
|
68
|
+
);
|
|
69
|
+
const id = tabs[step(from, tabs.length)].id;
|
|
70
|
+
select(id);
|
|
71
|
+
// Selection follows focus, and a caller that keeps the selection in the URL navigates on
|
|
72
|
+
// `onchange` — which leaves the focus wherever it was. Walk it to the tab that is now selected.
|
|
73
|
+
await tick();
|
|
74
|
+
document.getElementById(tabId(id))?.focus();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
let strip = $state<HTMLElement>();
|
|
78
|
+
let overflowing = $state(false);
|
|
79
|
+
|
|
80
|
+
const measure = () => {
|
|
81
|
+
if (!strip) return;
|
|
82
|
+
overflowing = strip.scrollWidth - strip.clientWidth - strip.scrollLeft > 4;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
$effect(() => {
|
|
86
|
+
// Re-measured whenever the labels change — a language switch can push the last tab out.
|
|
87
|
+
void tabs.map((tab) => tab.label);
|
|
88
|
+
measure();
|
|
89
|
+
});
|
|
90
|
+
</script>
|
|
91
|
+
|
|
92
|
+
<!--
|
|
93
|
+
@component
|
|
94
|
+
A row of tabs that switches what one panel shows: a real `tablist` with a roving tabindex, so Tab
|
|
95
|
+
lands on the selected tab and the arrow keys, Home and End move the selection along the row. The
|
|
96
|
+
selection follows focus, the pattern for panels that are cheap to show.
|
|
97
|
+
|
|
98
|
+
On a narrow screen the row scrolls sideways instead of wrapping, and while tabs are hidden past the
|
|
99
|
+
right edge it fades out there — at phone width, the fade is the only hint that a fifth tab exists.
|
|
100
|
+
|
|
101
|
+
The selection is the caller's: bind `value`, or pass it in and act on `onchange` — a page that
|
|
102
|
+
keeps its tab in the URL navigates there, and the tab moves when the URL does.
|
|
103
|
+
|
|
104
|
+
```svelte
|
|
105
|
+
<Tabs
|
|
106
|
+
bind:value={tab}
|
|
107
|
+
label="Sections"
|
|
108
|
+
controls="panel"
|
|
109
|
+
idPrefix="tab"
|
|
110
|
+
tabs={[
|
|
111
|
+
{ id: "overview", label: "Overview" },
|
|
112
|
+
{ id: "settings", label: "Settings" },
|
|
113
|
+
]}
|
|
114
|
+
/>
|
|
115
|
+
<div id="panel" role="tabpanel" aria-labelledby="tab-{tab}">…</div>
|
|
116
|
+
```
|
|
117
|
+
-->
|
|
118
|
+
<div class={["plinth-tabs", className]} class:fade={overflowing}>
|
|
119
|
+
<div
|
|
120
|
+
class="plinth-tabs-strip tabs tabs-border"
|
|
121
|
+
class:center
|
|
122
|
+
role="tablist"
|
|
123
|
+
aria-label={label}
|
|
124
|
+
bind:this={strip}
|
|
125
|
+
onscroll={measure}
|
|
126
|
+
>
|
|
127
|
+
{#each tabs as tab (tab.id)}
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="tab"
|
|
131
|
+
role="tab"
|
|
132
|
+
id={tabId(tab.id)}
|
|
133
|
+
aria-selected={tab.id === value}
|
|
134
|
+
aria-controls={controls}
|
|
135
|
+
tabindex={tab.id === value ? 0 : -1}
|
|
136
|
+
onclick={() => select(tab.id)}
|
|
137
|
+
{onkeydown}
|
|
138
|
+
>
|
|
139
|
+
{tab.label}
|
|
140
|
+
</button>
|
|
141
|
+
{/each}
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<svelte:window onresize={measure} />
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
.plinth-tabs {
|
|
149
|
+
position: relative;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* `display` stated here rather than left to daisyUI's `.tabs`: the one-row layout is what the
|
|
153
|
+
overflow and the fade are built on, so it cannot depend on a class the app might restyle. */
|
|
154
|
+
.plinth-tabs-strip {
|
|
155
|
+
display: flex;
|
|
156
|
+
flex-wrap: nowrap;
|
|
157
|
+
overflow-x: auto;
|
|
158
|
+
border-bottom: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
159
|
+
-webkit-overflow-scrolling: touch;
|
|
160
|
+
scrollbar-width: none;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.plinth-tabs-strip::-webkit-scrollbar {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* `safe` keeps the first tab reachable once the row overflows; plain `center` would push it off
|
|
168
|
+
the left edge, where no scroll can reach. The first declaration is the fallback. */
|
|
169
|
+
.plinth-tabs-strip.center {
|
|
170
|
+
justify-content: center;
|
|
171
|
+
justify-content: safe center;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* A mask rather than a gradient laid over the strip, so it fades into whatever surface the tabs
|
|
175
|
+
sit on. Only while something is actually hidden past the edge. */
|
|
176
|
+
.plinth-tabs.fade .plinth-tabs-strip {
|
|
177
|
+
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2.75rem), transparent);
|
|
178
|
+
mask-image: linear-gradient(to right, #000 calc(100% - 2.75rem), transparent);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.tab {
|
|
182
|
+
flex: 0 0 auto;
|
|
183
|
+
min-height: 2.75rem;
|
|
184
|
+
white-space: nowrap;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* Inside a horizontal scroller an outset ring is clipped; drawn inside, it shows whole. */
|
|
188
|
+
.tab:focus-visible {
|
|
189
|
+
outline-offset: -2px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.tab[aria-selected="true"] {
|
|
193
|
+
color: var(--color-base-content);
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.tab:not([aria-selected="true"]) {
|
|
198
|
+
color: color-mix(in oklch, var(--color-base-content) 65%, transparent);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.tab:not([aria-selected="true"]):hover {
|
|
202
|
+
color: var(--color-base-content);
|
|
203
|
+
}
|
|
204
|
+
</style>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ClassValue } from "svelte/elements";
|
|
2
|
+
import type { TabItem } from "./tabs.js";
|
|
3
|
+
declare function $$render<Id extends string>(): {
|
|
4
|
+
props: {
|
|
5
|
+
/** The tabs, in the order they are shown. `id` is the value, so it has to be unique. */
|
|
6
|
+
tabs: readonly TabItem<Id>[];
|
|
7
|
+
/** The selected tab's `id`. Bindable, and also reported through {@link Props.onchange}. */
|
|
8
|
+
value: Id;
|
|
9
|
+
/** Accessible name of the tab list. A prop because the library ships no translations. */
|
|
10
|
+
label: string;
|
|
11
|
+
/**
|
|
12
|
+
* The `id` of the panel the tabs drive. One panel whose content changes with the selection,
|
|
13
|
+
* which is how a page with a tab per section is usually built; leave it out when there is no
|
|
14
|
+
* single panel to point at.
|
|
15
|
+
*/
|
|
16
|
+
controls?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Prefix of each tab's element id: the tab for `ranking` is `${idPrefix}-ranking`, which is
|
|
19
|
+
* what the panel's `aria-labelledby` points at. Unique per instance by default; pass one when
|
|
20
|
+
* the panel has to name its tab.
|
|
21
|
+
*/
|
|
22
|
+
idPrefix?: string;
|
|
23
|
+
/** Centres the tabs while they fit. Once they overflow they scroll from the first one. */
|
|
24
|
+
center?: boolean;
|
|
25
|
+
/** Classes for the wrapper — spacing, mostly. */
|
|
26
|
+
class?: ClassValue;
|
|
27
|
+
/** Called with the new `id` only when it actually changes; re-picking the current tab is silent. */
|
|
28
|
+
onchange?: (id: Id) => void;
|
|
29
|
+
};
|
|
30
|
+
exports: {};
|
|
31
|
+
bindings: "value";
|
|
32
|
+
slots: {};
|
|
33
|
+
events: {};
|
|
34
|
+
};
|
|
35
|
+
declare class __sveltets_Render<Id extends string> {
|
|
36
|
+
props(): ReturnType<typeof $$render<Id>>['props'];
|
|
37
|
+
events(): ReturnType<typeof $$render<Id>>['events'];
|
|
38
|
+
slots(): ReturnType<typeof $$render<Id>>['slots'];
|
|
39
|
+
bindings(): "value";
|
|
40
|
+
exports(): {};
|
|
41
|
+
}
|
|
42
|
+
interface $$IsomorphicComponent {
|
|
43
|
+
new <Id extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Id>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Id>['props']>, ReturnType<__sveltets_Render<Id>['events']>, ReturnType<__sveltets_Render<Id>['slots']>> & {
|
|
44
|
+
$$bindings?: ReturnType<__sveltets_Render<Id>['bindings']>;
|
|
45
|
+
} & ReturnType<__sveltets_Render<Id>['exports']>;
|
|
46
|
+
<Id extends string>(internal: unknown, props: ReturnType<__sveltets_Render<Id>['props']> & {}): ReturnType<__sveltets_Render<Id>['exports']>;
|
|
47
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A row of tabs that switches what one panel shows: a real `tablist` with a roving tabindex, so Tab
|
|
51
|
+
* lands on the selected tab and the arrow keys, Home and End move the selection along the row. The
|
|
52
|
+
* selection follows focus, the pattern for panels that are cheap to show.
|
|
53
|
+
*
|
|
54
|
+
* On a narrow screen the row scrolls sideways instead of wrapping, and while tabs are hidden past the
|
|
55
|
+
* right edge it fades out there — at phone width, the fade is the only hint that a fifth tab exists.
|
|
56
|
+
*
|
|
57
|
+
* The selection is the caller's: bind `value`, or pass it in and act on `onchange` — a page that
|
|
58
|
+
* keeps its tab in the URL navigates there, and the tab moves when the URL does.
|
|
59
|
+
*
|
|
60
|
+
* ```svelte
|
|
61
|
+
* <Tabs
|
|
62
|
+
* bind:value={tab}
|
|
63
|
+
* label="Sections"
|
|
64
|
+
* controls="panel"
|
|
65
|
+
* idPrefix="tab"
|
|
66
|
+
* tabs={[
|
|
67
|
+
* { id: "overview", label: "Overview" },
|
|
68
|
+
* { id: "settings", label: "Settings" },
|
|
69
|
+
* ]}
|
|
70
|
+
* />
|
|
71
|
+
* <div id="panel" role="tabpanel" aria-labelledby="tab-{tab}">…</div>
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare const Tabs: $$IsomorphicComponent;
|
|
75
|
+
type Tabs<Id extends string> = InstanceType<typeof Tabs<Id>>;
|
|
76
|
+
export default Tabs;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import VirtualList from "../virtual/VirtualList.svelte";
|
|
3
|
+
import JsonViewFrame, {
|
|
4
|
+
type JsonViewProps,
|
|
5
|
+
type JsonViewRevealTarget,
|
|
6
|
+
} from "./JsonViewFrame.svelte";
|
|
7
|
+
|
|
8
|
+
const props: JsonViewProps = $props();
|
|
9
|
+
|
|
10
|
+
let list = $state<ReturnType<typeof VirtualList>>();
|
|
11
|
+
let host = $state<HTMLDivElement>();
|
|
12
|
+
let probe = $state<HTMLSpanElement>();
|
|
13
|
+
|
|
14
|
+
const reveal = ({ visibleIndex, columnCh }: JsonViewRevealTarget) => {
|
|
15
|
+
list?.scrollToIndex(visibleIndex, "center");
|
|
16
|
+
const scroller = host?.querySelector<HTMLElement>(".plinth-virtual");
|
|
17
|
+
const ch = probe?.getBoundingClientRect().width ?? 0;
|
|
18
|
+
if (!scroller || ch === 0) return;
|
|
19
|
+
const target = columnCh * ch;
|
|
20
|
+
const start = scroller.scrollLeft;
|
|
21
|
+
const end = start + scroller.clientWidth;
|
|
22
|
+
if (target < start + ch * 4 || target > end - ch * 4)
|
|
23
|
+
scroller.scrollLeft = Math.max(0, target - scroller.clientWidth / 2);
|
|
24
|
+
};
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<JsonViewFrame {...props} {reveal}>
|
|
28
|
+
{#snippet viewport({ visible, line, rowId, rowHeight, widthCh })}
|
|
29
|
+
<div bind:this={host} class="plinth-json-virtual">
|
|
30
|
+
<span bind:this={probe} class="plinth-json-probe" aria-hidden="true"></span>
|
|
31
|
+
<VirtualList
|
|
32
|
+
bind:this={list}
|
|
33
|
+
class="plinth-json-virtual-list"
|
|
34
|
+
count={visible.length}
|
|
35
|
+
itemSize={rowHeight}
|
|
36
|
+
getItemKey={(index) => rowId(visible[index])}
|
|
37
|
+
crossSize="max(100%, {widthCh + 1}ch)"
|
|
38
|
+
>
|
|
39
|
+
{#snippet item(index)}
|
|
40
|
+
{@render line(visible[index])}
|
|
41
|
+
{/snippet}
|
|
42
|
+
</VirtualList>
|
|
43
|
+
</div>
|
|
44
|
+
{/snippet}
|
|
45
|
+
</JsonViewFrame>
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.plinth-json-virtual {
|
|
49
|
+
position: relative;
|
|
50
|
+
display: flex;
|
|
51
|
+
flex: 1;
|
|
52
|
+
flex-direction: column;
|
|
53
|
+
min-height: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.plinth-json-probe {
|
|
57
|
+
position: absolute;
|
|
58
|
+
width: 1ch;
|
|
59
|
+
height: 0;
|
|
60
|
+
visibility: hidden;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
:global(.plinth-json-virtual-list) {
|
|
64
|
+
flex: 1;
|
|
65
|
+
min-height: 0;
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -1,30 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The pair a range picker emits, and the whole of what it emits.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* produces. A string rather than a `Date`
|
|
6
|
-
* body, a query string, a store, a load
|
|
7
|
-
* exactly this string anyway, while an ISO
|
|
8
|
-
* sorts correctly with a plain `<`. UTC rather
|
|
9
|
-
* different zones must be able to name the same window
|
|
4
|
+
* In the default `"datetime"` mode both ends are ISO-8601 instants in UTC
|
|
5
|
+
* (`2026-08-29T18:04:05.123Z`), the form `toISOString()` produces. A string rather than a `Date`
|
|
6
|
+
* because this value's job is to travel: into a fetch body, a query string, a store, a load
|
|
7
|
+
* function's return — all of which serialise a `Date` to exactly this string anyway, while an ISO
|
|
8
|
+
* string additionally survives the round trip back and sorts correctly with a plain `<`. UTC rather
|
|
9
|
+
* than a local offset because two clients in different zones must be able to name the same window
|
|
10
|
+
* with the same characters.
|
|
11
|
+
*
|
|
12
|
+
* In `"date"` mode both ends are calendar days, `2026-08-29` — the value of an
|
|
13
|
+
* `<input type="date">`, with no time and no zone, because a day is what the reader picked. An
|
|
14
|
+
* empty string leaves that side open: `{ from: "2026-03-01", to: "" }` is "since March", and two
|
|
15
|
+
* empty ends are all time.
|
|
10
16
|
*/
|
|
11
17
|
export interface DateRange {
|
|
12
18
|
from: string;
|
|
13
19
|
to: string;
|
|
14
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* What the picker works in: `"datetime"` for a window of instants (a dashboard's last 24 hours),
|
|
23
|
+
* `"date"` for a span of calendar days (a history filtered to last month).
|
|
24
|
+
*/
|
|
25
|
+
export type DateRangeMode = "datetime" | "date";
|
|
26
|
+
interface PresetBase {
|
|
27
|
+
/** Stable across renders — it is what the picker's selection prop holds. */
|
|
28
|
+
id: string;
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
15
31
|
/**
|
|
16
32
|
* One relative window offered as a button — a fixed duration measured back from "now".
|
|
17
33
|
*
|
|
18
34
|
* `durationMs` is a duration and not a calendar step on purpose: "30d" means 30×24h, so the
|
|
19
35
|
* window keeps its length across a DST boundary instead of silently growing or shrinking an hour
|
|
20
|
-
* twice a year. A
|
|
36
|
+
* twice a year. A window that follows the calendar — this month, last month — is a
|
|
37
|
+
* {@link CalendarPreset}.
|
|
38
|
+
*
|
|
39
|
+
* In `"date"` mode the window becomes the days it touches: from the day `durationMs` ago to today.
|
|
21
40
|
*/
|
|
22
|
-
export interface
|
|
23
|
-
/** Stable across renders — it is what the picker's selection prop holds. */
|
|
24
|
-
id: string;
|
|
25
|
-
label: string;
|
|
41
|
+
export interface DurationPreset extends PresetBase {
|
|
26
42
|
durationMs: number;
|
|
43
|
+
range?: never;
|
|
27
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* One window offered as a button and computed from the current moment — for the windows a
|
|
47
|
+
* duration cannot name: this month, last month, the last six months, all time. `range` returns the
|
|
48
|
+
* pair in the picker's mode, and runs again on every re-anchor, so "this month" turns into the
|
|
49
|
+
* next one when the month does.
|
|
50
|
+
*
|
|
51
|
+
* {@link thisMonth}, {@link lastMonth}, {@link lastMonths} and {@link allTime} build the usual
|
|
52
|
+
* ones for `"date"` mode.
|
|
53
|
+
*/
|
|
54
|
+
export interface CalendarPreset extends PresetBase {
|
|
55
|
+
range: (now: Date) => DateRange;
|
|
56
|
+
durationMs?: never;
|
|
57
|
+
}
|
|
58
|
+
export type DateRangePreset = DurationPreset | CalendarPreset;
|
|
28
59
|
/**
|
|
29
60
|
* The selection value that means "the user typed the two ends themselves".
|
|
30
61
|
*
|
|
@@ -33,8 +64,10 @@ export interface DateRangePreset {
|
|
|
33
64
|
export declare const CUSTOM_PRESET_ID = "custom";
|
|
34
65
|
/** The four windows a dashboard actually gets asked for. Replace the list, not the component. */
|
|
35
66
|
export declare const DEFAULT_PRESETS: readonly DateRangePreset[];
|
|
36
|
-
/**
|
|
37
|
-
export declare const
|
|
67
|
+
/** A local calendar day as `YYYY-MM-DD`, the value an `<input type="date">` holds. */
|
|
68
|
+
export declare const toDay: (date: Date) => string;
|
|
69
|
+
/** Builds the window a preset names as of `now`, in the picker's `mode`. */
|
|
70
|
+
export declare const buildRange: (preset: DateRangePreset, now?: Date | number, mode?: DateRangeMode) => DateRange;
|
|
38
71
|
/**
|
|
39
72
|
* Resolves a selection into the window it stands for right now, or `null` when there is nothing
|
|
40
73
|
* to build — an unknown id, or {@link CUSTOM_PRESET_ID}.
|
|
@@ -43,7 +76,41 @@ export declare const buildRange: (preset: DateRangePreset, now?: Date | number)
|
|
|
43
76
|
* range is never re-anchored" a property of the data rather than of the timer's wiring: even a
|
|
44
77
|
* tick that somehow fired while the user was in custom mode would have nothing to emit.
|
|
45
78
|
*/
|
|
46
|
-
export declare const anchorSelection: (selection: string, presets: readonly DateRangePreset[], now?: Date | number) => DateRange | null;
|
|
79
|
+
export declare const anchorSelection: (selection: string, presets: readonly DateRangePreset[], now?: Date | number, mode?: DateRangeMode) => DateRange | null;
|
|
80
|
+
/**
|
|
81
|
+
* The selection a picker handed `value` (and no `preset`) should open on: the preset whose window
|
|
82
|
+
* right now is exactly that pair, or {@link CUSTOM_PRESET_ID}. A page that remounts its picker —
|
|
83
|
+
* one per tab, say — gets its "Last month" button back instead of a custom pair spelling it out.
|
|
84
|
+
*
|
|
85
|
+
* A duration preset in `"datetime"` mode only matches to the millisecond, so in practice a seeded
|
|
86
|
+
* instant pair still opens as custom, as it always did.
|
|
87
|
+
*/
|
|
88
|
+
export declare const selectionFor: (value: DateRange, presets: readonly DateRangePreset[], now?: Date | number, mode?: DateRangeMode) => string;
|
|
89
|
+
/**
|
|
90
|
+
* Whether a pair of days is in order. An empty end leaves that side open, so it can never put the
|
|
91
|
+
* pair out of order.
|
|
92
|
+
*/
|
|
93
|
+
export declare const isValidDayRange: (from: string, to: string) => boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Reads an `<input type="date">` value back: the day itself, `""` for an empty field — no bound on
|
|
96
|
+
* that side — or `null` for anything that is not yet a real day.
|
|
97
|
+
*
|
|
98
|
+
* A year below 1000 counts as not yet a day. Chrome reports a value on every digit of the year as
|
|
99
|
+
* it is typed — `0002-03-01`, `0020-03-01`, `0202-03-01` — and each of those would otherwise be
|
|
100
|
+
* emitted, and fetched, on the way to `2026`.
|
|
101
|
+
*/
|
|
102
|
+
export declare const parseDay: (value: string | null | undefined) => string | null;
|
|
103
|
+
/** "This month", for `"date"` mode: from the 1st, open at the end. */
|
|
104
|
+
export declare const thisMonth: (label: string, id?: string) => CalendarPreset;
|
|
105
|
+
/** "Last month", for `"date"` mode: its first day to its last. */
|
|
106
|
+
export declare const lastMonth: (label: string, id?: string) => CalendarPreset;
|
|
107
|
+
/**
|
|
108
|
+
* The last `months` months, for `"date"` mode: from the same day that many months ago — or the
|
|
109
|
+
* last day that month has — open at the end.
|
|
110
|
+
*/
|
|
111
|
+
export declare const lastMonths: (months: number, label: string, id?: string) => CalendarPreset;
|
|
112
|
+
/** "All time", for `"date"` mode: both ends open. */
|
|
113
|
+
export declare const allTime: (label: string, id?: string) => CalendarPreset;
|
|
47
114
|
/** Whether the pair is in order. An empty end is not a range, so it is not valid either. */
|
|
48
115
|
export declare const isValidRange: (from: string | null, to: string | null) => boolean;
|
|
49
116
|
/**
|
|
@@ -99,3 +166,4 @@ export declare const createReAnchor: (options: {
|
|
|
99
166
|
intervalMs: number;
|
|
100
167
|
onAnchor: () => void;
|
|
101
168
|
}) => ReAnchor;
|
|
169
|
+
export {};
|