@greendrake/ui-data 0.1.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/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +48 -0
- package/src/CrudPanel.vue +305 -0
- package/src/DescriptionList.vue +53 -0
- package/src/InfiniteScrollTable.types.ts +105 -0
- package/src/InfiniteScrollTable.vue +1085 -0
- package/src/MasterDetail.vue +191 -0
- package/src/errorMessage.ts +2 -0
- package/src/formatters.ts +25 -0
- package/src/listLoader.ts +31 -0
- package/src/localFilterDebounce.ts +21 -0
- package/src/main.ts +15 -0
- package/src/useAsyncAction.ts +47 -0
- package/src/useDetailLoader.ts +57 -0
- package/src/useRecordEditor.ts +148 -0
- package/src/useSortState.ts +67 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Greendrake
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# @greendrake/ui-data
|
|
2
|
+
|
|
3
|
+
Data-heavy dashboard components on top of `@greendrake/ui`: a virtualised `InfiniteScrollTable`, the `MasterDetail` split and the `CrudPanel` shell built on it, the `useRecordEditor` diff-and-patch editing convention, and an admin-CRUD kit of loader adapters, sort state, detail/action composables, formatters and `DescriptionList`. Ships as TypeScript + SFC source; the consuming Vite app compiles it.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add @greendrake/ui-data vue
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
`vue` is the peer dependency; `@greendrake/ui` and `@greendrake/scss-kit` come in as dependencies, so `@greendrake/ui`'s own peer `vue-router` must be installed as well.
|
|
12
|
+
|
|
13
|
+
No build step, no `.d.ts`: `exports` points at `src/main.ts` and the consumer's Vite + `vue-tsc` toolchain compiles the SFCs, whose generics (`InfiniteScrollTable<T>`, `CrudPanel<T>`) are written against the `vueCompilerOptions` of `@greendrake/dev-config/tsconfig/vue.json`. A consumer's tsconfig extends it:
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{
|
|
17
|
+
"extends": "@greendrake/dev-config/tsconfig/vue.json",
|
|
18
|
+
"include": ["src"]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Styling contract
|
|
23
|
+
|
|
24
|
+
As for `@greendrake/ui`: the app loads `@greendrake/theme` and defines the app-provided custom properties its README lists. The table and panel additionally read the optional `--cell-padding`, `--list-row-padding`, `--row-border-bottom`, `--row-select-color` and `--row-select-color-hover` from that list, each with a fallback.
|
|
25
|
+
|
|
26
|
+
Layout: `InfiniteScrollTable` measures its own container to decide how many rows to render, so its height must flow top-down. Place it in a flex column whose items have `min-height: 0`, or pass `visibleHeight`; a content-driven height makes the measurement loop until every row is rendered.
|
|
27
|
+
|
|
28
|
+
## InfiniteScrollTable
|
|
29
|
+
|
|
30
|
+
Virtualised table: only the rows that fit the measured viewport (plus two clipped ones) are in the DOM, scrolling is pixel-continuous through a custom scrollbar, the wheel, touch panning with inertia, and the keyboard when `selectable`. Rows arrive through the loader in `bufferSize` windows around the viewport; when a single response holds the whole dataset (`data.length >= total`) the table keeps it in memory and serves text-filter refinements and sorting locally without further calls. Columns are resizable by dragging header edges.
|
|
31
|
+
|
|
32
|
+
### Loader contract
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
interface LoaderParams {
|
|
36
|
+
offset: number
|
|
37
|
+
limit: number
|
|
38
|
+
[key: string]: string | number // every non-empty `filters` entry
|
|
39
|
+
}
|
|
40
|
+
interface LoaderResponse<T> {
|
|
41
|
+
data: T[]
|
|
42
|
+
total: number
|
|
43
|
+
}
|
|
44
|
+
type LoaderFunction<T> = (params: LoaderParams, loading: Ref<boolean>, background: boolean) => Promise<LoaderResponse<T>>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`loading` is the table's spinner flag, handed to the loader to set around its call. The spinner also shows while the viewport overlaps rows not yet buffered, so a loader that never touches `loading` still gets one for gaps.
|
|
48
|
+
|
|
49
|
+
`background` is true when nobody asked for the load — a live binding re-reading after a server push or a dropped connection (`refresh(true)`). What that means for the request is the loader's to decide; with `@greendrake/vue-api` it is `backgroundCall(background)`, a call that shows no spinner and raises no error prompt.
|
|
50
|
+
|
|
51
|
+
`T` must satisfy `Record<string, unknown>`, so declare row types as type aliases (`type User = { … }`), not interfaces: an interface carries no implicit index signature and is rejected where the table's generic is inferred.
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
interface Filters {
|
|
55
|
+
[key: string]: string | null | undefined
|
|
56
|
+
sort_by?: string
|
|
57
|
+
sort_dir?: 'asc' | 'desc'
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
A `filters` change re-fetches from offset 0 unless the whole dataset is in memory and the change is a refinement of the text filter (`textFilterKey`) or a sort change, in which case it is applied locally over `localSearchFields`.
|
|
62
|
+
|
|
63
|
+
### Props
|
|
64
|
+
|
|
65
|
+
| Prop | Type | Default |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `loader` | `LoaderFunction<T>` | required |
|
|
68
|
+
| `columns` | `ColumnDefinition<T>[]` | required |
|
|
69
|
+
| `rowKey` | `RowKey<T>` — field name, or `(row) => string \| number` for composite keys | `'id'` |
|
|
70
|
+
| `rowHeight` | px | 45 |
|
|
71
|
+
| `visibleHeight` | px, or `null` to measure the container | `null` |
|
|
72
|
+
| `filters` | `Filters` | `{}` |
|
|
73
|
+
| `textFilterKey` | the filter key treated as the text search | `'q'` |
|
|
74
|
+
| `localSearchFields` | fields the in-memory text filter matches; `false` sends every text-filter change to the loader | `['title']` |
|
|
75
|
+
| `selectable` | pointer/keyboard row selection (wrapper gets `tabindex="0"`) | `false` |
|
|
76
|
+
| `rowDraggable` | rows are `draggable`, drop targets are tracked | `false` |
|
|
77
|
+
| `hideHeader` | | `false` |
|
|
78
|
+
| `onRowClick` | `(record: T) => void` | — |
|
|
79
|
+
| `bufferSize` | rows fetched per request | 1000 |
|
|
80
|
+
| `renderAll` | render every buffered row (test affordance for datasets within one buffer) | `false` |
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface ColumnDefinition<T> {
|
|
84
|
+
field: string
|
|
85
|
+
label: string
|
|
86
|
+
className?: string // on th and td
|
|
87
|
+
cellClass?: (value: unknown, record: T) => string | undefined
|
|
88
|
+
sortable?: boolean
|
|
89
|
+
sortType?: 'string' | 'number' | 'date' // for local sorting; inferred from the data otherwise
|
|
90
|
+
sortIcon?: string // rendered after the label
|
|
91
|
+
onHeaderClick?: () => void
|
|
92
|
+
onCellClick?: (value: unknown, record: T) => void
|
|
93
|
+
formatter?: (value: unknown, record: T) => string
|
|
94
|
+
wrap?: boolean // multi-line cell instead of single-line ellipsis
|
|
95
|
+
component?: Component // custom cell renderer
|
|
96
|
+
componentProps?: (record: T) => Record<string, unknown>
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Selection: mouse click selects, Shift extends a range, Ctrl/Cmd toggles; touch taps select; Arrow keys move the cursor (Shift extends), Ctrl/Cmd+A selects all.
|
|
101
|
+
|
|
102
|
+
### Emits
|
|
103
|
+
|
|
104
|
+
- `range-changed(range: VisibleRange)` — `{ start, end, total }`, the 1-based window of fully visible rows; emitted immediately and on every change.
|
|
105
|
+
- `rowactivate(record: T)` — double-click.
|
|
106
|
+
- `rowdragstart(record: T, event: DragEvent)`, `rowdrop(event: DragEvent, target: DropTarget<T> | null)` — with `rowDraggable`; `DropTarget` is `{ record, position: 'before' | 'after' }`, the hovered row carrying a `drop-before` / `drop-after` class meanwhile.
|
|
107
|
+
|
|
108
|
+
Slot `empty` replaces the `messages.nothingFound` row.
|
|
109
|
+
|
|
110
|
+
### Exposed
|
|
111
|
+
|
|
112
|
+
Type a template ref as `ref<InfiniteScrollTableExposed<Row>>()`; a generic SFC instance has no nameable `InstanceType`.
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
interface InfiniteScrollTableExposed<T> {
|
|
116
|
+
refresh: (background?: boolean) => Promise<void> // re-fetch around the current position; the promise settles with the load
|
|
117
|
+
patchRow: (keyValue: string | number, data: Partial<T>) => boolean
|
|
118
|
+
addRow: (data: T, position?: RowPosition) => void // 'start' (default) | 'end' | { after: key } | { before: key }
|
|
119
|
+
removeRow: (keyValue: string | number) => void
|
|
120
|
+
removeRows: (keyValues: (string | number)[]) => void
|
|
121
|
+
resort: () => void // re-apply local filter/sort in place (after patching a sorted field)
|
|
122
|
+
getRow: (keyValue: string | number) => T | undefined
|
|
123
|
+
willFilterLocally: (value: string | undefined) => boolean // whether a text-filter change to `value` stays in memory
|
|
124
|
+
selected: Set<string | number> | undefined // the selection when `selectable`
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Row surgery (`patchRow`, `addRow`, `removeRows`) is the way to reflect a single-record change; `refresh()` after one is a wasted round trip.
|
|
129
|
+
|
|
130
|
+
### Example
|
|
131
|
+
|
|
132
|
+
```vue
|
|
133
|
+
<script setup lang="ts">
|
|
134
|
+
import { ref, computed } from 'vue'
|
|
135
|
+
import { InfiniteScrollTable, useSortState, fmtDate, type LoaderFunction, type Filters, type InfiniteScrollTableExposed } from '@greendrake/ui-data'
|
|
136
|
+
|
|
137
|
+
type User = { id: string; email: string; created_at: string }
|
|
138
|
+
|
|
139
|
+
const sort = useSortState('created_at')
|
|
140
|
+
const columns = computed(() => [sort.column<User>('email', 'Email'), sort.column<User>('created_at', 'Created', { formatter: fmtDate })])
|
|
141
|
+
const filters = computed<Filters>(() => sort.sortFilters.value)
|
|
142
|
+
|
|
143
|
+
// listUsers is the app's own call, resolving to { data, total }
|
|
144
|
+
const loader: LoaderFunction<User> = async (params, loading) => {
|
|
145
|
+
loading.value = true
|
|
146
|
+
try {
|
|
147
|
+
return await listUsers(params)
|
|
148
|
+
} finally {
|
|
149
|
+
loading.value = false
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const table = ref<InfiniteScrollTableExposed<User>>()
|
|
153
|
+
</script>
|
|
154
|
+
<template>
|
|
155
|
+
<InfiniteScrollTable ref="table" :loader="loader" :columns="columns" :filters="filters" row-key="id" selectable />
|
|
156
|
+
</template>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## MasterDetail
|
|
160
|
+
|
|
161
|
+
Resizable master pane on the left, tabbed detail panes on the right (a `TabBar` from `@greendrake/ui` with closable tabs). Props: `initialWidth` (default 150), `minWidth` (default 100), `fullWidthUntilTab` (default `false`; the master spans the whole width until a tab opens). Emits `tab-open`, `tab-close`, `tab-activate`, each with the `TabDef`. Slots: `master` receives `{ openTab }`; `detail` receives `{ tab, isActive }` and is rendered once per open tab, inactive panes kept alive with `v-show`. Exposes `openTab(id, label?)` (opens or activates), `closeTab(id)`, `activateTab(id)`, `updateTab(id, newId?, newLabel?)`, `tabs`, `activeTab`, `activeTabId`.
|
|
162
|
+
|
|
163
|
+
## CrudPanel
|
|
164
|
+
|
|
165
|
+
Master/detail CRUD shell: a controls bar (search field, optional Create button, refresh, visible-range readout, optional Delete button) over an `InfiniteScrollTable` in the master pane, and detail tabs opened by double-clicking a row. A panel without a `detail` slot is table-only: rows select (for deletion) and never open a pane.
|
|
166
|
+
|
|
167
|
+
| Prop | Type | Default |
|
|
168
|
+
| --- | --- | --- |
|
|
169
|
+
| `columns` | `ColumnDefinition<T>[]` | required |
|
|
170
|
+
| `loader` | `LoaderFunction<T>` | required |
|
|
171
|
+
| `rowKey` | `RowKey<T>` | `'id'` |
|
|
172
|
+
| `rowHeight` | px | 28 |
|
|
173
|
+
| `filters` | `Filters`, merged with the search box's `q` | `{}` |
|
|
174
|
+
| `initialWidth`, `minWidth`, `fullWidthUntilTab` | forwarded to `MasterDetail` | 200, 120, `true` |
|
|
175
|
+
| `showRange` | render the `start-end/total` readout | `true` |
|
|
176
|
+
| `localSearchFields` | `string[] \| false \| null`; `null` keeps the table's default | `null` |
|
|
177
|
+
| `creatable` | Create button, opens a `new:<n>` tab | `false` |
|
|
178
|
+
| `newTabLabel` | label of that tab | `messages.newTab` |
|
|
179
|
+
| `onDelete` | `(keys: Key[]) => Promise<unknown>`; enables the Delete button for the selection | `null` |
|
|
180
|
+
| `deleteConfirmMessage` | when set, deletion asks first in a `Modal` | `null` |
|
|
181
|
+
| `keyExists` | `(key: Key) => boolean`, backs the `isDupe` slot prop | `null` |
|
|
182
|
+
|
|
183
|
+
The search field's debounce is `localFilterDebounce` over the table: swift when the change resolves in memory, longer when it triggers a fetch. A single column hides the table header.
|
|
184
|
+
|
|
185
|
+
Slots: `controls-left` (after the search field and Create), `controls-middle` (`{ range: VisibleRange }`), `controls-right` (after Delete; `{ selected: Set<Key> | undefined }`), `detail` (`{ tab: TabDef, isNew: boolean, record: T | undefined, isDupe: (original: Key, candidate: Key) => boolean }`). `record` is the live table row for the tab's key (`undefined` for a `new:` tab or a row the table has not seen); `isDupe` flags a candidate key that differs from `original` and already exists, for rename and create forms.
|
|
186
|
+
|
|
187
|
+
Exposed (`CrudPanelExposed<T>`; type the ref as `ref<CrudPanelExposed<Row>>()`): `openTab`, `closeTab`, `updateTab`, `removeRows(keys)` (also clears the selection), `addRow`, `patchRow`, `getRow`, `refresh`, `applySaved(key, record, tabId?)`, and readonly `selected`, `range`, `deleting`.
|
|
188
|
+
|
|
189
|
+
`applySaved` is the post-save hook of the record-edit convention below: it patches the row in place when the key exists, otherwise adds the row and, given the originating `new:` tab's id, renames that tab to the persisted key so the `record` slot prop resolves and the next save updates instead of creates.
|
|
190
|
+
|
|
191
|
+
```vue
|
|
192
|
+
<script setup lang="ts">
|
|
193
|
+
import { ref, computed } from 'vue'
|
|
194
|
+
import { CrudPanel, listLoader, useSortState, type CrudPanelExposed } from '@greendrake/ui-data'
|
|
195
|
+
import HostEditor from './HostEditor.vue'
|
|
196
|
+
|
|
197
|
+
type Host = { name: string; address: string; enabled: boolean }
|
|
198
|
+
|
|
199
|
+
const sort = useSortState('name', 'asc')
|
|
200
|
+
const columns = computed(() => [sort.column<Host>('name', 'Name')])
|
|
201
|
+
// listHosts / deleteHosts are the app's own calls; listHosts resolves to { data, total }
|
|
202
|
+
const loader = listLoader<Host>(args => listHosts(args), sort)
|
|
203
|
+
const crud = ref<CrudPanelExposed<Host>>()
|
|
204
|
+
</script>
|
|
205
|
+
<template>
|
|
206
|
+
<CrudPanel ref="crud" :columns="columns" :loader="loader" row-key="name" creatable :on-delete="keys => deleteHosts(keys)" delete-confirm-message="Delete the selected hosts?">
|
|
207
|
+
<template #detail="{ tab, isNew, record, isDupe }">
|
|
208
|
+
<HostEditor :record="record" :is-new="isNew" :is-dupe="isDupe" @saved="(key, host) => crud?.applySaved(key, host, tab.id)" />
|
|
209
|
+
</template>
|
|
210
|
+
</CrudPanel>
|
|
211
|
+
</template>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
## Record editing
|
|
215
|
+
|
|
216
|
+
`useRecordEditor<T>(options: RecordEditorOptions<T>): RecordEditor<T>` implements the convention `CrudPanel` and `patchRow` expect:
|
|
217
|
+
|
|
218
|
+
1. The editor receives the full record (from a table row, a fetch, etc.).
|
|
219
|
+
2. It keeps `data` (the state the backend has acknowledged) and `workData` (a reactive working copy bound to inputs).
|
|
220
|
+
3. On save only the fields that differ between the two are sent.
|
|
221
|
+
4. `save(patch)` resolves to the full updated record, which overwrites both objects — server-side changes (timestamps, derived fields) are acknowledged and the next diff is correct.
|
|
222
|
+
5. The editor emits `('saved', key, record)`; the host wires it to `CrudPanel.applySaved`.
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
interface RecordEditorOptions<T> {
|
|
226
|
+
initial: T // deep-cloned into data and workData
|
|
227
|
+
save: (patch: Partial<T>) => Promise<T>
|
|
228
|
+
fields?: readonly (keyof T)[] // editable/diffed keys; default: the keys of `initial`
|
|
229
|
+
normalise?: (record: T) => T // canonicalise initial and every response (e.g. undefined vs null)
|
|
230
|
+
}
|
|
231
|
+
interface RecordEditor<T> {
|
|
232
|
+
data: T
|
|
233
|
+
workData: T
|
|
234
|
+
changes: ComputedRef<Partial<T> | null>
|
|
235
|
+
dirty: ComputedRef<boolean>
|
|
236
|
+
saving: Ref<boolean>
|
|
237
|
+
submit: () => Promise<T | null> // null when nothing changed
|
|
238
|
+
reset: () => void // workData ← data
|
|
239
|
+
applyResponse: (updated: T) => void // acknowledge a server update outside submit()
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Equality is by canonical JSON (strings, numbers, booleans, arrays, plain objects). Validation, HTTP and multi-step flows stay with the caller: gate `submit()` behind checks, or build around `changes` / `applyResponse`.
|
|
244
|
+
|
|
245
|
+
```vue
|
|
246
|
+
<script setup lang="ts">
|
|
247
|
+
import { useRecordEditor } from '@greendrake/ui-data'
|
|
248
|
+
import { Checkbox, Field, TextField, AsyncAction } from '@greendrake/ui'
|
|
249
|
+
|
|
250
|
+
type Host = { name: string; address: string; enabled: boolean }
|
|
251
|
+
|
|
252
|
+
const props = defineProps<{ record: Host }>()
|
|
253
|
+
const emit = defineEmits<{ saved: [key: string, record: Host] }>()
|
|
254
|
+
|
|
255
|
+
// updateHost is the app's own call, resolving to the full updated Host
|
|
256
|
+
const { workData, dirty, submit } = useRecordEditor<Host>({
|
|
257
|
+
initial: props.record,
|
|
258
|
+
fields: ['address', 'enabled'],
|
|
259
|
+
save: patch => updateHost(props.record.name, patch)
|
|
260
|
+
})
|
|
261
|
+
const save = async (): Promise<void> => {
|
|
262
|
+
const updated = await submit()
|
|
263
|
+
if (updated) emit('saved', updated.name, updated)
|
|
264
|
+
}
|
|
265
|
+
</script>
|
|
266
|
+
<template>
|
|
267
|
+
<Field label="Address">
|
|
268
|
+
<TextField v-model="workData.address" />
|
|
269
|
+
</Field>
|
|
270
|
+
<Checkbox v-model="workData.enabled" label="Enabled" />
|
|
271
|
+
<AsyncAction :promise-maker="save" :is-dirty="dirty" is-valid label-dirty="Save" label-done="Saved" />
|
|
272
|
+
</template>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Admin-CRUD kit
|
|
276
|
+
|
|
277
|
+
### listLoader
|
|
278
|
+
|
|
279
|
+
`listLoader<T>(fetch: (args: PageArgs, background: boolean) => Promise<LoaderResponse<T>>, sort?: SortState)` adapts a paged list call to the table's `LoaderFunction<T>`:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
type PageArgs = {
|
|
283
|
+
q?: string
|
|
284
|
+
offset?: number
|
|
285
|
+
limit?: number
|
|
286
|
+
sort_by?: string
|
|
287
|
+
sort_dir?: string
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Without `sort` the call receives the bare `offset`/`limit` window; with it, `q` (the text filter) and the current `sort_by`/`sort_dir` as well — for list methods with server-side search and sort. Extra filter fields ride the `fetch` closure, and so does the `background` flag, handed on verbatim: `listLoader((args, background) => listThreads({ ...args, type }, background))`. The returned function ignores the `loading` ref; the table's gap detection drives the spinner.
|
|
292
|
+
|
|
293
|
+
### useSortState
|
|
294
|
+
|
|
295
|
+
`useSortState(initialBy: string, initialDir: SortDir = 'desc', defaultDir: SortDir | ((field: string) => SortDir) = 'asc'): SortState`
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
interface SortState {
|
|
299
|
+
sortBy: Ref<string>
|
|
300
|
+
sortDir: Ref<SortDir> // 'asc' | 'desc'
|
|
301
|
+
toggleSort: (field: string) => void // same field flips; a new field takes over with defaultDir
|
|
302
|
+
sortIcon: (field: string) => string | undefined // '▲' / '▼' for the active field
|
|
303
|
+
sortFilters: ComputedRef<{ sort_by: string; sort_dir: SortDir }> // spread into Filters
|
|
304
|
+
column: <T>(field: string, label: string, options?: ColumnOptions<T>) => ColumnDefinition<T>
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`column` builds a `ColumnDefinition` whose header click toggles the sort and whose icon reflects it. `ColumnOptions<T>` is `ColumnDefinition<T>` minus `field`/`label`/`sortable`/`sortIcon`/`onHeaderClick`, plus `sortKey?: string | false` — the field sent to the backend when it differs from `field`, or `false` for an unsortable column. Build the columns array inside a `computed` so icons track state.
|
|
309
|
+
|
|
310
|
+
### localFilterDebounce
|
|
311
|
+
|
|
312
|
+
`localFilterDebounce(table: Ref<LocalFilterable | undefined>): (value: string | undefined) => number` — a `SearchField` `debounce` resolver: 250ms when the table reports it can serve the change from memory (`willFilterLocally`), 600ms otherwise (and before the table mounts). `LocalFilterable` is the `{ willFilterLocally }` slice of `InfiniteScrollTableExposed`.
|
|
313
|
+
|
|
314
|
+
### useDetailLoader
|
|
315
|
+
|
|
316
|
+
`useDetailLoader<T, Id>(id: WatchSource<Id>, fetcher: (id: Id) => Promise<T>): DetailLoader<T>` — the detail-pane fetch skeleton: loads immediately, reloads whenever `id` changes, discards responses that arrive after the id moved on. Returns `{ data: Ref<T | null>, loading, error: Ref<string | null>, reload(), mutate(fn: (cur: T) => void) }`. A rejection's message lands in `error` for in-pane display, so pass a fetcher that does not report errors globally itself. `mutate` folds a known local change into the loaded value without a re-fetch.
|
|
317
|
+
|
|
318
|
+
### useAsyncAction
|
|
319
|
+
|
|
320
|
+
`useAsyncAction<Args extends unknown[]>(fn: (...args: Args) => Promise<string | void>): AsyncAction<Args>` — in-flight/error/notice bookkeeping for an action button. Returns `{ acting, error, notice, run(...args) }`: `run` ignores re-entry while acting, clears `error`/`notice`, captures a rejection's message in `error`, and stores a string result as the success `notice`. Buttons sharing one in-flight state parameterise `fn` and pass the variant to `run`.
|
|
321
|
+
|
|
322
|
+
### errorMessage
|
|
323
|
+
|
|
324
|
+
`errorMessage(e: unknown): string` — `e.message` for an `Error`, `String(e)` otherwise.
|
|
325
|
+
|
|
326
|
+
### Formatters
|
|
327
|
+
|
|
328
|
+
- `fmtDate(v: unknown): string` — `'YYYY-MM-DD HH:MM:SSZ'` in UTC from an ISO/`'YYYY-MM-DD HH:MM:SS'` string (a zoneless string is taken as UTC), a number or a `Date`; `''` for `null`/`undefined`/`''`; throws on other types.
|
|
329
|
+
- `fmtBool(v: unknown): string` — `'✓'` or `''`, for table cells.
|
|
330
|
+
- `fmtYesNo(v: unknown): string` — `'yes'` / `'no'`, for detail prose.
|
|
331
|
+
|
|
332
|
+
### DescriptionList
|
|
333
|
+
|
|
334
|
+
Label/value grid for detail panes. Prop `items: (DescriptionItem | false | null | undefined)[]` — falsy entries are skipped, so conditional rows inline as `cond && { … }`. `DescriptionItem` is `{ label: string; value: string | number | null | undefined; code?: boolean }`; `code` renders the value in `<code>`. Values arrive display-ready.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greendrake/ui-data",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Data-heavy dashboard components: virtualized InfiniteScrollTable, CrudPanel/MasterDetail, record editing and the admin-CRUD kit",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Eugene Greendrake <eugene@greendrake.nz>",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/greendrake/lib.git",
|
|
10
|
+
"directory": "ui-data"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/greendrake/lib/tree/main/ui-data#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/greendrake/lib/issues"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"main": "src/main.ts",
|
|
18
|
+
"types": "src/main.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/main.ts",
|
|
22
|
+
"default": "./src/main.ts"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"check": "vue-tsc --noEmit"
|
|
33
|
+
},
|
|
34
|
+
"prettier": "@greendrake/dev-config/prettier",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@greendrake/scss-kit": "0.1.0",
|
|
37
|
+
"@greendrake/ui": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"vue": "^3.5.34"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@greendrake/dev-config": "0.1.0",
|
|
44
|
+
"typescript": "6.0.3",
|
|
45
|
+
"vue": "^3.5.34",
|
|
46
|
+
"vue-tsc": "^2.2.0"
|
|
47
|
+
}
|
|
48
|
+
}
|