@viniaraujo68/plinth 0.3.1 → 0.5.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 +189 -19
- 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/index.d.ts +8 -1
- package/dist/components/index.js +11 -5
- 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
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import Dropdown from "../components/Dropdown.svelte";
|
|
5
|
+
import { getUserContext } from "../user/context.js";
|
|
6
|
+
import UserAvatar from "./UserAvatar.svelte";
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
/** The app's mark and name, at the start of the bar. Usually a link home. */
|
|
10
|
+
brand?: Snippet;
|
|
11
|
+
/**
|
|
12
|
+
* Controls at the end of the bar, before the account menu: a language toggle, a theme toggle,
|
|
13
|
+
* the sign-in and sign-up links a visitor who is not signed in needs.
|
|
14
|
+
*/
|
|
15
|
+
actions?: Snippet;
|
|
16
|
+
/**
|
|
17
|
+
* The account menu's own entries, as `<li>` elements — "My groups", "Account". They sit between
|
|
18
|
+
* the user's name and the sign-out entry. Choosing one closes the menu.
|
|
19
|
+
*/
|
|
20
|
+
menu?: Snippet;
|
|
21
|
+
/** Accessible name of the avatar that opens the menu. A prop because the library ships no translations. */
|
|
22
|
+
menuLabel?: string;
|
|
23
|
+
/** The menu's sign-out entry. Shown only when the user context has somewhere to sign out to. */
|
|
24
|
+
logoutLabel?: string;
|
|
25
|
+
class?: ClassValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
brand,
|
|
30
|
+
actions,
|
|
31
|
+
menu,
|
|
32
|
+
menuLabel = "Account",
|
|
33
|
+
logoutLabel = "Sign out",
|
|
34
|
+
class: className,
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
const user = getUserContext();
|
|
38
|
+
const signedIn = $derived(user.status === "authenticated" ? user.data : null);
|
|
39
|
+
|
|
40
|
+
let accountMenu = $state<Dropdown>();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Closes the menu once something in it is chosen. The popover light-dismisses on a click outside
|
|
44
|
+
* it, never on one inside, and a link that navigates within the app leaves the bar — and the open
|
|
45
|
+
* panel — mounted. Delegated from the list, so the app's own entries need no handler of their own.
|
|
46
|
+
*/
|
|
47
|
+
const closeOnChoice = (event: MouseEvent) => {
|
|
48
|
+
if ((event.target as Element | null)?.closest("a, button")) accountMenu?.close();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const signOut = async () => {
|
|
52
|
+
await user.logout?.();
|
|
53
|
+
};
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<!--
|
|
57
|
+
@component
|
|
58
|
+
A bar across the top of the page, for an app with too little navigation to fill `AppShell`'s
|
|
59
|
+
sidebar: the brand at the start, the app's controls at the end and, once someone is signed in, their
|
|
60
|
+
avatar, which opens an account menu with their name, the app's entries and signing out.
|
|
61
|
+
|
|
62
|
+
It reads the signed-in user from the user context, as `AppShell` does, so the same
|
|
63
|
+
`setUserContext` drives both. It owns no page layout: put the page below it, at whatever width the
|
|
64
|
+
app uses — `--plinth-top-bar-width` caps the bar's content to match it.
|
|
65
|
+
|
|
66
|
+
```svelte
|
|
67
|
+
<TopBar menuLabel="Account menu" logoutLabel="Sign out">
|
|
68
|
+
{#snippet brand()}<a href="/">Acme</a>{/snippet}
|
|
69
|
+
{#snippet actions()}<ThemeToggle iconOnly />{/snippet}
|
|
70
|
+
{#snippet menu()}<li><a href="/account">Account</a></li>{/snippet}
|
|
71
|
+
</TopBar>
|
|
72
|
+
```
|
|
73
|
+
-->
|
|
74
|
+
<header class={["plinth-top-bar", className]}>
|
|
75
|
+
<div class="plinth-top-bar-inner">
|
|
76
|
+
<div class="plinth-top-bar-brand">{@render brand?.()}</div>
|
|
77
|
+
<div class="plinth-top-bar-end">
|
|
78
|
+
{@render actions?.()}
|
|
79
|
+
{#if signedIn}
|
|
80
|
+
<Dropdown
|
|
81
|
+
bind:this={accountMenu}
|
|
82
|
+
class="plinth-top-bar-account"
|
|
83
|
+
aria-label={menuLabel}
|
|
84
|
+
title={signedIn.name}
|
|
85
|
+
panelClass="plinth-top-bar-menu dropdown rounded-box bg-base-100 p-2 shadow-lg"
|
|
86
|
+
>
|
|
87
|
+
<UserAvatar name={signedIn.name} />
|
|
88
|
+
{#snippet content()}
|
|
89
|
+
<!-- The handler only reacts to a click that landed on a link or a button inside, and a
|
|
90
|
+
keyboard activates those with a click event of its own, so nothing is mouse-only. -->
|
|
91
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
|
|
92
|
+
<ul class="menu w-full p-0" onclick={closeOnChoice}>
|
|
93
|
+
<li class="plinth-top-bar-identity menu-title">
|
|
94
|
+
<span class="plinth-top-bar-name">{signedIn.name}</span>
|
|
95
|
+
{#if signedIn.email}<span class="plinth-top-bar-email">{signedIn.email}</span>{/if}
|
|
96
|
+
</li>
|
|
97
|
+
{@render menu?.()}
|
|
98
|
+
{#if user.logout}
|
|
99
|
+
<li><button type="button" onclick={signOut}>{logoutLabel}</button></li>
|
|
100
|
+
{/if}
|
|
101
|
+
</ul>
|
|
102
|
+
{/snippet}
|
|
103
|
+
</Dropdown>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</header>
|
|
108
|
+
|
|
109
|
+
<style>
|
|
110
|
+
.plinth-top-bar {
|
|
111
|
+
position: sticky;
|
|
112
|
+
top: 0;
|
|
113
|
+
z-index: 50;
|
|
114
|
+
border-bottom: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
115
|
+
background-color: color-mix(in oklch, var(--color-base-100) 82%, transparent);
|
|
116
|
+
backdrop-filter: blur(12px);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.plinth-top-bar-inner {
|
|
120
|
+
display: flex;
|
|
121
|
+
align-items: center;
|
|
122
|
+
justify-content: space-between;
|
|
123
|
+
gap: 0.75rem;
|
|
124
|
+
max-width: var(--plinth-top-bar-width, none);
|
|
125
|
+
height: 4rem;
|
|
126
|
+
margin-inline: auto;
|
|
127
|
+
padding-inline: 1.25rem;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.plinth-top-bar-brand {
|
|
131
|
+
display: flex;
|
|
132
|
+
min-width: 0;
|
|
133
|
+
align-items: center;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.plinth-top-bar-end {
|
|
137
|
+
display: flex;
|
|
138
|
+
flex-shrink: 0;
|
|
139
|
+
align-items: center;
|
|
140
|
+
gap: 0.75rem;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@media (max-width: 35rem) {
|
|
144
|
+
.plinth-top-bar-inner {
|
|
145
|
+
padding-inline: 1rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.plinth-top-bar-end {
|
|
149
|
+
gap: 0.5rem;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* The trigger and the panel are `Dropdown`'s elements, which this component's scoped rules do
|
|
154
|
+
not reach; their classes come in through props, hence the global selectors. */
|
|
155
|
+
:global(.plinth-top-bar-account) {
|
|
156
|
+
display: grid;
|
|
157
|
+
place-items: center;
|
|
158
|
+
padding: 0;
|
|
159
|
+
border: none;
|
|
160
|
+
border-radius: 9999px;
|
|
161
|
+
background: none;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
:global(.plinth-top-bar-account:focus-visible) {
|
|
166
|
+
outline: 2px solid var(--color-primary);
|
|
167
|
+
outline-offset: 2px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
:global(.plinth-top-bar-menu) {
|
|
171
|
+
width: 13rem;
|
|
172
|
+
border: 1px solid color-mix(in oklch, var(--color-base-content) 12%, transparent);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.plinth-top-bar-identity {
|
|
176
|
+
display: flex;
|
|
177
|
+
flex-direction: column;
|
|
178
|
+
gap: 0.125rem;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.plinth-top-bar-name,
|
|
182
|
+
.plinth-top-bar-email {
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
text-overflow: ellipsis;
|
|
185
|
+
white-space: nowrap;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.plinth-top-bar-email {
|
|
189
|
+
font-weight: 400;
|
|
190
|
+
}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ClassValue } from "svelte/elements";
|
|
3
|
+
interface Props {
|
|
4
|
+
/** The app's mark and name, at the start of the bar. Usually a link home. */
|
|
5
|
+
brand?: Snippet;
|
|
6
|
+
/**
|
|
7
|
+
* Controls at the end of the bar, before the account menu: a language toggle, a theme toggle,
|
|
8
|
+
* the sign-in and sign-up links a visitor who is not signed in needs.
|
|
9
|
+
*/
|
|
10
|
+
actions?: Snippet;
|
|
11
|
+
/**
|
|
12
|
+
* The account menu's own entries, as `<li>` elements — "My groups", "Account". They sit between
|
|
13
|
+
* the user's name and the sign-out entry. Choosing one closes the menu.
|
|
14
|
+
*/
|
|
15
|
+
menu?: Snippet;
|
|
16
|
+
/** Accessible name of the avatar that opens the menu. A prop because the library ships no translations. */
|
|
17
|
+
menuLabel?: string;
|
|
18
|
+
/** The menu's sign-out entry. Shown only when the user context has somewhere to sign out to. */
|
|
19
|
+
logoutLabel?: string;
|
|
20
|
+
class?: ClassValue;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* A bar across the top of the page, for an app with too little navigation to fill `AppShell`'s
|
|
24
|
+
* sidebar: the brand at the start, the app's controls at the end and, once someone is signed in, their
|
|
25
|
+
* avatar, which opens an account menu with their name, the app's entries and signing out.
|
|
26
|
+
*
|
|
27
|
+
* It reads the signed-in user from the user context, as `AppShell` does, so the same
|
|
28
|
+
* `setUserContext` drives both. It owns no page layout: put the page below it, at whatever width the
|
|
29
|
+
* app uses — `--plinth-top-bar-width` caps the bar's content to match it.
|
|
30
|
+
*
|
|
31
|
+
* ```svelte
|
|
32
|
+
* <TopBar menuLabel="Account menu" logoutLabel="Sign out">
|
|
33
|
+
* {#snippet brand()}<a href="/">Acme</a>{/snippet}
|
|
34
|
+
* {#snippet actions()}<ThemeToggle iconOnly />{/snippet}
|
|
35
|
+
* {#snippet menu()}<li><a href="/account">Account</a></li>{/snippet}
|
|
36
|
+
* </TopBar>
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
declare const TopBar: import("svelte").Component<Props, {}, "">;
|
|
40
|
+
type TopBar = ReturnType<typeof TopBar>;
|
|
41
|
+
export default TopBar;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ClassValue } from "svelte/elements";
|
|
3
|
+
import { initials } from "./initials.js";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
/** Whose avatar this is. Only the initials are drawn; the name itself is the caller's to show. */
|
|
7
|
+
name: string;
|
|
8
|
+
class?: ClassValue;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let { name, class: className }: Props = $props();
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<!--
|
|
15
|
+
@component
|
|
16
|
+
A signed-in user's initials on a disc. Hidden from assistive technology: every place that draws it
|
|
17
|
+
also shows the name next to it, or labels the control it sits in, and initials read aloud as
|
|
18
|
+
letters would only repeat that.
|
|
19
|
+
-->
|
|
20
|
+
<span class={["avatar-initials", className]} aria-hidden="true"
|
|
21
|
+
><span class="avatar-initials-text">{initials(name)}</span></span
|
|
22
|
+
>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.avatar-initials {
|
|
26
|
+
display: flex;
|
|
27
|
+
flex-shrink: 0;
|
|
28
|
+
align-items: center;
|
|
29
|
+
justify-content: center;
|
|
30
|
+
width: 2rem;
|
|
31
|
+
height: 2rem;
|
|
32
|
+
border-radius: 9999px;
|
|
33
|
+
/* Solid rather than a wash: on the tinted bars this sits on, a 15% disc all but disappears.
|
|
34
|
+
`--color-primary-content` is the pair the theme guarantees against it in both modes. */
|
|
35
|
+
background-color: var(--color-primary);
|
|
36
|
+
color: var(--color-primary-content);
|
|
37
|
+
font-size: 0.75rem;
|
|
38
|
+
font-weight: 500;
|
|
39
|
+
line-height: 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Centring the line box centres the font's ascent and descent, not the letters: capitals sit
|
|
43
|
+
above the baseline with nothing hanging below, so in a font like Segoe UI Variable they land a
|
|
44
|
+
pixel low on a 2rem disc. Trimmed to cap height and baseline, the box is the letters, and the
|
|
45
|
+
disc centres them whatever the font. The inner element is what gets trimmed, because the
|
|
46
|
+
property applies to block containers and the disc is a flex container. */
|
|
47
|
+
.avatar-initials-text {
|
|
48
|
+
text-box: trim-both cap alphabetic;
|
|
49
|
+
}
|
|
50
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ClassValue } from "svelte/elements";
|
|
2
|
+
interface Props {
|
|
3
|
+
/** Whose avatar this is. Only the initials are drawn; the name itself is the caller's to show. */
|
|
4
|
+
name: string;
|
|
5
|
+
class?: ClassValue;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A signed-in user's initials on a disc. Hidden from assistive technology: every place that draws it
|
|
9
|
+
* also shows the name next to it, or labels the control it sits in, and initials read aloud as
|
|
10
|
+
* letters would only repeat that.
|
|
11
|
+
*/
|
|
12
|
+
declare const UserAvatar: import("svelte").Component<Props, {}, "">;
|
|
13
|
+
type UserAvatar = ReturnType<typeof UserAvatar>;
|
|
14
|
+
export default UserAvatar;
|
package/dist/shell/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { default as AppShell } from "./AppShell.svelte";
|
|
2
2
|
export { default as Breadcrumbs } from "./Breadcrumbs.svelte";
|
|
3
|
+
export { default as NavigationProgress } from "./NavigationProgress.svelte";
|
|
4
|
+
export { default as TopBar } from "./TopBar.svelte";
|
|
3
5
|
export type { Crumb } from "./crumbs.js";
|
package/dist/shell/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// The application chrome around a routed page.
|
|
2
|
-
// than a tree of
|
|
3
|
-
// that renders — on a sidebar, on a bottom bar and in a breadcrumb trail alike.
|
|
1
|
+
// The application chrome around a routed page. `AppShell` and `Breadcrumbs` read the routing
|
|
2
|
+
// context rather than a tree of their own, so the navigation an app declares once in `routes.ts`
|
|
3
|
+
// is the navigation that renders — on a sidebar, on a bottom bar and in a breadcrumb trail alike.
|
|
4
|
+
// `TopBar` is the lighter alternative for an app with little to navigate, and
|
|
5
|
+
// `NavigationProgress` goes with either.
|
|
4
6
|
// `@viniaraujo68/plinth/shell`.
|
|
5
7
|
export { default as AppShell } from "./AppShell.svelte";
|
|
6
8
|
export { default as Breadcrumbs } from "./Breadcrumbs.svelte";
|
|
9
|
+
export { default as NavigationProgress } from "./NavigationProgress.svelte";
|
|
10
|
+
export { default as TopBar } from "./TopBar.svelte";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The letters an avatar shows for a name: the first letter of the first word and of the last one,
|
|
3
|
+
* upper-cased — "Ada Lovelace" is "AL", "ada" is "A". A blank name gives "?" rather than an empty
|
|
4
|
+
* disc, which would read as a rendering bug.
|
|
5
|
+
*/
|
|
6
|
+
export declare const initials: (name: string) => string;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The letters an avatar shows for a name: the first letter of the first word and of the last one,
|
|
3
|
+
* upper-cased — "Ada Lovelace" is "AL", "ada" is "A". A blank name gives "?" rather than an empty
|
|
4
|
+
* disc, which would read as a rendering bug.
|
|
5
|
+
*/
|
|
6
|
+
export const initials = (name) => {
|
|
7
|
+
const words = name.split(/\s+/).filter(Boolean);
|
|
8
|
+
if (words.length === 0)
|
|
9
|
+
return "?";
|
|
10
|
+
const first = words[0][0];
|
|
11
|
+
const last = words.length > 1 ? words[words.length - 1][0] : "";
|
|
12
|
+
return `${first}${last}`.toUpperCase();
|
|
13
|
+
};
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
<script lang="ts" generics="T">
|
|
2
2
|
import type { Snippet } from "svelte";
|
|
3
3
|
import type { ClassValue } from "svelte/elements";
|
|
4
|
+
import Paginator, { type PaginatorLabels } from "../components/Paginator.svelte";
|
|
4
5
|
import ColumnMenu from "./ColumnMenu.svelte";
|
|
6
|
+
import Skeleton from "../components/Skeleton.svelte";
|
|
7
|
+
import FilterBar from "./FilterBar.svelte";
|
|
8
|
+
import type { FilterLabels } from "./filter-labels.js";
|
|
5
9
|
import {
|
|
6
10
|
columnWidth,
|
|
7
11
|
isColumnResizable,
|
|
@@ -11,8 +15,9 @@
|
|
|
11
15
|
visibleColumns,
|
|
12
16
|
writeColumnState,
|
|
13
17
|
} from "./columns.js";
|
|
18
|
+
import { emptyFilter, filterableColumns, filterRows } from "./filter.js";
|
|
14
19
|
import { ariaSort, cellText, nextSort, sortRows } from "./sort.js";
|
|
15
|
-
import type { Column, ColumnState, SortState } from "./types.js";
|
|
20
|
+
import type { Column, ColumnState, FilterGroup, SortState } from "./types.js";
|
|
16
21
|
|
|
17
22
|
interface Props {
|
|
18
23
|
rows: readonly T[];
|
|
@@ -92,6 +97,22 @@
|
|
|
92
97
|
moveColumnLabel?: (column: Column<T>, direction: "up" | "down") => string;
|
|
93
98
|
/** Names a column's resize handle for a screen reader. */
|
|
94
99
|
resizeLabel?: (column: Column<T>) => string;
|
|
100
|
+
filters?: FilterGroup;
|
|
101
|
+
filterBar?: boolean;
|
|
102
|
+
filterGroups?: boolean;
|
|
103
|
+
filterLabels?: Partial<FilterLabels>;
|
|
104
|
+
loading?: boolean;
|
|
105
|
+
loadingRows?: number;
|
|
106
|
+
loadingLabel?: string;
|
|
107
|
+
error?: unknown;
|
|
108
|
+
errorState?: Snippet<[unknown, (() => void) | undefined]>;
|
|
109
|
+
errorLabel?: string;
|
|
110
|
+
retryLabel?: string;
|
|
111
|
+
onRetry?: () => void;
|
|
112
|
+
pageSize?: number;
|
|
113
|
+
page?: number;
|
|
114
|
+
pageSizes?: number[];
|
|
115
|
+
paginatorLabels?: Partial<PaginatorLabels>;
|
|
95
116
|
}
|
|
96
117
|
|
|
97
118
|
let {
|
|
@@ -114,17 +135,61 @@
|
|
|
114
135
|
resetColumnsLabel = "Reset columns",
|
|
115
136
|
moveColumnLabel = (column, direction) => `Move ${column.label} ${direction}`,
|
|
116
137
|
resizeLabel = (column) => `Resize ${column.label}`,
|
|
138
|
+
filters = $bindable(emptyFilter()),
|
|
139
|
+
filterBar = true,
|
|
140
|
+
filterGroups = true,
|
|
141
|
+
loading = false,
|
|
142
|
+
loadingRows = 5,
|
|
143
|
+
loadingLabel = "Loading…",
|
|
144
|
+
error,
|
|
145
|
+
errorState,
|
|
146
|
+
errorLabel = "The rows could not be loaded.",
|
|
147
|
+
retryLabel = "Try again",
|
|
148
|
+
onRetry,
|
|
149
|
+
filterLabels,
|
|
150
|
+
pageSize = $bindable(),
|
|
151
|
+
page = $bindable(1),
|
|
152
|
+
pageSizes,
|
|
153
|
+
paginatorLabels,
|
|
117
154
|
}: Props = $props();
|
|
118
155
|
|
|
156
|
+
const filtered = $derived(filterRows(rows, columns, filters));
|
|
157
|
+
|
|
119
158
|
const sorted = $derived.by(() => {
|
|
120
159
|
const column = columns.find((candidate) => candidate.key === sort?.key);
|
|
121
|
-
if (!sort || !column) return
|
|
122
|
-
return sortRows(
|
|
160
|
+
if (!sort || !column) return filtered;
|
|
161
|
+
return sortRows(filtered, column, sort.direction, locale);
|
|
123
162
|
});
|
|
124
163
|
|
|
164
|
+
const totalPages = $derived(
|
|
165
|
+
pageSize === undefined ? 1 : Math.max(1, Math.ceil(sorted.length / pageSize)),
|
|
166
|
+
);
|
|
167
|
+
const currentPage = $derived(Math.min(Math.max(1, Math.floor(page)), totalPages));
|
|
168
|
+
const offset = $derived(pageSize === undefined ? 0 : (currentPage - 1) * pageSize);
|
|
169
|
+
const visible = $derived(
|
|
170
|
+
pageSize === undefined ? sorted : sorted.slice(offset, offset + pageSize),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const showFilterBar = $derived(filterBar && filterableColumns(columns).length > 0);
|
|
174
|
+
|
|
175
|
+
const failed = $derived(error !== undefined && error !== null && error !== false);
|
|
176
|
+
const placeholders = $derived(
|
|
177
|
+
loading && !failed && visible.length === 0 ? Math.max(1, loadingRows) : 0,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const onFiltersChange = (next: FilterGroup) => {
|
|
181
|
+
filters = next;
|
|
182
|
+
page = 1;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const onPageSizeChange = (size: number) => {
|
|
186
|
+
pageSize = size;
|
|
187
|
+
page = 1;
|
|
188
|
+
};
|
|
189
|
+
|
|
125
190
|
const shown = $derived(visibleColumns(columns, columnState));
|
|
126
191
|
const sized = $derived(shown.some((column) => columnWidth(column, columnState) !== undefined));
|
|
127
|
-
const hasBar = $derived(toolbar !== undefined || columnsLabel !== undefined);
|
|
192
|
+
const hasBar = $derived(toolbar !== undefined || columnsLabel !== undefined || showFilterBar);
|
|
128
193
|
|
|
129
194
|
let restored = $state(false);
|
|
130
195
|
|
|
@@ -246,8 +311,13 @@ part — `hideable`, `hiddenByDefault`, `resizable`, `minWidth`. The arrangement
|
|
|
246
311
|
release neither loses a stored layout nor ends up somewhere nobody put it. Sorting by a column the
|
|
247
312
|
reader hid keeps working: the order is about the rows, not about what is on screen.
|
|
248
313
|
|
|
249
|
-
|
|
250
|
-
|
|
314
|
+
Columns that declare a `filter` get a chip bar in the table's bar, bound through `filters`; the
|
|
315
|
+
rules are applied before the sort, and the same rules drive `filterRows` for a caller that filters
|
|
316
|
+
outside the table. `pageSize` pages the filtered, sorted rows with a `Paginator` under the table,
|
|
317
|
+
and the position handed to `cell` and `card` stays the row's place in the whole order.
|
|
318
|
+
|
|
319
|
+
It stays short of a data grid — no virtualization, no saved views. A table that needs those needs a
|
|
320
|
+
different component.
|
|
251
321
|
|
|
252
322
|
```svelte
|
|
253
323
|
<DataTable
|
|
@@ -290,6 +360,16 @@ needs those needs a different component.
|
|
|
290
360
|
{#if toolbar}
|
|
291
361
|
<div class="plinth-table-toolbar">{@render toolbar()}</div>
|
|
292
362
|
{/if}
|
|
363
|
+
{#if showFilterBar}
|
|
364
|
+
<FilterBar
|
|
365
|
+
class="plinth-table-filters"
|
|
366
|
+
{columns}
|
|
367
|
+
filter={filters}
|
|
368
|
+
onchange={onFiltersChange}
|
|
369
|
+
groups={filterGroups}
|
|
370
|
+
labels={filterLabels}
|
|
371
|
+
/>
|
|
372
|
+
{/if}
|
|
293
373
|
{#if columnsLabel !== undefined}
|
|
294
374
|
<div class="plinth-table-columns">
|
|
295
375
|
<ColumnMenu
|
|
@@ -305,12 +385,16 @@ needs those needs a different component.
|
|
|
305
385
|
</div>
|
|
306
386
|
{/if}
|
|
307
387
|
|
|
308
|
-
<div class="plinth-table-wrap">
|
|
388
|
+
<div class="plinth-table-wrap" class:plinth-refreshing={loading && !failed && visible.length > 0}>
|
|
389
|
+
{#if loading && !failed}
|
|
390
|
+
<div class="plinth-table-progress" role="progressbar" aria-label={loadingLabel}></div>
|
|
391
|
+
{/if}
|
|
309
392
|
<table
|
|
310
393
|
class={["plinth-table table", className]}
|
|
311
394
|
class:has-card={card !== undefined}
|
|
312
395
|
class:plinth-sized={sized}
|
|
313
396
|
aria-label={label}
|
|
397
|
+
aria-busy={loading}
|
|
314
398
|
>
|
|
315
399
|
<thead>
|
|
316
400
|
<tr>
|
|
@@ -354,14 +438,40 @@ needs those needs a different component.
|
|
|
354
438
|
</thead>
|
|
355
439
|
|
|
356
440
|
<tbody>
|
|
357
|
-
{#if
|
|
441
|
+
{#if failed}
|
|
442
|
+
<tr>
|
|
443
|
+
<td class="plinth-failed" colspan={shown.length}>
|
|
444
|
+
{#if errorState}
|
|
445
|
+
{@render errorState(error, onRetry)}
|
|
446
|
+
{:else}
|
|
447
|
+
<div class="plinth-failed-body" role="alert">
|
|
448
|
+
<span>{errorLabel}</span>
|
|
449
|
+
{#if onRetry}
|
|
450
|
+
<button type="button" class="btn btn-sm" onclick={onRetry}>{retryLabel}</button>
|
|
451
|
+
{/if}
|
|
452
|
+
</div>
|
|
453
|
+
{/if}
|
|
454
|
+
</td>
|
|
455
|
+
</tr>
|
|
456
|
+
{:else if placeholders > 0}
|
|
457
|
+
{#each { length: placeholders }, slot (slot)}
|
|
458
|
+
<tr class="plinth-placeholder" aria-hidden="true">
|
|
459
|
+
{#each shown as column (column.key)}
|
|
460
|
+
<td data-align={alignOf(column)} class="plinth-cell">
|
|
461
|
+
<Skeleton class="plinth-placeholder-bar" />
|
|
462
|
+
</td>
|
|
463
|
+
{/each}
|
|
464
|
+
</tr>
|
|
465
|
+
{/each}
|
|
466
|
+
{:else if visible.length === 0}
|
|
358
467
|
<tr>
|
|
359
468
|
<td class="plinth-empty" colspan={shown.length}>
|
|
360
469
|
{#if empty}{@render empty()}{:else}No data{/if}
|
|
361
470
|
</td>
|
|
362
471
|
</tr>
|
|
363
472
|
{:else}
|
|
364
|
-
{#each
|
|
473
|
+
{#each visible as row, position (rowKey(row))}
|
|
474
|
+
{@const index = offset + position}
|
|
365
475
|
<!-- A row is not an interactive element, and dressing it as one would cost the grid
|
|
366
476
|
semantics that make the table navigable in the first place. When a click handler is
|
|
367
477
|
given the row takes focus and answers Enter and Space, which is the reachable part
|
|
@@ -400,8 +510,23 @@ needs those needs a different component.
|
|
|
400
510
|
</table>
|
|
401
511
|
</div>
|
|
402
512
|
|
|
513
|
+
{#if pageSize !== undefined && sorted.length > 0 && !failed}
|
|
514
|
+
<Paginator
|
|
515
|
+
class="plinth-table-paginator"
|
|
516
|
+
pageNumber={currentPage}
|
|
517
|
+
{pageSize}
|
|
518
|
+
{pageSizes}
|
|
519
|
+
{totalPages}
|
|
520
|
+
totalItems={sorted.length}
|
|
521
|
+
onPageChange={(next) => (page = next)}
|
|
522
|
+
{onPageSizeChange}
|
|
523
|
+
labels={paginatorLabels}
|
|
524
|
+
/>
|
|
525
|
+
{/if}
|
|
526
|
+
|
|
403
527
|
<style>
|
|
404
528
|
.plinth-table-wrap {
|
|
529
|
+
position: relative;
|
|
405
530
|
/* The whole breakpoint. Querying the container rather than the viewport is also what lets the
|
|
406
531
|
table be dropped into a narrow box and render its card form on a desktop, which is how the
|
|
407
532
|
showcase demonstrates both at once and how a test reaches either. */
|
|
@@ -427,6 +552,16 @@ needs those needs a different component.
|
|
|
427
552
|
margin-left: auto;
|
|
428
553
|
}
|
|
429
554
|
|
|
555
|
+
:global(.plinth-table-filters) {
|
|
556
|
+
flex: 1 1 16rem;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
:global(.plinth-table-paginator) {
|
|
560
|
+
margin-top: 0.75rem;
|
|
561
|
+
color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
|
|
562
|
+
font-size: 0.8125rem;
|
|
563
|
+
}
|
|
564
|
+
|
|
430
565
|
/* A table with a dragged column is as wide as its columns add up to, rather than as wide as its
|
|
431
566
|
box: stretched to the box, the browser would share the spare width out between the columns —
|
|
432
567
|
the dragged one included — and a drag would never land where the pointer let go. */
|
|
@@ -519,6 +654,61 @@ needs those needs a different component.
|
|
|
519
654
|
cursor: pointer;
|
|
520
655
|
}
|
|
521
656
|
|
|
657
|
+
.plinth-table-progress {
|
|
658
|
+
position: absolute;
|
|
659
|
+
inset: 0 0 auto;
|
|
660
|
+
z-index: 1;
|
|
661
|
+
height: 2px;
|
|
662
|
+
overflow: hidden;
|
|
663
|
+
background: color-mix(in oklch, var(--color-primary) 15%, transparent);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.plinth-table-progress::after {
|
|
667
|
+
content: "";
|
|
668
|
+
position: absolute;
|
|
669
|
+
inset: 0 auto 0 0;
|
|
670
|
+
width: 35%;
|
|
671
|
+
background: var(--color-primary);
|
|
672
|
+
animation: plinth-table-progress 1.1s ease-in-out infinite;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
@keyframes plinth-table-progress {
|
|
676
|
+
from {
|
|
677
|
+
transform: translateX(-100%);
|
|
678
|
+
}
|
|
679
|
+
to {
|
|
680
|
+
transform: translateX(300%);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
.plinth-refreshing tbody {
|
|
685
|
+
opacity: 0.55;
|
|
686
|
+
transition: opacity 150ms;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
:global(.plinth-placeholder-bar) {
|
|
690
|
+
display: inline-block;
|
|
691
|
+
width: 70%;
|
|
692
|
+
height: 0.85rem;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
.plinth-placeholder td[data-align="right"] :global(.plinth-placeholder-bar) {
|
|
696
|
+
width: 45%;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.plinth-failed {
|
|
700
|
+
padding: 2rem 1rem;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.plinth-failed-body {
|
|
704
|
+
display: flex;
|
|
705
|
+
flex-direction: column;
|
|
706
|
+
align-items: center;
|
|
707
|
+
gap: 0.75rem;
|
|
708
|
+
color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
|
|
709
|
+
text-align: center;
|
|
710
|
+
}
|
|
711
|
+
|
|
522
712
|
.plinth-empty {
|
|
523
713
|
padding: 2rem 1rem;
|
|
524
714
|
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|