@ternkit/tern 1.3.0 → 1.4.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/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/runtime/validate.d.ts +31 -0
- package/dist/runtime/validate.js +168 -0
- package/dist/server/intl.js +55 -12
- package/dist/ui/AlertDialog.svelte +79 -0
- package/dist/ui/AlertDialog.svelte.d.ts +24 -0
- package/dist/ui/AreaChart.svelte +9 -0
- package/dist/ui/AreaChart.svelte.d.ts +26 -0
- package/dist/ui/AspectRatio.svelte +33 -0
- package/dist/ui/AspectRatio.svelte.d.ts +10 -0
- package/dist/ui/BarChart.svelte +170 -0
- package/dist/ui/BarChart.svelte.d.ts +40 -0
- package/dist/ui/Breadcrumb.svelte +92 -0
- package/dist/ui/Breadcrumb.svelte.d.ts +14 -0
- package/dist/ui/ButtonGroup.svelte +61 -0
- package/dist/ui/ButtonGroup.svelte.d.ts +12 -0
- package/dist/ui/Calendar.svelte +265 -0
- package/dist/ui/Calendar.svelte.d.ts +28 -0
- package/dist/ui/Carousel.svelte +169 -0
- package/dist/ui/Carousel.svelte.d.ts +36 -0
- package/dist/ui/ChartFrame.svelte +174 -0
- package/dist/ui/ChartFrame.svelte.d.ts +24 -0
- package/dist/ui/Checkbox.svelte +13 -1
- package/dist/ui/Collapsible.svelte +59 -0
- package/dist/ui/Collapsible.svelte.d.ts +12 -0
- package/dist/ui/Combobox.svelte +350 -0
- package/dist/ui/Combobox.svelte.d.ts +24 -0
- package/dist/ui/Command.svelte +268 -0
- package/dist/ui/Command.svelte.d.ts +28 -0
- package/dist/ui/ConsentScreen.svelte +125 -0
- package/dist/ui/ConsentScreen.svelte.d.ts +21 -0
- package/dist/ui/ContextMenu.svelte +78 -0
- package/dist/ui/ContextMenu.svelte.d.ts +13 -0
- package/dist/ui/DataTable.svelte +277 -0
- package/dist/ui/DataTable.svelte.d.ts +53 -0
- package/dist/ui/DatePicker.svelte +138 -0
- package/dist/ui/DatePicker.svelte.d.ts +25 -0
- package/dist/ui/Dialog.svelte +168 -27
- package/dist/ui/Dialog.svelte.d.ts +10 -0
- package/dist/ui/DonutChart.svelte +115 -0
- package/dist/ui/DonutChart.svelte.d.ts +40 -0
- package/dist/ui/Drawer.svelte +19 -0
- package/dist/ui/Drawer.svelte.d.ts +14 -0
- package/dist/ui/Empty.svelte +62 -0
- package/dist/ui/Empty.svelte.d.ts +15 -0
- package/dist/ui/Field.svelte +22 -2
- package/dist/ui/Field.svelte.d.ts +2 -0
- package/dist/ui/Form.svelte +159 -0
- package/dist/ui/Form.svelte.d.ts +21 -0
- package/dist/ui/HoverCard.svelte +106 -0
- package/dist/ui/HoverCard.svelte.d.ts +26 -0
- package/dist/ui/Input.svelte +2 -1
- package/dist/ui/InputGroup.svelte +80 -0
- package/dist/ui/InputGroup.svelte.d.ts +13 -0
- package/dist/ui/InputOTP.svelte +140 -0
- package/dist/ui/InputOTP.svelte.d.ts +18 -0
- package/dist/ui/Item.svelte +86 -0
- package/dist/ui/Item.svelte.d.ts +17 -0
- package/dist/ui/Label.svelte +29 -0
- package/dist/ui/Label.svelte.d.ts +10 -0
- package/dist/ui/LineChart.svelte +171 -0
- package/dist/ui/LineChart.svelte.d.ts +42 -0
- package/dist/ui/Menu.svelte +22 -151
- package/dist/ui/Menu.svelte.d.ts +3 -10
- package/dist/ui/MenuList.svelte +317 -0
- package/dist/ui/MenuList.svelte.d.ts +29 -0
- package/dist/ui/Menubar.svelte +133 -0
- package/dist/ui/Menubar.svelte.d.ts +14 -0
- package/dist/ui/NavigationMenu.svelte +208 -0
- package/dist/ui/NavigationMenu.svelte.d.ts +18 -0
- package/dist/ui/Pagination.svelte +114 -0
- package/dist/ui/Pagination.svelte.d.ts +16 -0
- package/dist/ui/PasswordInput.svelte +134 -0
- package/dist/ui/PasswordInput.svelte.d.ts +11 -0
- package/dist/ui/Popover.svelte +127 -0
- package/dist/ui/Popover.svelte.d.ts +29 -0
- package/dist/ui/Resizable.svelte +122 -0
- package/dist/ui/Resizable.svelte.d.ts +16 -0
- package/dist/ui/ScrollArea.svelte +43 -0
- package/dist/ui/ScrollArea.svelte.d.ts +13 -0
- package/dist/ui/Select.svelte +3 -2
- package/dist/ui/SelectMenu.svelte +301 -0
- package/dist/ui/SelectMenu.svelte.d.ts +18 -0
- package/dist/ui/Sheet.svelte +21 -0
- package/dist/ui/Sheet.svelte.d.ts +16 -0
- package/dist/ui/Sidebar.svelte +263 -0
- package/dist/ui/Sidebar.svelte.d.ts +35 -0
- package/dist/ui/SignIn.svelte +182 -0
- package/dist/ui/SignIn.svelte.d.ts +38 -0
- package/dist/ui/SignUp.svelte +106 -0
- package/dist/ui/SignUp.svelte.d.ts +23 -0
- package/dist/ui/Slider.svelte +196 -0
- package/dist/ui/Slider.svelte.d.ts +19 -0
- package/dist/ui/Sparkline.svelte +23 -0
- package/dist/ui/Sparkline.svelte.d.ts +11 -0
- package/dist/ui/Stat.svelte +91 -0
- package/dist/ui/Stat.svelte.d.ts +18 -0
- package/dist/ui/Table.svelte +70 -0
- package/dist/ui/Table.svelte.d.ts +36 -0
- package/dist/ui/Textarea.svelte +2 -1
- package/dist/ui/Toggle.svelte +82 -0
- package/dist/ui/Toggle.svelte.d.ts +17 -0
- package/dist/ui/ToggleGroup.svelte +143 -0
- package/dist/ui/ToggleGroup.svelte.d.ts +27 -0
- package/dist/ui/Tooltip.svelte +27 -19
- package/dist/ui/Tooltip.svelte.d.ts +2 -1
- package/dist/ui/chart.d.ts +26 -0
- package/dist/ui/chart.js +89 -0
- package/dist/ui/dates.d.ts +13 -0
- package/dist/ui/dates.js +64 -0
- package/dist/ui/field.d.ts +8 -0
- package/dist/ui/floating.d.ts +44 -0
- package/dist/ui/floating.js +97 -0
- package/dist/ui/form.d.ts +12 -0
- package/dist/ui/form.js +8 -0
- package/dist/ui/index.d.ts +54 -0
- package/dist/ui/index.js +43 -0
- package/dist/ui/menu.d.ts +25 -0
- package/dist/ui/menu.js +1 -0
- package/dist/ui/options.d.ts +17 -0
- package/dist/ui/options.js +22 -0
- package/dist/ui/table.d.ts +26 -0
- package/dist/ui/table.js +1 -0
- package/dist/ui/theme.css +25 -0
- package/dist/vite/index.js +10 -2
- package/package.json +1 -1
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Avatar from './Avatar.svelte';
|
|
3
|
+
import Button from './Button.svelte';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** From plugins/oidc: provider.ConsentRequest(ctx), returned by the page's load. */
|
|
7
|
+
consent: { client: string; scopes: string[]; action: string; params: Record<string, string> };
|
|
8
|
+
/** Who is signed in, so they can tell they're approving as the right account. */
|
|
9
|
+
user?: { name: string; email?: string };
|
|
10
|
+
/** Where to switch accounts. */
|
|
11
|
+
switchHref?: string;
|
|
12
|
+
level?: 1 | 2 | 3;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
let { consent, user, switchHref, level = 1, class: klass = '' }: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<!-- Asks the signed-in user to let another app use their account. The form posts back to plugins/oidc, which checks the request again. -->
|
|
19
|
+
<section class="tern-consent {klass}" aria-labelledby="tern-consent-title">
|
|
20
|
+
<svelte:element this={`h${level}`} id="tern-consent-title">{consent.client} wants to access your account</svelte:element>
|
|
21
|
+
{#if user}
|
|
22
|
+
<div class="user">
|
|
23
|
+
<Avatar name={user.name} size="2rem" />
|
|
24
|
+
<div>
|
|
25
|
+
<div class="name">{user.name}</div>
|
|
26
|
+
{#if user.email}<div class="email">{user.email}</div>{/if}
|
|
27
|
+
</div>
|
|
28
|
+
{#if switchHref}<a href={switchHref}>Not you?</a>{/if}
|
|
29
|
+
</div>
|
|
30
|
+
{/if}
|
|
31
|
+
<div>
|
|
32
|
+
<p id="tern-consent-scopes">It will be able to:</p>
|
|
33
|
+
<ul aria-labelledby="tern-consent-scopes">
|
|
34
|
+
{#each consent.scopes as scope (scope)}
|
|
35
|
+
<li>
|
|
36
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
|
|
37
|
+
{scope}
|
|
38
|
+
</li>
|
|
39
|
+
{/each}
|
|
40
|
+
</ul>
|
|
41
|
+
</div>
|
|
42
|
+
<p class="note">You can take this back at any time in your account settings.</p>
|
|
43
|
+
<form method="POST" action={consent.action}>
|
|
44
|
+
{#each Object.entries(consent.params) as [k, v] (k)}<input type="hidden" name={k} value={v} />{/each}
|
|
45
|
+
<Button type="submit" name="decision" value="deny" variant="secondary">Deny</Button>
|
|
46
|
+
<Button type="submit" name="decision" value="allow">Allow</Button>
|
|
47
|
+
</form>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<style>
|
|
51
|
+
.tern-consent {
|
|
52
|
+
display: grid;
|
|
53
|
+
gap: 1.25rem;
|
|
54
|
+
width: 100%;
|
|
55
|
+
max-width: 26rem;
|
|
56
|
+
box-sizing: border-box;
|
|
57
|
+
padding: 2rem;
|
|
58
|
+
color: var(--tern-fg);
|
|
59
|
+
background: var(--tern-surface);
|
|
60
|
+
border: 1px solid var(--tern-border);
|
|
61
|
+
border-radius: calc(var(--tern-radius) + 4px);
|
|
62
|
+
box-shadow: var(--tern-shadow);
|
|
63
|
+
}
|
|
64
|
+
:is(h1, h2, h3) {
|
|
65
|
+
margin: 0;
|
|
66
|
+
font-size: 1.25rem;
|
|
67
|
+
font-weight: 650;
|
|
68
|
+
letter-spacing: -0.01em;
|
|
69
|
+
}
|
|
70
|
+
.user {
|
|
71
|
+
display: flex;
|
|
72
|
+
align-items: center;
|
|
73
|
+
gap: 0.75rem;
|
|
74
|
+
padding: 0.75rem;
|
|
75
|
+
background: var(--tern-surface-2);
|
|
76
|
+
border-radius: var(--tern-radius);
|
|
77
|
+
}
|
|
78
|
+
.user > div {
|
|
79
|
+
flex: 1;
|
|
80
|
+
min-width: 0;
|
|
81
|
+
}
|
|
82
|
+
.name {
|
|
83
|
+
font-weight: 600;
|
|
84
|
+
font-size: 0.875rem;
|
|
85
|
+
}
|
|
86
|
+
.email {
|
|
87
|
+
color: var(--tern-muted);
|
|
88
|
+
font-size: 0.8125rem;
|
|
89
|
+
}
|
|
90
|
+
p {
|
|
91
|
+
margin: 0;
|
|
92
|
+
font-size: 0.875rem;
|
|
93
|
+
}
|
|
94
|
+
ul {
|
|
95
|
+
display: grid;
|
|
96
|
+
gap: 0.5rem;
|
|
97
|
+
margin: 0.6rem 0 0;
|
|
98
|
+
padding: 0;
|
|
99
|
+
list-style: none;
|
|
100
|
+
}
|
|
101
|
+
li {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
gap: 0.5rem;
|
|
105
|
+
font-size: 0.9375rem;
|
|
106
|
+
}
|
|
107
|
+
li svg {
|
|
108
|
+
color: var(--tern-success);
|
|
109
|
+
flex: none;
|
|
110
|
+
}
|
|
111
|
+
.note {
|
|
112
|
+
color: var(--tern-muted);
|
|
113
|
+
font-size: 0.8125rem;
|
|
114
|
+
}
|
|
115
|
+
form {
|
|
116
|
+
display: flex;
|
|
117
|
+
justify-content: flex-end;
|
|
118
|
+
gap: 0.5rem;
|
|
119
|
+
}
|
|
120
|
+
a {
|
|
121
|
+
color: var(--tern-accent);
|
|
122
|
+
font-size: 0.8125rem;
|
|
123
|
+
font-weight: 550;
|
|
124
|
+
}
|
|
125
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
type Props = {
|
|
2
|
+
/** From plugins/oidc: provider.ConsentRequest(ctx), returned by the page's load. */
|
|
3
|
+
consent: {
|
|
4
|
+
client: string;
|
|
5
|
+
scopes: string[];
|
|
6
|
+
action: string;
|
|
7
|
+
params: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
/** Who is signed in, so they can tell they're approving as the right account. */
|
|
10
|
+
user?: {
|
|
11
|
+
name: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
};
|
|
14
|
+
/** Where to switch accounts. */
|
|
15
|
+
switchHref?: string;
|
|
16
|
+
level?: 1 | 2 | 3;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
declare const ConsentScreen: import("svelte").Component<Props, {}, "">;
|
|
20
|
+
type ConsentScreen = ReturnType<typeof ConsentScreen>;
|
|
21
|
+
export default ConsentScreen;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import MenuList from './MenuList.svelte';
|
|
4
|
+
import type { MenuItem } from './menu.js';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
items: MenuItem[];
|
|
8
|
+
/** Names the menu for screen readers, such as "File actions". */
|
|
9
|
+
label: string;
|
|
10
|
+
/** The area that has the menu. Right-click, a long press, Shift+F10 or the menu key opens it. */
|
|
11
|
+
children: Snippet;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
let { items, label, children, class: klass = '' }: Props = $props();
|
|
15
|
+
|
|
16
|
+
const uid = $props.id();
|
|
17
|
+
let at = $state<{ x: number; y: number } | null>(null);
|
|
18
|
+
let returnTo: HTMLElement | null = null;
|
|
19
|
+
let press: { timer: ReturnType<typeof setTimeout>; x: number; y: number } | null = null;
|
|
20
|
+
|
|
21
|
+
function openAt(x: number, y: number) {
|
|
22
|
+
returnTo = document.activeElement as HTMLElement | null;
|
|
23
|
+
at = { x, y };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cancelPress() {
|
|
27
|
+
if (press) clearTimeout(press.timer);
|
|
28
|
+
press = null;
|
|
29
|
+
}
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
33
|
+
<div
|
|
34
|
+
class="tern-context {klass}"
|
|
35
|
+
oncontextmenu={(e) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
cancelPress();
|
|
38
|
+
openAt(e.clientX, e.clientY);
|
|
39
|
+
}}
|
|
40
|
+
onkeydown={(e) => {
|
|
41
|
+
if ((e.key === 'F10' && e.shiftKey) || e.key === 'ContextMenu') {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
const r = (e.target as HTMLElement).getBoundingClientRect();
|
|
44
|
+
openAt(r.left + 8, r.bottom);
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
onpointerdown={(e) => {
|
|
48
|
+
// iOS doesn't fire contextmenu on a long press.
|
|
49
|
+
if (e.pointerType !== 'touch') return;
|
|
50
|
+
const { clientX: x, clientY: y } = e;
|
|
51
|
+
press = { x, y, timer: setTimeout(() => openAt(x, y), 550) };
|
|
52
|
+
}}
|
|
53
|
+
onpointermove={(e) => {
|
|
54
|
+
if (press && Math.hypot(e.clientX - press.x, e.clientY - press.y) > 8) cancelPress();
|
|
55
|
+
}}
|
|
56
|
+
onpointerup={cancelPress}
|
|
57
|
+
onpointercancel={cancelPress}
|
|
58
|
+
>
|
|
59
|
+
{@render children()}
|
|
60
|
+
</div>
|
|
61
|
+
{#if at}
|
|
62
|
+
<MenuList
|
|
63
|
+
{items}
|
|
64
|
+
id="tern-context-{uid}"
|
|
65
|
+
{label}
|
|
66
|
+
anchor={at}
|
|
67
|
+
onclose={(how) => {
|
|
68
|
+
at = null;
|
|
69
|
+
if (how.returnFocus) returnTo?.focus();
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
{/if}
|
|
73
|
+
|
|
74
|
+
<style>
|
|
75
|
+
.tern-context {
|
|
76
|
+
-webkit-touch-callout: none;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { MenuItem } from './menu.js';
|
|
3
|
+
type Props = {
|
|
4
|
+
items: MenuItem[];
|
|
5
|
+
/** Names the menu for screen readers, such as "File actions". */
|
|
6
|
+
label: string;
|
|
7
|
+
/** The area that has the menu. Right-click, a long press, Shift+F10 or the menu key opens it. */
|
|
8
|
+
children: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const ContextMenu: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type ContextMenu = ReturnType<typeof ContextMenu>;
|
|
13
|
+
export default ContextMenu;
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
<script lang="ts" generics="Row">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Checkbox from './Checkbox.svelte';
|
|
4
|
+
import Input from './Input.svelte';
|
|
5
|
+
import Pagination from './Pagination.svelte';
|
|
6
|
+
import type { Column, DataQuery } from './table.js';
|
|
7
|
+
import { matches } from './options.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
columns: Column<Row>[];
|
|
12
|
+
/** All rows, or with onquery, the current page's. */
|
|
13
|
+
rows: Row[];
|
|
14
|
+
caption: string;
|
|
15
|
+
hideCaption?: boolean;
|
|
16
|
+
/** A stable key per row, for selection. */
|
|
17
|
+
key: (row: Row) => string;
|
|
18
|
+
/** Rows per page; 0 shows all. */
|
|
19
|
+
pageSize?: number;
|
|
20
|
+
/** Show a search box that filters rows. */
|
|
21
|
+
searchable?: boolean;
|
|
22
|
+
/** Checkboxes to select rows; bind the selected keys. */
|
|
23
|
+
selectable?: boolean;
|
|
24
|
+
selected?: string[];
|
|
25
|
+
/** Sorting, searching and paging happen elsewhere, such as in a Go endpoint: this runs on each change, and you set rows and total. */
|
|
26
|
+
onquery?: (q: DataQuery) => void;
|
|
27
|
+
/** With onquery: how many rows match in all. */
|
|
28
|
+
total?: number;
|
|
29
|
+
/** Buttons for selected rows or the whole table, above it. */
|
|
30
|
+
toolbar?: Snippet<[string[]]>;
|
|
31
|
+
/** Actions at the end of each row. */
|
|
32
|
+
actions?: Snippet<[Row]>;
|
|
33
|
+
empty?: string;
|
|
34
|
+
/** Dims the rows while new ones load, keeping the frame. */
|
|
35
|
+
loading?: boolean;
|
|
36
|
+
class?: string;
|
|
37
|
+
};
|
|
38
|
+
let {
|
|
39
|
+
columns,
|
|
40
|
+
rows,
|
|
41
|
+
caption,
|
|
42
|
+
hideCaption = false,
|
|
43
|
+
key,
|
|
44
|
+
pageSize = 10,
|
|
45
|
+
searchable = false,
|
|
46
|
+
selectable = false,
|
|
47
|
+
selected = $bindable([]),
|
|
48
|
+
onquery,
|
|
49
|
+
total,
|
|
50
|
+
toolbar,
|
|
51
|
+
actions,
|
|
52
|
+
empty = 'No rows match',
|
|
53
|
+
loading = false,
|
|
54
|
+
class: klass = ''
|
|
55
|
+
}: Props = $props();
|
|
56
|
+
|
|
57
|
+
const uid = $props.id();
|
|
58
|
+
let sort = $state<DataQuery['sort']>(null);
|
|
59
|
+
let search = $state('');
|
|
60
|
+
let page = $state(1);
|
|
61
|
+
|
|
62
|
+
const cell = (row: Row, col: Column<Row>) => (col.value ? col.value(row) : (row as Record<string, unknown>)[col.key]);
|
|
63
|
+
const compare = (a: unknown, b: unknown) => {
|
|
64
|
+
if (a == null) return b == null ? 0 : 1;
|
|
65
|
+
if (b == null) return -1;
|
|
66
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
67
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
68
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const filtered = $derived(onquery || !search ? rows : rows.filter((r) => matches(columns.map((c) => String(cell(r, c) ?? '')).join(' '), search)));
|
|
72
|
+
const sorted = $derived.by(() => {
|
|
73
|
+
if (onquery || !sort) return filtered;
|
|
74
|
+
const col = columns.find((c) => c.key === sort!.key);
|
|
75
|
+
if (!col) return filtered;
|
|
76
|
+
const dir = sort.dir === 'asc' ? 1 : -1;
|
|
77
|
+
return [...filtered].sort((a, b) => compare(cell(a, col), cell(b, col)) * dir);
|
|
78
|
+
});
|
|
79
|
+
const count = $derived(onquery ? (total ?? rows.length) : sorted.length);
|
|
80
|
+
const pages = $derived(pageSize ? Math.max(1, Math.ceil(count / pageSize)) : 1);
|
|
81
|
+
const shown = $derived(onquery || !pageSize ? sorted : sorted.slice((page - 1) * pageSize, page * pageSize));
|
|
82
|
+
|
|
83
|
+
$effect(() => {
|
|
84
|
+
if (page > pages) page = pages;
|
|
85
|
+
});
|
|
86
|
+
$effect(() => {
|
|
87
|
+
onquery?.({ page, pageSize, sort, search });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function toggleSort(col: Column<Row>) {
|
|
91
|
+
if (!sort || sort.key !== col.key) sort = { key: col.key, dir: 'asc' };
|
|
92
|
+
else if (sort.dir === 'asc') sort = { key: col.key, dir: 'desc' };
|
|
93
|
+
else sort = null;
|
|
94
|
+
page = 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const shownKeys = $derived(shown.map(key));
|
|
98
|
+
const allShown = $derived(shownKeys.length > 0 && shownKeys.every((k) => selected.includes(k)));
|
|
99
|
+
const someShown = $derived(!allShown && shownKeys.some((k) => selected.includes(k)));
|
|
100
|
+
function toggleAll() {
|
|
101
|
+
selected = allShown ? selected.filter((k) => !shownKeys.includes(k)) : [...new Set([...selected, ...shownKeys])];
|
|
102
|
+
}
|
|
103
|
+
function toggle(k: string) {
|
|
104
|
+
selected = selected.includes(k) ? selected.filter((x) => x !== k) : [...selected, k];
|
|
105
|
+
}
|
|
106
|
+
const status = $derived(
|
|
107
|
+
count === 0 ? empty : `${pageSize ? `${(page - 1) * pageSize + 1}–${Math.min(count, page * pageSize)} of ` : ''}${count} ${count === 1 ? 'row' : 'rows'}${selected.length ? `, ${selected.length} selected` : ''}`
|
|
108
|
+
);
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<div class="tern-datatable {klass}">
|
|
112
|
+
{#if searchable || toolbar}
|
|
113
|
+
<div class="bar">
|
|
114
|
+
{#if searchable}
|
|
115
|
+
<div class="search">
|
|
116
|
+
<Input
|
|
117
|
+
type="search"
|
|
118
|
+
placeholder="Search…"
|
|
119
|
+
aria-label="Search {caption}"
|
|
120
|
+
aria-controls="tern-datatable-{uid}"
|
|
121
|
+
bind:value={search}
|
|
122
|
+
oninput={() => (page = 1)}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
{/if}
|
|
126
|
+
{#if toolbar}<div class="tools">{@render toolbar(selected)}</div>{/if}
|
|
127
|
+
</div>
|
|
128
|
+
{/if}
|
|
129
|
+
<div class="wrap" class:loading>
|
|
130
|
+
<table class="tern-table" id="tern-datatable-{uid}" aria-busy={loading || undefined}>
|
|
131
|
+
<caption class:tern-sr-only={hideCaption}>{caption}</caption>
|
|
132
|
+
<thead>
|
|
133
|
+
<tr>
|
|
134
|
+
{#if selectable}
|
|
135
|
+
<th scope="col" class="select">
|
|
136
|
+
<Checkbox checked={allShown} indeterminate={someShown} onchange={toggleAll} aria-label="Select all rows on this page" />
|
|
137
|
+
</th>
|
|
138
|
+
{/if}
|
|
139
|
+
{#each columns as col (col.key)}
|
|
140
|
+
<th scope="col" style:text-align={col.align} style:width={col.width} aria-sort={sort && sort.key === col.key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : col.sortable ? 'none' : undefined}>
|
|
141
|
+
{#if col.sortable}
|
|
142
|
+
<button type="button" class="sort" onclick={() => toggleSort(col)}>
|
|
143
|
+
{col.header}
|
|
144
|
+
<svg aria-hidden="true" viewBox="0 0 16 16" width="12" height="12" class:on={sort?.key === col.key}>
|
|
145
|
+
{#if sort && sort.key === col.key && sort.dir === 'desc'}<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />{:else if sort?.key === col.key}<path d="M4 10l4-4 4 4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />{:else}<path d="M5 6.5l3-3 3 3M5 9.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />{/if}
|
|
146
|
+
</svg>
|
|
147
|
+
</button>
|
|
148
|
+
{:else}
|
|
149
|
+
{col.header}
|
|
150
|
+
{/if}
|
|
151
|
+
</th>
|
|
152
|
+
{/each}
|
|
153
|
+
{#if actions}<th scope="col"><span class="tern-sr-only">Actions</span></th>{/if}
|
|
154
|
+
</tr>
|
|
155
|
+
</thead>
|
|
156
|
+
<tbody>
|
|
157
|
+
{#each shown as row (key(row))}
|
|
158
|
+
{@const k = key(row)}
|
|
159
|
+
<tr class:selected={selected.includes(k)}>
|
|
160
|
+
{#if selectable}
|
|
161
|
+
<td class="select"><Checkbox checked={selected.includes(k)} onchange={() => toggle(k)} aria-label="Select row {String(cell(row, columns[0]!) ?? k)}" /></td>
|
|
162
|
+
{/if}
|
|
163
|
+
{#each columns as col (col.key)}
|
|
164
|
+
<td style:text-align={col.align}>
|
|
165
|
+
{#if col.cell}{@render col.cell(row)}{:else}{(row as Record<string, unknown>)[col.key] ?? ''}{/if}
|
|
166
|
+
</td>
|
|
167
|
+
{/each}
|
|
168
|
+
{#if actions}<td class="actions">{@render actions(row)}</td>{/if}
|
|
169
|
+
</tr>
|
|
170
|
+
{:else}
|
|
171
|
+
<tr><td class="empty" colspan={columns.length + (selectable ? 1 : 0) + (actions ? 1 : 0)}>{empty}</td></tr>
|
|
172
|
+
{/each}
|
|
173
|
+
</tbody>
|
|
174
|
+
</table>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="foot">
|
|
177
|
+
<span class="status" role="status">{status}</span>
|
|
178
|
+
{#if pages > 1}<Pagination bind:page {pages} label="{caption} pages" />{/if}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<style>
|
|
183
|
+
.tern-datatable {
|
|
184
|
+
display: grid;
|
|
185
|
+
gap: 0.75rem;
|
|
186
|
+
}
|
|
187
|
+
.bar {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-wrap: wrap;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: space-between;
|
|
192
|
+
gap: 0.5rem;
|
|
193
|
+
}
|
|
194
|
+
.search {
|
|
195
|
+
width: min(18rem, 100%);
|
|
196
|
+
}
|
|
197
|
+
.tools {
|
|
198
|
+
display: flex;
|
|
199
|
+
gap: 0.5rem;
|
|
200
|
+
}
|
|
201
|
+
.wrap {
|
|
202
|
+
overflow-x: auto;
|
|
203
|
+
background: var(--tern-surface);
|
|
204
|
+
border: 1px solid var(--tern-border);
|
|
205
|
+
border-radius: var(--tern-radius);
|
|
206
|
+
transition: opacity var(--tern-duration);
|
|
207
|
+
}
|
|
208
|
+
.loading {
|
|
209
|
+
opacity: 0.55;
|
|
210
|
+
}
|
|
211
|
+
caption {
|
|
212
|
+
padding: 0.75rem;
|
|
213
|
+
text-align: start;
|
|
214
|
+
font-weight: 600;
|
|
215
|
+
color: var(--tern-fg);
|
|
216
|
+
}
|
|
217
|
+
.sort {
|
|
218
|
+
display: inline-flex;
|
|
219
|
+
align-items: center;
|
|
220
|
+
gap: 0.3rem;
|
|
221
|
+
padding: 0.15rem 0.25rem;
|
|
222
|
+
margin: -0.15rem -0.25rem;
|
|
223
|
+
font: inherit;
|
|
224
|
+
text-transform: inherit;
|
|
225
|
+
letter-spacing: inherit;
|
|
226
|
+
color: inherit;
|
|
227
|
+
background: transparent;
|
|
228
|
+
border: 0;
|
|
229
|
+
border-radius: 4px;
|
|
230
|
+
cursor: pointer;
|
|
231
|
+
}
|
|
232
|
+
.sort:hover {
|
|
233
|
+
color: var(--tern-fg);
|
|
234
|
+
}
|
|
235
|
+
.sort:focus-visible {
|
|
236
|
+
outline: 2px solid var(--tern-ring);
|
|
237
|
+
}
|
|
238
|
+
.sort svg {
|
|
239
|
+
opacity: 0.6;
|
|
240
|
+
}
|
|
241
|
+
.sort svg.on {
|
|
242
|
+
opacity: 1;
|
|
243
|
+
color: var(--tern-fg);
|
|
244
|
+
}
|
|
245
|
+
.select {
|
|
246
|
+
width: 2.25rem;
|
|
247
|
+
}
|
|
248
|
+
tr.selected td {
|
|
249
|
+
background: var(--tern-accent-soft);
|
|
250
|
+
}
|
|
251
|
+
td {
|
|
252
|
+
font-variant-numeric: tabular-nums;
|
|
253
|
+
}
|
|
254
|
+
.actions {
|
|
255
|
+
text-align: end;
|
|
256
|
+
white-space: nowrap;
|
|
257
|
+
}
|
|
258
|
+
.wrap :global(tbody tr:last-child td) {
|
|
259
|
+
border-bottom: 0;
|
|
260
|
+
}
|
|
261
|
+
.empty {
|
|
262
|
+
padding: 2rem;
|
|
263
|
+
text-align: center;
|
|
264
|
+
color: var(--tern-muted);
|
|
265
|
+
}
|
|
266
|
+
.foot {
|
|
267
|
+
display: flex;
|
|
268
|
+
flex-wrap: wrap;
|
|
269
|
+
align-items: center;
|
|
270
|
+
justify-content: space-between;
|
|
271
|
+
gap: 0.5rem;
|
|
272
|
+
}
|
|
273
|
+
.status {
|
|
274
|
+
font-size: 0.8125rem;
|
|
275
|
+
color: var(--tern-muted);
|
|
276
|
+
}
|
|
277
|
+
</style>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { Column, DataQuery } from './table.js';
|
|
3
|
+
declare function $$render<Row>(): {
|
|
4
|
+
props: {
|
|
5
|
+
columns: Column<Row>[];
|
|
6
|
+
/** All rows, or with onquery, the current page's. */
|
|
7
|
+
rows: Row[];
|
|
8
|
+
caption: string;
|
|
9
|
+
hideCaption?: boolean;
|
|
10
|
+
/** A stable key per row, for selection. */
|
|
11
|
+
key: (row: Row) => string;
|
|
12
|
+
/** Rows per page; 0 shows all. */
|
|
13
|
+
pageSize?: number;
|
|
14
|
+
/** Show a search box that filters rows. */
|
|
15
|
+
searchable?: boolean;
|
|
16
|
+
/** Checkboxes to select rows; bind the selected keys. */
|
|
17
|
+
selectable?: boolean;
|
|
18
|
+
selected?: string[];
|
|
19
|
+
/** Sorting, searching and paging happen elsewhere, such as in a Go endpoint: this runs on each change, and you set rows and total. */
|
|
20
|
+
onquery?: (q: DataQuery) => void;
|
|
21
|
+
/** With onquery: how many rows match in all. */
|
|
22
|
+
total?: number;
|
|
23
|
+
/** Buttons for selected rows or the whole table, above it. */
|
|
24
|
+
toolbar?: Snippet<[string[]]>;
|
|
25
|
+
/** Actions at the end of each row. */
|
|
26
|
+
actions?: Snippet<[Row]>;
|
|
27
|
+
empty?: string;
|
|
28
|
+
/** Dims the rows while new ones load, keeping the frame. */
|
|
29
|
+
loading?: boolean;
|
|
30
|
+
class?: string;
|
|
31
|
+
};
|
|
32
|
+
exports: {};
|
|
33
|
+
bindings: "selected";
|
|
34
|
+
slots: {};
|
|
35
|
+
events: {};
|
|
36
|
+
};
|
|
37
|
+
declare class __sveltets_Render<Row> {
|
|
38
|
+
props(): ReturnType<typeof $$render<Row>>['props'];
|
|
39
|
+
events(): ReturnType<typeof $$render<Row>>['events'];
|
|
40
|
+
slots(): ReturnType<typeof $$render<Row>>['slots'];
|
|
41
|
+
bindings(): "selected";
|
|
42
|
+
exports(): {};
|
|
43
|
+
}
|
|
44
|
+
interface $$IsomorphicComponent {
|
|
45
|
+
new <Row>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Row>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Row>['props']>, ReturnType<__sveltets_Render<Row>['events']>, ReturnType<__sveltets_Render<Row>['slots']>> & {
|
|
46
|
+
$$bindings?: ReturnType<__sveltets_Render<Row>['bindings']>;
|
|
47
|
+
} & ReturnType<__sveltets_Render<Row>['exports']>;
|
|
48
|
+
<Row>(internal: unknown, props: ReturnType<__sveltets_Render<Row>['props']> & {}): ReturnType<__sveltets_Render<Row>['exports']>;
|
|
49
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
50
|
+
}
|
|
51
|
+
declare const DataTable: $$IsomorphicComponent;
|
|
52
|
+
type DataTable<Row> = InstanceType<typeof DataTable<Row>>;
|
|
53
|
+
export default DataTable;
|