@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.
Files changed (103) hide show
  1. package/README.md +189 -19
  2. package/dist/components/ErrorDisplay.svelte +30 -16
  3. package/dist/components/ErrorDisplay.svelte.d.ts +4 -0
  4. package/dist/components/JsonView.svelte +43 -0
  5. package/dist/components/JsonView.svelte.d.ts +4 -0
  6. package/dist/components/JsonViewFrame.svelte +654 -0
  7. package/dist/components/JsonViewFrame.svelte.d.ts +31 -0
  8. package/dist/components/Paginator.svelte +34 -8
  9. package/dist/components/Paginator.svelte.d.ts +10 -0
  10. package/dist/components/RequestErrorDisplay.svelte +43 -0
  11. package/dist/components/RequestErrorDisplay.svelte.d.ts +11 -0
  12. package/dist/components/Tabs.svelte +204 -0
  13. package/dist/components/Tabs.svelte.d.ts +76 -0
  14. package/dist/components/VirtualJsonView.svelte +67 -0
  15. package/dist/components/VirtualJsonView.svelte.d.ts +4 -0
  16. package/dist/components/index.d.ts +8 -1
  17. package/dist/components/index.js +11 -5
  18. package/dist/components/json-view.d.ts +70 -0
  19. package/dist/components/json-view.js +302 -0
  20. package/dist/components/request-error.d.ts +8 -0
  21. package/dist/components/request-error.js +55 -0
  22. package/dist/components/tabs.d.ts +5 -0
  23. package/dist/components/tabs.js +1 -0
  24. package/dist/http.d.ts +10 -5
  25. package/dist/http.js +34 -6
  26. package/dist/i18n/LocaleToggle.svelte +45 -0
  27. package/dist/i18n/LocaleToggle.svelte.d.ts +48 -0
  28. package/dist/i18n/i18n.svelte.d.ts +56 -0
  29. package/dist/i18n/i18n.svelte.js +94 -0
  30. package/dist/i18n/index.d.ts +3 -0
  31. package/dist/i18n/index.js +7 -0
  32. package/dist/i18n/translate.d.ts +30 -0
  33. package/dist/i18n/translate.js +38 -0
  34. package/dist/index.d.ts +1 -1
  35. package/dist/index.js +1 -1
  36. package/dist/redirect.d.ts +40 -0
  37. package/dist/redirect.js +59 -0
  38. package/dist/routing.svelte.d.ts +1 -0
  39. package/dist/routing.svelte.js +3 -0
  40. package/dist/shell/AppShell.svelte +265 -93
  41. package/dist/shell/AppShell.svelte.d.ts +3 -2
  42. package/dist/shell/NavigationProgress.svelte +85 -0
  43. package/dist/shell/NavigationProgress.svelte.d.ts +31 -0
  44. package/dist/shell/TopBar.svelte +191 -0
  45. package/dist/shell/TopBar.svelte.d.ts +41 -0
  46. package/dist/shell/UserAvatar.svelte +50 -0
  47. package/dist/shell/UserAvatar.svelte.d.ts +14 -0
  48. package/dist/shell/index.d.ts +2 -0
  49. package/dist/shell/index.js +7 -3
  50. package/dist/shell/initials.d.ts +6 -0
  51. package/dist/shell/initials.js +13 -0
  52. package/dist/table/DataTable.svelte +199 -9
  53. package/dist/table/DataTable.svelte.d.ts +28 -5
  54. package/dist/table/FilterBar.svelte +417 -0
  55. package/dist/table/FilterBar.svelte.d.ts +34 -0
  56. package/dist/table/FilterRuleEditor.svelte +290 -0
  57. package/dist/table/FilterRuleEditor.svelte.d.ts +33 -0
  58. package/dist/table/filter-labels.d.ts +23 -0
  59. package/dist/table/filter-labels.js +30 -0
  60. package/dist/table/filter.d.ts +27 -0
  61. package/dist/table/filter.js +279 -0
  62. package/dist/table/index.d.ts +4 -1
  63. package/dist/table/index.js +5 -2
  64. package/dist/table/types.d.ts +49 -0
  65. package/dist/toast/Toaster.svelte +7 -3
  66. package/dist/toast/index.d.ts +1 -1
  67. package/dist/toast/toast-manager.svelte.d.ts +29 -1
  68. package/dist/toast/toast-manager.svelte.js +61 -1
  69. package/dist/url-state/codec.d.ts +15 -0
  70. package/dist/url-state/codec.js +1 -0
  71. package/dist/url-state/index.d.ts +5 -0
  72. package/dist/url-state/index.js +4 -0
  73. package/dist/url-state/params-codec.d.ts +73 -0
  74. package/dist/url-state/params-codec.js +116 -0
  75. package/dist/url-state/search-params.d.ts +4 -0
  76. package/dist/url-state/search-params.js +28 -0
  77. package/dist/url-state/synced-state.svelte.d.ts +20 -0
  78. package/dist/url-state/synced-state.svelte.js +88 -0
  79. package/dist/url-state/url-store.svelte.d.ts +12 -0
  80. package/dist/url-state/url-store.svelte.js +56 -0
  81. package/dist/virtual/DataList.svelte +215 -0
  82. package/dist/virtual/DataList.svelte.d.ts +52 -0
  83. package/dist/virtual/VirtualList.svelte +150 -0
  84. package/dist/virtual/VirtualList.svelte.d.ts +42 -0
  85. package/dist/virtual/index.d.ts +7 -0
  86. package/dist/virtual/index.js +6 -0
  87. package/dist/virtual/keyed-cache.d.ts +7 -0
  88. package/dist/virtual/keyed-cache.js +20 -0
  89. package/dist/virtual/labels.d.ts +12 -0
  90. package/dist/virtual/labels.js +6 -0
  91. package/dist/virtual/paging.d.ts +5 -0
  92. package/dist/virtual/paging.js +11 -0
  93. package/dist/virtual/range.d.ts +9 -0
  94. package/dist/virtual/range.js +31 -0
  95. package/dist/virtual/size-tree.d.ts +10 -0
  96. package/dist/virtual/size-tree.js +56 -0
  97. package/dist/virtual/virtualizer.svelte.d.ts +38 -0
  98. package/dist/virtual/virtualizer.svelte.js +295 -0
  99. package/dist/virtual/windowed-data.svelte.d.ts +38 -0
  100. package/dist/virtual/windowed-data.svelte.js +139 -0
  101. package/dist/virtual/windowed-virtualizer.svelte.d.ts +9 -0
  102. package/dist/virtual/windowed-virtualizer.svelte.js +22 -0
  103. 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;
@@ -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";
@@ -1,6 +1,10 @@
1
- // The application chrome around a routed page. Everything here reads the routing context rather
2
- // than a tree of its own, so the navigation an app declares once in `routes.ts` is the navigation
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 rows;
122
- return sortRows(rows, column, sort.direction, locale);
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
- It stays short of a data grid — no filtering, no virtualization, no saved views. A table that
250
- needs those needs a different component.
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 sorted.length === 0}
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 sorted as row, index (rowKey(row))}
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);