@tradalab/lyra 3.6.2 → 3.8.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 +166 -131
- package/dist/data-table/index.d.ts +135 -2
- package/dist/data-table/index.js +132 -8
- package/dist/data-table/index.js.map +1 -1
- package/dist/shell/index.d.ts +9 -5
- package/dist/shell/index.js +1 -1
- package/dist/shell/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,131 +1,166 @@
|
|
|
1
|
-
# @tradalab/lyra
|
|
2
|
-
|
|
3
|
-
> Tradalab's UI kit — shadcn (`radix-lyra` style) primitives, composed
|
|
4
|
-
> components, and an app shell for **React 19 / Next 16 / Tailwind v4**.
|
|
5
|
-
|
|
6
|
-
ESM-only. Extracted from Tradalab's foundation kit so every company app shares
|
|
7
|
-
the same component surface.
|
|
8
|
-
|
|
9
|
-
## Install
|
|
10
|
-
|
|
11
|
-
```bash
|
|
12
|
-
pnpm add @tradalab/lyra
|
|
13
|
-
# peers: react ^19, react-dom ^19 (next ^16 only if you use /shell)
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
The stylesheet pulls in Tailwind v4, so your app needs `@tailwindcss/postcss`.
|
|
17
|
-
|
|
18
|
-
```css
|
|
19
|
-
/* app/globals.css */
|
|
20
|
-
@import "@tradalab/lyra/styles.css";
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
## Subpaths
|
|
24
|
-
|
|
25
|
-
| Subpath | Exports | Boundary |
|
|
26
|
-
|---|---|---|
|
|
27
|
-
| `@tradalab/lyra/utils` | `cn` | server-safe |
|
|
28
|
-
| `@tradalab/lyra/hooks` | `useMobile` | client |
|
|
29
|
-
| `@tradalab/lyra/ui` | 60 shadcn primitives + `AccountMenu`, `ActionButton`, `Container`, `EmptyState`, `StatusBadge` | client |
|
|
30
|
-
| `@tradalab/lyra/blocks` | `Form`/`FormField` + RHF glue, `Panel`, `Confirm`/`useConfirm`, `Tree`, `TzPicker`, `OptionPicker`, `McpSettings`, `LlmSettings`, `PlatePicker`, `usePlate`, `useCompact`, `PreferenceScript` | client |
|
|
31
|
-
| `@tradalab/lyra/
|
|
32
|
-
| `@tradalab/lyra/
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
import {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
`
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
1
|
+
# @tradalab/lyra
|
|
2
|
+
|
|
3
|
+
> Tradalab's UI kit — shadcn (`radix-lyra` style) primitives, composed
|
|
4
|
+
> components, and an app shell for **React 19 / Next 16 / Tailwind v4**.
|
|
5
|
+
|
|
6
|
+
ESM-only. Extracted from Tradalab's foundation kit so every company app shares
|
|
7
|
+
the same component surface.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pnpm add @tradalab/lyra
|
|
13
|
+
# peers: react ^19, react-dom ^19 (next ^16 only if you use /shell)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The stylesheet pulls in Tailwind v4, so your app needs `@tailwindcss/postcss`.
|
|
17
|
+
|
|
18
|
+
```css
|
|
19
|
+
/* app/globals.css */
|
|
20
|
+
@import "@tradalab/lyra/styles.css";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Subpaths
|
|
24
|
+
|
|
25
|
+
| Subpath | Exports | Boundary |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `@tradalab/lyra/utils` | `cn` | server-safe |
|
|
28
|
+
| `@tradalab/lyra/hooks` | `useMobile` | client |
|
|
29
|
+
| `@tradalab/lyra/ui` | 60 shadcn primitives + `AccountMenu`, `ActionButton`, `Container`, `EmptyState`, `StatusBadge` | client |
|
|
30
|
+
| `@tradalab/lyra/blocks` | `Form`/`FormField` + RHF glue, `Panel`, `Confirm`/`useConfirm`, `Tree`, `TzPicker`, `OptionPicker`, `McpSettings`, `LlmSettings`, `PlatePicker`, `usePlate`, `useCompact`, `PreferenceScript` | client |
|
|
31
|
+
| `@tradalab/lyra/data-table` | `DataTable` + `usePager`, `TableFilterBar`, `TableSearch`, `TableReload`, `filterRows`, `pageWindow` | client |
|
|
32
|
+
| `@tradalab/lyra/shell` | `AppShell`, `PageShell`, `PageHeader`, `NavItem`, `NavGroup`, `Breadcrumbs`, `useActiveRoute` | client |
|
|
33
|
+
| `@tradalab/lyra/styles.css` | Tailwind v4 base + `radix-lyra` tokens (light/dark) | CSS |
|
|
34
|
+
|
|
35
|
+
Client subpaths carry a build-injected `"use client"`; import them inside a
|
|
36
|
+
client tree or pass via `children` — don't import them into a Server Component's
|
|
37
|
+
own module.
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { Button, Card, CardHeader } from "@tradalab/lyra/ui"
|
|
41
|
+
import { AppShell, NavItem } from "@tradalab/lyra/shell"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Tables: four modes, and the list decides which
|
|
45
|
+
|
|
46
|
+
`DataTable` owns the controls; `usePager` (3.8.0) owns the wiring that every console used to
|
|
47
|
+
write out by hand. Pick the mode from what is behind the list, not from habit.
|
|
48
|
+
|
|
49
|
+
| Mode | Pass | For |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| numbered | `{...pager.bind(total)}` | the server pages it and returns a `total` - a list somebody **audits** |
|
|
52
|
+
| cursor | `onLoadMore` + `hasMore` + `loadingMore` | the store only gives a continuation token and no count |
|
|
53
|
+
| client-paged | `{...pager.slice(rows).props}` | the fetch IS the whole answer: a list derived from two endpoints, one operation's result |
|
|
54
|
+
| unpaged | `unpaged` | the row count does not grow with tenants or with time - a declared inventory, a fixed catalogue |
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
const pager = usePager({ pageSize: 25, labels }) // labels come from the app's i18n
|
|
58
|
+
const { data } = useThings({ page: pager.page, pageSize: pager.size })
|
|
59
|
+
|
|
60
|
+
<TableFilterBar actions={<TableReload onClick={refetch} loading={isFetching} />}>
|
|
61
|
+
<TableSearch onChange={pager.onFilter(setQuery)} placeholder="Name or code" />
|
|
62
|
+
</TableFilterBar>
|
|
63
|
+
<DataTable columns={columns} data={data?.things ?? []} {...pager.bind(data?.total ?? 0)} />
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Two rules the components enforce rather than document:
|
|
67
|
+
|
|
68
|
+
- **`TableFilterBar` replaces `DataTable`'s own bar** (`searchable`/`toolbar`/`onReload`). Two
|
|
69
|
+
bars on one table is how a screen ends up with two search boxes that search different things.
|
|
70
|
+
- **`searchable` stays off on anything the server pages.** It filters the rows already in the
|
|
71
|
+
browser, so it answers "no results" about a row on page three. Send the query instead;
|
|
72
|
+
`filterRows` is for the unpaged case, where local filtering is the truth.
|
|
73
|
+
|
|
74
|
+
lyra ships **no copy and no number formatting**: `usePager` takes `labels`, including
|
|
75
|
+
`range: ({from, to, total}) => string`. That keeps `next-intl` out of the library, which matters
|
|
76
|
+
because `data-table` must stay loadable by a consumer that is not a Next app.
|
|
77
|
+
|
|
78
|
+
## Toast
|
|
79
|
+
|
|
80
|
+
`2.0.0` replaced the sonner-based toast with shadcn's Base UI Toast. `sonner` is
|
|
81
|
+
gone — it is no longer a peer dependency, and apps should drop it.
|
|
82
|
+
|
|
83
|
+
`toast` is a **manager object, not a callable**:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { Toaster, toast } from "@tradalab/lyra/ui"
|
|
87
|
+
|
|
88
|
+
toast.add({ title: "Saved" })
|
|
89
|
+
toast.add({ title: "Upload failed", description: err.message, type: "error" })
|
|
90
|
+
const id = toast.add({ title: "Uploading…", type: "loading", timeout: 0 })
|
|
91
|
+
toast.close(id)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`type` is `"success" | "error" | "info" | "warning" | "loading"` — there is no
|
|
95
|
+
`toast.success("x")`. `timeout: 0` never auto-dismisses (sonner's `duration:
|
|
96
|
+
Infinity`). Position belongs to the viewport this package styles, so sonner's
|
|
97
|
+
`position` and `richColors` props have no equivalent. Mount `<Toaster/>` once —
|
|
98
|
+
apps on `@tradalab/nekit` already get it from `NekitProvider`.
|
|
99
|
+
|
|
100
|
+
## Plate and density
|
|
101
|
+
|
|
102
|
+
`3.4.0` added a palette the reader picks. The colours live in `styles.css` behind
|
|
103
|
+
`[data-plate="<name>"]`, so an app only wires the choice:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import { PlatePicker, PreferenceScript, useCompact, usePlate } from "@tradalab/lyra/blocks"
|
|
107
|
+
|
|
108
|
+
// In the root layout, as early in the tree as possible.
|
|
109
|
+
<PreferenceScript />
|
|
110
|
+
|
|
111
|
+
// On a settings screen.
|
|
112
|
+
const [plate, setPlate] = usePlate()
|
|
113
|
+
<PlatePicker value={plate} onChange={setPlate} label="Accent colour" resetLabel="Default" />
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`usePlate` and `useCompact` remember the choice per DEVICE, in `localStorage`
|
|
117
|
+
under `lyra.plate` and `lyra.compact`, which is the same granularity as the
|
|
118
|
+
theme. Absence means the default, so turning one off removes its key rather than
|
|
119
|
+
storing "off".
|
|
120
|
+
|
|
121
|
+
Keep `suppressHydrationWarning` on `<html>`. The script writes attributes the
|
|
122
|
+
server did not render, which is the same reason `next-themes` needs it; every
|
|
123
|
+
tradalab layout and `template-web` already carry it.
|
|
124
|
+
|
|
125
|
+
Leaving out `<PreferenceScript />` is neither a compile error nor a runtime one.
|
|
126
|
+
The attributes then arrive from an effect, so the first paint is always the
|
|
127
|
+
neutral default and every reader who chose a colour watches it flip. It has to be
|
|
128
|
+
a raw inline script to beat that paint, which is why lyra renders it instead of
|
|
129
|
+
leaving it to each app.
|
|
130
|
+
|
|
131
|
+
## Develop
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
make install # pnpm install
|
|
135
|
+
make build # tsup + inject-use-client + verify-build-output
|
|
136
|
+
make verify # typecheck + lint + test
|
|
137
|
+
make ui-add <name> # scaffold a shadcn primitive, adapt, rebuild barrel
|
|
138
|
+
make ui-update <name> # overwrite a primitive with the latest upstream version
|
|
139
|
+
make ui-sync # re-adapt every primitive + rebuild barrel
|
|
140
|
+
make ui-diff # report which primitives changed upstream (exit 1 if any)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Staying current with shadcn** — `make ui-diff` compares every primitive
|
|
144
|
+
against the live `radix-lyra` registry and exits non-zero when one has moved.
|
|
145
|
+
It normalises away everything the CLI would rewrite anyway (icon-library
|
|
146
|
+
placeholders, `cn-*` template classes, import aliases, prettier line-wrapping),
|
|
147
|
+
so what it prints is real change. Prefer it over `shadcn add --diff`, which
|
|
148
|
+
caps its output at five files per run.
|
|
149
|
+
|
|
150
|
+
A primitive lyra deliberately diverges from carries a `customised` note in
|
|
151
|
+
`primitives.lock.json`; `ui-diff` lists those separately instead of failing on
|
|
152
|
+
them forever. Document the divergence in a JSDoc block in the file too —
|
|
153
|
+
`make ui-update` will overwrite it, and that note is what tells the next person
|
|
154
|
+
to put it back.
|
|
155
|
+
|
|
156
|
+
**Layout** — `src/<layer>/` with a barrel `index.ts`; one tsup entry per
|
|
157
|
+
subpath; ESM-only to `dist/`. **Layers** (enforced by `eslint-plugin-boundaries`):
|
|
158
|
+
`utils`/`hooks` (foundation) → `ui` → `shell`. No upward imports.
|
|
159
|
+
|
|
160
|
+
**`"use client"` is not in source** — it is injected onto the client entry
|
|
161
|
+
artifacts post-build by `scripts/inject-use-client.mjs`, then asserted by
|
|
162
|
+
`scripts/verify-build-output.mjs`. `scripts/sync-ui.mjs` adapts shadcn-CLI
|
|
163
|
+
output to repo conventions.
|
|
164
|
+
|
|
165
|
+
**Release** — `pnpm publish --no-git-checks` (the `prepublishOnly` hook builds
|
|
166
|
+
first). Published to the npm public registry.
|
|
@@ -3,6 +3,129 @@ import { ReactNode, HTMLAttributes } from 'react';
|
|
|
3
3
|
import { ColumnDef, Row, Cell, Column, Table, Header, HeaderGroup } from '@tanstack/react-table';
|
|
4
4
|
export { ColumnDef } from '@tanstack/react-table';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Case-insensitive search over chosen fields, for a list the server does NOT page.
|
|
8
|
+
*
|
|
9
|
+
* Honest only there: when the fetch is the whole answer, filtering here searches everything
|
|
10
|
+
* the reader could have seen. On a server-paged list the same code searches one page and then
|
|
11
|
+
* reports "no results" about rows it never had - which is why those lists send the query to
|
|
12
|
+
* the server instead.
|
|
13
|
+
*
|
|
14
|
+
* Takes a field selector rather than key names so a derived value (a joined label, a formatted
|
|
15
|
+
* code) is searchable too - on the tables this exists for, the derived column is usually the
|
|
16
|
+
* one people type into the box.
|
|
17
|
+
*/
|
|
18
|
+
declare function filterRows<T>(rows: T[], q: string, fields: (r: T) => (string | undefined | null)[]): T[];
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The row above a table: narrowing on the left, actions on the right.
|
|
22
|
+
*
|
|
23
|
+
* This REPLACES `DataTable`'s own bar (`searchable` + `toolbar` + `onReload`) rather than
|
|
24
|
+
* sitting beside it. Two bars on one table is how a screen ends up with two search boxes that
|
|
25
|
+
* search different things, and `searchable` in particular must stay off on a server-paged
|
|
26
|
+
* table: it filters the rows already in the browser, so it answers "no results" about a row
|
|
27
|
+
* that is on page three.
|
|
28
|
+
*/
|
|
29
|
+
declare function TableFilterBar({ children, actions, className, }: {
|
|
30
|
+
children?: ReactNode;
|
|
31
|
+
actions?: ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
}): React.JSX.Element;
|
|
34
|
+
/**
|
|
35
|
+
* A search box that reports the settled value, debounced here so no call site has to remember.
|
|
36
|
+
*
|
|
37
|
+
* 300ms, because on a server-paged list every keystroke is both a round trip and a new query
|
|
38
|
+
* key - and a new query key throws away the page already loaded.
|
|
39
|
+
*/
|
|
40
|
+
declare function TableSearch({ onChange, placeholder, className, icon, }: {
|
|
41
|
+
/** Called with the trimmed value once typing settles. */
|
|
42
|
+
onChange: (q: string) => void;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
className?: string;
|
|
45
|
+
/** Set false where the surrounding bar already says what the box is for. */
|
|
46
|
+
icon?: boolean;
|
|
47
|
+
}): React.JSX.Element;
|
|
48
|
+
/** Refetch, spinning while it does. Belongs in the filter bar, not in DataTable's own bar. */
|
|
49
|
+
declare function TableReload({ onClick, loading, label, }: {
|
|
50
|
+
onClick: () => void;
|
|
51
|
+
loading?: boolean;
|
|
52
|
+
label?: string;
|
|
53
|
+
}): React.JSX.Element;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The state behind `DataTable`'s numbered pager, so a screen configures paging instead of
|
|
57
|
+
* owning it.
|
|
58
|
+
*
|
|
59
|
+
* Every console that grew a numbered table had written the same twelve lines - page state,
|
|
60
|
+
* size state, the 0-based/1-based conversion, the range string, the back-to-page-1 wrapper -
|
|
61
|
+
* and the copies had already drifted into pagers that behaved slightly differently on screens
|
|
62
|
+
* next to each other. `DataTable` owned the controls; nothing owned the wiring.
|
|
63
|
+
*
|
|
64
|
+
* Two shapes, because the lists behind these tables are not all the same:
|
|
65
|
+
*
|
|
66
|
+
* `bind(total)` the SERVER paged it. Feed `page`/`size` into the request and hand back the
|
|
67
|
+
* `total` it answered with.
|
|
68
|
+
* `slice(rows)` the fetch IS the whole answer - a list derived from two endpoints, the
|
|
69
|
+
* result of one operation, a parsed file - so the page is cut here.
|
|
70
|
+
*
|
|
71
|
+
* `slice` is honest only in that second case. Over a list the server truncated it reports a
|
|
72
|
+
* page count that does not exist, which tells the reader they have seen everything.
|
|
73
|
+
*/
|
|
74
|
+
/** Sizes offered in the rows-per-page selector. */
|
|
75
|
+
declare const PAGE_SIZE_OPTIONS: number[];
|
|
76
|
+
/** Rows per page before anyone chooses. Smaller than a screenful is churn; larger buries the pager. */
|
|
77
|
+
declare const DEFAULT_PAGE_SIZE = 25;
|
|
78
|
+
/**
|
|
79
|
+
* Copy for the pager. lyra ships none of it: `range` also decides number formatting, which is
|
|
80
|
+
* a locale decision the app's i18n already makes.
|
|
81
|
+
*/
|
|
82
|
+
type PagerLabels = {
|
|
83
|
+
first?: string;
|
|
84
|
+
previous?: string;
|
|
85
|
+
next?: string;
|
|
86
|
+
last?: string;
|
|
87
|
+
size?: string;
|
|
88
|
+
range?: (r: {
|
|
89
|
+
from: number;
|
|
90
|
+
to: number;
|
|
91
|
+
total: number;
|
|
92
|
+
}) => string;
|
|
93
|
+
};
|
|
94
|
+
/** The `DataTable` props that make up the numbered pager. Spread them; never spell them out. */
|
|
95
|
+
type PagerProps = {
|
|
96
|
+
pageIndex: number;
|
|
97
|
+
pageCount: number;
|
|
98
|
+
onPageChange: (index: number) => void;
|
|
99
|
+
pageSize: number;
|
|
100
|
+
pageSizeOptions: number[];
|
|
101
|
+
onPageSizeChange: (size: number) => void;
|
|
102
|
+
rangeLabel: string;
|
|
103
|
+
pageLabels: {
|
|
104
|
+
first?: string;
|
|
105
|
+
previous?: string;
|
|
106
|
+
next?: string;
|
|
107
|
+
last?: string;
|
|
108
|
+
size?: string;
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
type UsePagerOptions = {
|
|
112
|
+
pageSize?: number;
|
|
113
|
+
pageSizeOptions?: number[];
|
|
114
|
+
labels?: PagerLabels;
|
|
115
|
+
};
|
|
116
|
+
declare function usePager(options?: UsePagerOptions): {
|
|
117
|
+
page: number;
|
|
118
|
+
size: number;
|
|
119
|
+
setPage: React.Dispatch<React.SetStateAction<number>>;
|
|
120
|
+
onFilter: <T>(apply: (v: T) => void) => (v: T) => void;
|
|
121
|
+
bind: (total: number) => PagerProps;
|
|
122
|
+
/** Cut the current page out of a complete list. */
|
|
123
|
+
slice<T>(rows: T[]): {
|
|
124
|
+
rows: T[];
|
|
125
|
+
props: PagerProps;
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
6
129
|
declare const TableContext: React.Context<{
|
|
7
130
|
data: unknown[];
|
|
8
131
|
columns: ColumnDef<unknown, unknown>[];
|
|
@@ -83,6 +206,16 @@ interface DataTableProps<TData, TValue> {
|
|
|
83
206
|
nextLabel?: string;
|
|
84
207
|
/** Rows per page (default 10). Pagination controls appear only when needed. */
|
|
85
208
|
pageSize?: number;
|
|
209
|
+
/**
|
|
210
|
+
* Render every row and no pager at all.
|
|
211
|
+
*
|
|
212
|
+
* For the list whose row count does not grow with tenants or with time - a declared
|
|
213
|
+
* inventory, a fixed catalogue, the handful of providers somebody configured by hand. There
|
|
214
|
+
* the fetch is the whole answer, so a pager is a control nobody needs and a page count is a
|
|
215
|
+
* number nobody asked for. Opt-in rather than the default, because the default (slice by
|
|
216
|
+
* `pageSize`, Prev/Next) is what eleven existing call sites already rely on.
|
|
217
|
+
*/
|
|
218
|
+
unpaged?: boolean;
|
|
86
219
|
/** Extra classes on the bordered table wrapper. */
|
|
87
220
|
className?: string;
|
|
88
221
|
/** Show a search box that globally filters rows across all accessor columns. */
|
|
@@ -134,6 +267,6 @@ interface DataTableProps<TData, TValue> {
|
|
|
134
267
|
* reader opened it with.
|
|
135
268
|
*/
|
|
136
269
|
declare function pageWindow(index: number, count: number, radius?: number): (number | null)[];
|
|
137
|
-
declare function DataTable<TData, TValue>({ columns, data, loading, emptyText, empty, loadingText, previousLabel, nextLabel, pageSize, className, searchable, searchPlaceholder, onReload, reloadLabel, toolbar, onLoadMore, hasMore, loadingMore, loadingMoreText, pageIndex, pageCount, onPageChange, pageSizeOptions, onPageSizeChange, rangeLabel, pageLabels, }: DataTableProps<TData, TValue>): React.JSX.Element;
|
|
270
|
+
declare function DataTable<TData, TValue>({ columns, data, loading, emptyText, empty, loadingText, previousLabel, nextLabel, pageSize, unpaged, className, searchable, searchPlaceholder, onReload, reloadLabel, toolbar, onLoadMore, hasMore, loadingMore, loadingMoreText, pageIndex, pageCount, onPageChange, pageSizeOptions, onPageSizeChange, rangeLabel, pageLabels, }: DataTableProps<TData, TValue>): React.JSX.Element;
|
|
138
271
|
|
|
139
|
-
export { DataTable, type DataTableProps, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableColumnHeader, type TableColumnHeaderProps, TableContext, TableHead, type TableHeadProps, TableHeader, TableHeaderGroup, type TableHeaderGroupProps, type TableHeaderProps, TableProvider, type TableProviderProps, TableRow, type TableRowProps, pageWindow };
|
|
272
|
+
export { DEFAULT_PAGE_SIZE, DataTable, type DataTableProps, PAGE_SIZE_OPTIONS, type PagerLabels, type PagerProps, TableBody, type TableBodyProps, TableCell, type TableCellProps, TableColumnHeader, type TableColumnHeaderProps, TableContext, TableFilterBar, TableHead, type TableHeadProps, TableHeader, TableHeaderGroup, type TableHeaderGroupProps, type TableHeaderProps, TableProvider, type TableProviderProps, TableReload, TableRow, type TableRowProps, TableSearch, type UsePagerOptions, filterRows, pageWindow, usePager };
|
package/dist/data-table/index.js
CHANGED
|
@@ -1,13 +1,134 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { TableHead, Table, TableRow, TableHeader, TableCell, TableBody, NativeSelect, NativeSelectOption } from '../chunk-AM6UJ666.js';
|
|
3
3
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '../chunk-VEWKPOMS.js';
|
|
4
|
-
import {
|
|
4
|
+
import { Input, Button } from '../chunk-VGOGWZCF.js';
|
|
5
5
|
import { cn } from '../chunk-XH55BHUU.js';
|
|
6
6
|
import { flexRender, useReactTable, getSortedRowModel, getCoreRowModel, getPaginationRowModel, getFilteredRowModel } from '@tanstack/react-table';
|
|
7
|
-
import {
|
|
8
|
-
import { createContext, memo, useState,
|
|
7
|
+
import { SearchIcon, RefreshCwIcon, ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, Loader2Icon } from 'lucide-react';
|
|
8
|
+
import { createContext, memo, useState, useRef, useEffect, useCallback, useContext } from 'react';
|
|
9
9
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
10
10
|
|
|
11
|
+
// src/data-table/filter-rows.ts
|
|
12
|
+
function filterRows(rows, q, fields) {
|
|
13
|
+
const needle = q.trim().toLowerCase();
|
|
14
|
+
if (!needle) return rows;
|
|
15
|
+
return rows.filter((r) => fields(r).some((v) => v?.toLowerCase().includes(needle)));
|
|
16
|
+
}
|
|
17
|
+
function TableFilterBar({
|
|
18
|
+
children,
|
|
19
|
+
actions,
|
|
20
|
+
className
|
|
21
|
+
}) {
|
|
22
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap items-center gap-2", className), children: [
|
|
23
|
+
children,
|
|
24
|
+
actions ? /* @__PURE__ */ jsx("div", { className: "ml-auto flex items-center gap-2", children: actions }) : null
|
|
25
|
+
] });
|
|
26
|
+
}
|
|
27
|
+
function TableSearch({
|
|
28
|
+
onChange,
|
|
29
|
+
placeholder = "Search\u2026",
|
|
30
|
+
className,
|
|
31
|
+
icon = true
|
|
32
|
+
}) {
|
|
33
|
+
const [text, setText] = useState("");
|
|
34
|
+
const sink = useRef(onChange);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
sink.current = onChange;
|
|
37
|
+
}, [onChange]);
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const id = setTimeout(() => sink.current(text.trim()), 300);
|
|
40
|
+
return () => clearTimeout(id);
|
|
41
|
+
}, [text]);
|
|
42
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("relative w-full max-w-xs", className), children: [
|
|
43
|
+
icon ? /* @__PURE__ */ jsx(SearchIcon, { className: "text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" }) : null,
|
|
44
|
+
/* @__PURE__ */ jsx(
|
|
45
|
+
Input,
|
|
46
|
+
{
|
|
47
|
+
value: text,
|
|
48
|
+
onChange: (e) => setText(e.target.value),
|
|
49
|
+
placeholder,
|
|
50
|
+
className: icon ? "pl-8" : void 0
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
] });
|
|
54
|
+
}
|
|
55
|
+
function TableReload({
|
|
56
|
+
onClick,
|
|
57
|
+
loading,
|
|
58
|
+
label = "Reload"
|
|
59
|
+
}) {
|
|
60
|
+
return /* @__PURE__ */ jsx(
|
|
61
|
+
Button,
|
|
62
|
+
{
|
|
63
|
+
type: "button",
|
|
64
|
+
variant: "outline",
|
|
65
|
+
size: "icon-sm",
|
|
66
|
+
onClick,
|
|
67
|
+
disabled: loading,
|
|
68
|
+
"aria-label": label,
|
|
69
|
+
title: label,
|
|
70
|
+
children: /* @__PURE__ */ jsx(RefreshCwIcon, { className: cn("size-4", loading && "animate-spin") })
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
var PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
|
|
75
|
+
var DEFAULT_PAGE_SIZE = 25;
|
|
76
|
+
function usePager(options = {}) {
|
|
77
|
+
const { pageSize: initialSize = DEFAULT_PAGE_SIZE, pageSizeOptions = PAGE_SIZE_OPTIONS, labels } = options;
|
|
78
|
+
const [page, setPage] = useState(1);
|
|
79
|
+
const [size, setSize] = useState(initialSize);
|
|
80
|
+
const props = useCallback(
|
|
81
|
+
(total) => ({
|
|
82
|
+
pageIndex: page - 1,
|
|
83
|
+
pageCount: Math.max(1, Math.ceil(total / size)),
|
|
84
|
+
onPageChange: (i) => setPage(i + 1),
|
|
85
|
+
pageSize: size,
|
|
86
|
+
pageSizeOptions,
|
|
87
|
+
// A different size means a different page 1; staying on page 7 of the old size lands
|
|
88
|
+
// past the end of the new one.
|
|
89
|
+
onPageSizeChange: (n) => {
|
|
90
|
+
setSize(n);
|
|
91
|
+
setPage(1);
|
|
92
|
+
},
|
|
93
|
+
rangeLabel: total > 0 ? labels?.range?.({
|
|
94
|
+
from: (page - 1) * size + 1,
|
|
95
|
+
to: Math.min(page * size, total),
|
|
96
|
+
total
|
|
97
|
+
}) ?? "" : "",
|
|
98
|
+
pageLabels: {
|
|
99
|
+
first: labels?.first,
|
|
100
|
+
previous: labels?.previous,
|
|
101
|
+
next: labels?.next,
|
|
102
|
+
last: labels?.last,
|
|
103
|
+
size: labels?.size
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
[page, size, pageSizeOptions, labels]
|
|
107
|
+
);
|
|
108
|
+
const onFilter = useCallback(
|
|
109
|
+
(apply) => (v) => {
|
|
110
|
+
apply(v);
|
|
111
|
+
setPage(1);
|
|
112
|
+
},
|
|
113
|
+
[]
|
|
114
|
+
);
|
|
115
|
+
return {
|
|
116
|
+
page,
|
|
117
|
+
size,
|
|
118
|
+
setPage,
|
|
119
|
+
onFilter,
|
|
120
|
+
bind: props,
|
|
121
|
+
/** Cut the current page out of a complete list. */
|
|
122
|
+
slice(rows) {
|
|
123
|
+
const last = Math.max(1, Math.ceil(rows.length / size));
|
|
124
|
+
const at = Math.min(page, last);
|
|
125
|
+
return {
|
|
126
|
+
rows: rows.slice((at - 1) * size, at * size),
|
|
127
|
+
props: { ...props(rows.length), pageIndex: at - 1 }
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
11
132
|
var TableContext = createContext({
|
|
12
133
|
data: [],
|
|
13
134
|
columns: [],
|
|
@@ -107,6 +228,7 @@ function DataTable({
|
|
|
107
228
|
previousLabel = "Previous",
|
|
108
229
|
nextLabel = "Next",
|
|
109
230
|
pageSize = 10,
|
|
231
|
+
unpaged,
|
|
110
232
|
className,
|
|
111
233
|
searchable,
|
|
112
234
|
searchPlaceholder = "Search\u2026",
|
|
@@ -128,6 +250,7 @@ function DataTable({
|
|
|
128
250
|
const [globalFilter, setGlobalFilter] = useState("");
|
|
129
251
|
const infinite = Boolean(onLoadMore);
|
|
130
252
|
const served = !infinite && Boolean(onPageChange);
|
|
253
|
+
const sliced = !infinite && !served && !unpaged;
|
|
131
254
|
const table = useReactTable({
|
|
132
255
|
data,
|
|
133
256
|
columns,
|
|
@@ -135,9 +258,10 @@ function DataTable({
|
|
|
135
258
|
onGlobalFilterChange: setGlobalFilter,
|
|
136
259
|
getCoreRowModel: getCoreRowModel(),
|
|
137
260
|
getFilteredRowModel: getFilteredRowModel(),
|
|
138
|
-
//
|
|
139
|
-
// the
|
|
140
|
-
|
|
261
|
+
// Only the default finite table slices rows itself. The other three modes each hand the
|
|
262
|
+
// table exactly what to render: infinite every accumulated row, served the one page the
|
|
263
|
+
// server answered with, unpaged the whole list.
|
|
264
|
+
...sliced ? { getPaginationRowModel: getPaginationRowModel(), initialState: { pagination: { pageSize } } } : {}
|
|
141
265
|
});
|
|
142
266
|
const rows = table.getRowModel().rows;
|
|
143
267
|
const showBar = Boolean(searchable || onReload || toolbar);
|
|
@@ -303,13 +427,13 @@ function DataTable({
|
|
|
303
427
|
)
|
|
304
428
|
] })
|
|
305
429
|
] }) : null,
|
|
306
|
-
|
|
430
|
+
sliced && table.getPageCount() > 1 ? /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-2 py-4", children: [
|
|
307
431
|
/* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => table.previousPage(), disabled: !table.getCanPreviousPage(), children: previousLabel }),
|
|
308
432
|
/* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => table.nextPage(), disabled: !table.getCanNextPage(), children: nextLabel })
|
|
309
433
|
] }) : null
|
|
310
434
|
] });
|
|
311
435
|
}
|
|
312
436
|
|
|
313
|
-
export { DataTable, TableBody2 as TableBody, TableCell2 as TableCell, TableColumnHeader, TableContext, TableHead2 as TableHead, TableHeader2 as TableHeader, TableHeaderGroup, TableProvider, TableRow2 as TableRow, pageWindow };
|
|
437
|
+
export { DEFAULT_PAGE_SIZE, DataTable, PAGE_SIZE_OPTIONS, TableBody2 as TableBody, TableCell2 as TableCell, TableColumnHeader, TableContext, TableFilterBar, TableHead2 as TableHead, TableHeader2 as TableHeader, TableHeaderGroup, TableProvider, TableReload, TableRow2 as TableRow, TableSearch, filterRows, pageWindow, usePager };
|
|
314
438
|
//# sourceMappingURL=index.js.map
|
|
315
439
|
//# sourceMappingURL=index.js.map
|