@tristan2828/ui-foundation 4.45.0 → 4.47.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/conventions/docs/add-an-entity.md +25 -11
- package/conventions/docs/cell-patterns.md +111 -32
- package/conventions/docs/design-language.md +58 -22
- package/dist/components/app/data-table.d.ts +61 -4
- package/dist/components/app/data-table.js +217 -17
- package/dist/components/app/entity-view.d.ts +27 -1
- package/dist/components/app/entity-view.js +16 -9
- package/dist/components/app/key-stats.d.ts +14 -0
- package/dist/components/app/key-stats.js +6 -0
- package/dist/components/app/module-card.d.ts +11 -2
- package/dist/components/app/module-card.js +45 -5
- package/dist/components/app/table-groups.d.ts +12 -0
- package/dist/components/app/table-groups.js +37 -0
- package/dist/hooks/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +1 -0
- package/dist/hooks/use-table-layout.d.ts +1 -0
- package/dist/hooks/use-table-layout.js +2 -1
- package/dist/hooks/use-table-url-state.d.ts +11 -1
- package/dist/hooks/use-table-url-state.js +26 -2
- package/dist/index.d.ts +5 -4
- package/dist/index.js +1 -0
- package/package.json +1 -1
|
@@ -7,3 +7,15 @@ export type GroupRun<T> = RowGroup & {
|
|
|
7
7
|
rows: T[];
|
|
8
8
|
};
|
|
9
9
|
export declare function groupRuns<T>(rows: readonly T[], groupOf: (row: T) => RowGroup): GroupRun<T>[];
|
|
10
|
+
/**
|
|
11
|
+
* Rows grouped by a choice field's value (DataTable's `groupings`): one
|
|
12
|
+
* group per option, in the options' order, then any value the options
|
|
13
|
+
* don't name (in the order first met), then the rows with none under
|
|
14
|
+
* `emptyLabel`. A multi-value field (an array) puts the row under each of
|
|
15
|
+
* its values. Rows keep the server's order inside a group, and a group
|
|
16
|
+
* with no rows on this page never exists.
|
|
17
|
+
*/
|
|
18
|
+
export declare function groupByValues<T>(rows: readonly T[], valueOf: (row: T) => string | readonly string[] | null | undefined, options: readonly {
|
|
19
|
+
value: string;
|
|
20
|
+
label: string;
|
|
21
|
+
}[], emptyLabel: string): GroupRun<T>[];
|
|
@@ -16,3 +16,40 @@ export function groupRuns(rows, groupOf) {
|
|
|
16
16
|
}
|
|
17
17
|
return runs;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Rows grouped by a choice field's value (DataTable's `groupings`): one
|
|
21
|
+
* group per option, in the options' order, then any value the options
|
|
22
|
+
* don't name (in the order first met), then the rows with none under
|
|
23
|
+
* `emptyLabel`. A multi-value field (an array) puts the row under each of
|
|
24
|
+
* its values. Rows keep the server's order inside a group, and a group
|
|
25
|
+
* with no rows on this page never exists.
|
|
26
|
+
*/
|
|
27
|
+
export function groupByValues(rows, valueOf, options, emptyLabel) {
|
|
28
|
+
const byKey = new Map();
|
|
29
|
+
const unknown = [];
|
|
30
|
+
const known = new Set(options.map((option) => option.value));
|
|
31
|
+
// The empty group's key can't collide with a value: values are non-empty.
|
|
32
|
+
const EMPTY = '';
|
|
33
|
+
for (const row of rows) {
|
|
34
|
+
const raw = valueOf(row);
|
|
35
|
+
const values = [...new Set((typeof raw === 'string' ? [raw] : (raw ?? [])).filter((value) => value !== ''))];
|
|
36
|
+
for (const value of values.length > 0 ? values : [EMPTY]) {
|
|
37
|
+
if (value !== EMPTY && !known.has(value) && !byKey.has(value))
|
|
38
|
+
unknown.push(value);
|
|
39
|
+
const group = byKey.get(value);
|
|
40
|
+
if (group)
|
|
41
|
+
group.push(row);
|
|
42
|
+
else
|
|
43
|
+
byKey.set(value, [row]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const run = (key, label) => {
|
|
47
|
+
const groupRows = byKey.get(key);
|
|
48
|
+
return groupRows ? [{ key, label, rows: groupRows }] : [];
|
|
49
|
+
};
|
|
50
|
+
return [
|
|
51
|
+
...options.flatMap((option) => run(option.value, option.label)),
|
|
52
|
+
...unknown.flatMap((value) => run(value, value)),
|
|
53
|
+
...run(EMPTY, emptyLabel),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -13,6 +13,13 @@ export type TableView<F extends string, M extends string> = {
|
|
|
13
13
|
* Applies only while the view is active (see activeViewOf).
|
|
14
14
|
*/
|
|
15
15
|
columns?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The grouping this view turns on, for DataTable's `grouping`: a
|
|
18
|
+
* grouping's id, or '' for None. Pressing the view sets it; a view that
|
|
19
|
+
* leaves it out keeps whatever grouping is on, since a grouping is how
|
|
20
|
+
* the rows are read, not which rows they are.
|
|
21
|
+
*/
|
|
22
|
+
group?: string;
|
|
16
23
|
};
|
|
17
24
|
export declare function serializeSort(next: Sort): string | null;
|
|
18
25
|
export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
|
|
@@ -27,6 +27,7 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
|
|
|
27
27
|
...Object.fromEntries(filterNames.map((name) => [name, view.filters?.[name] ?? ''])),
|
|
28
28
|
...Object.fromEntries(multiFilterNames.map((name) => [name, view.multiFilters?.[name] ?? []])),
|
|
29
29
|
sort: view.sort ? serializeSort(view.sort) : null,
|
|
30
|
+
...(view.group === undefined ? {} : { group: view.group || null }),
|
|
30
31
|
page: null,
|
|
31
32
|
};
|
|
32
33
|
}
|
|
@@ -7,6 +7,7 @@ export type LayoutStore = {
|
|
|
7
7
|
export declare function createLayoutStore(getStorage: () => StorageLike | undefined): LayoutStore;
|
|
8
8
|
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
9
9
|
export declare function resolveLayout(layouts: readonly string[], fromUrl: string | null, remembered: string | null, defaultLayout: string): string;
|
|
10
|
+
export declare const browserStore: LayoutStore;
|
|
10
11
|
export type TableLayoutOptions = {
|
|
11
12
|
/** Every layout id the table has, the table's own `'table'` among them. */
|
|
12
13
|
layouts: readonly string[];
|
|
@@ -43,7 +43,8 @@ export function resolveLayout(layouts, fromUrl, remembered, defaultLayout) {
|
|
|
43
43
|
return remembered;
|
|
44
44
|
return defaultLayout;
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
// This device's remembered picks, shared with useTableUrlState's grouping.
|
|
47
|
+
export const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
47
48
|
/**
|
|
48
49
|
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
49
50
|
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import type { SortingState } from '../components/app/data-table.js';
|
|
2
2
|
import { type TableView } from './table-url-changes.js';
|
|
3
|
-
export
|
|
3
|
+
export type TableUrlStateOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Where the table's grouping (DataTable's `grouping`) is remembered on
|
|
6
|
+
* this device, one key per table (`"my-app:tasks:group"`), the way
|
|
7
|
+
* useTableLayout remembers a layout. Without one it lives only in the URL.
|
|
8
|
+
*/
|
|
9
|
+
groupStorageKey?: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>, { groupStorageKey }?: TableUrlStateOptions): {
|
|
4
12
|
page: number;
|
|
5
13
|
sorting: SortingState;
|
|
6
14
|
filters: Record<F, string>;
|
|
7
15
|
multiFilters: Record<M, string[]>;
|
|
8
16
|
activeView: V | null;
|
|
17
|
+
grouping: string;
|
|
18
|
+
setGrouping: (next: string) => void;
|
|
9
19
|
setPage: (next: number) => void;
|
|
10
20
|
setSorting: (next: SortingState) => void;
|
|
11
21
|
setFilter: (name: F, value: string) => void;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
1
2
|
import { useSearchParams } from 'react-router';
|
|
2
3
|
import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
|
|
4
|
+
import { browserStore } from './use-table-layout.js';
|
|
3
5
|
// A table's page, sort and filters, kept in the URL query string
|
|
4
6
|
// (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
|
|
5
7
|
// component state, so they survive going to the edit form and back, a
|
|
@@ -15,9 +17,22 @@ import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from
|
|
|
15
17
|
// counts as active); pass that view's `columns` to DataTable. A view is
|
|
16
18
|
// pressed with applyView().
|
|
17
19
|
//
|
|
20
|
+
// `grouping` is DataTable's: a grouping's id, or '' for None. It's
|
|
21
|
+
// `?group=` in the URL, then the one remembered under `groupStorageKey`,
|
|
22
|
+
// then None. Like the sort it doesn't change which rows are shown, so it
|
|
23
|
+
// keeps the page and doesn't count toward the active view; a view that
|
|
24
|
+
// names a `group` sets it when pressed.
|
|
25
|
+
//
|
|
18
26
|
// Every setter is one update(), and must stay one: see setFilters.
|
|
19
|
-
export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
|
|
27
|
+
export function useTableUrlState(filterNames, multiFilterNames = [], views = {}, { groupStorageKey } = {}) {
|
|
20
28
|
const [params, setParams] = useSearchParams();
|
|
29
|
+
const subscribe = useCallback((listener) => (groupStorageKey === undefined ? () => { } : browserStore.subscribe(groupStorageKey, listener)), [groupStorageKey]);
|
|
30
|
+
const rememberedGrouping = useSyncExternalStore(subscribe, () => (groupStorageKey === undefined ? null : browserStore.get(groupStorageKey)), () => null);
|
|
31
|
+
const grouping = params.get('group') ?? rememberedGrouping ?? '';
|
|
32
|
+
const rememberGrouping = (next) => {
|
|
33
|
+
if (groupStorageKey !== undefined)
|
|
34
|
+
browserStore.set(groupStorageKey, next);
|
|
35
|
+
};
|
|
21
36
|
const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
|
|
22
37
|
const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
|
|
23
38
|
const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
|
|
@@ -33,6 +48,11 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
|
|
|
33
48
|
filters,
|
|
34
49
|
multiFilters,
|
|
35
50
|
activeView,
|
|
51
|
+
grouping,
|
|
52
|
+
setGrouping: (next) => {
|
|
53
|
+
rememberGrouping(next);
|
|
54
|
+
update({ group: next || null });
|
|
55
|
+
},
|
|
36
56
|
setPage: (next) => update({ page: next > 1 ? String(next) : null }),
|
|
37
57
|
setSorting: (next) => update({ sort: serializeSort(next), page: null }),
|
|
38
58
|
setFilter: (name, value) => update({ [name]: value, page: null }),
|
|
@@ -46,6 +66,10 @@ export function useTableUrlState(filterNames, multiFilterNames = [], views = {})
|
|
|
46
66
|
setMultiFilter: (name, values) => update({ [name]: values, page: null }),
|
|
47
67
|
// Replaces the whole filter + sort state at once — a saved view. See
|
|
48
68
|
// viewToUrlChanges() for why anything the view doesn't set is cleared.
|
|
49
|
-
applyView: (view) =>
|
|
69
|
+
applyView: (view) => {
|
|
70
|
+
if (view.group !== undefined)
|
|
71
|
+
rememberGrouping(view.group);
|
|
72
|
+
update(viewToUrlChanges(filterNames, multiFilterNames, view));
|
|
73
|
+
},
|
|
50
74
|
};
|
|
51
75
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,11 +6,12 @@ export { pickSpinWheelIndex, type SpinWheelItem } from './components/app/spin-wh
|
|
|
6
6
|
export { FillInChoice, FillInQueue, type FillInChoiceProps, type FillInQueueProgress, type FillInQueueProps, } from './components/app/fill-in-queue.js';
|
|
7
7
|
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
8
8
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
9
|
-
export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
9
|
+
export { DataTable, type DataTableCardLayout, type DataTableGrouping, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
10
10
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
11
11
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
12
12
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
13
|
-
export {
|
|
13
|
+
export { KeyStats, type KeyStat, type KeyStatsProps } from './components/app/key-stats.js';
|
|
14
|
+
export { EntityView, type EntityViewField, type EntityViewLayout, type EntityViewMobileOrder, type EntityViewPlacement, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
|
|
14
15
|
export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceList, type EditInPlaceListOptions, type EditInPlaceOptions, type EditKind, type EditListAddProps, type EditListItemProps, } from './components/app/edit-in-place.js';
|
|
15
16
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
|
16
17
|
export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
|
|
@@ -43,7 +44,7 @@ export { ImageField, type ImageFieldProps } from './components/app/image-field.j
|
|
|
43
44
|
export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from './lib/image-file.js';
|
|
44
45
|
export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
|
|
45
46
|
export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
|
|
46
|
-
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
|
|
47
|
+
export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripMobile, type ModuleStripProps, } from './components/app/module-card.js';
|
|
47
48
|
export { WidgetCard, WidgetCardGroup, WidgetList, WidgetListItem, type WidgetCardGroupProps, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
|
|
48
49
|
export { WidgetLayoutSheet, type WidgetLayoutSheetProps } from './components/app/widget-layout-sheet.js';
|
|
49
50
|
export { ThemeProvider } from './components/theme-provider.js';
|
|
@@ -55,7 +56,7 @@ export type { AuthContextValue, AuthStatus, AuthUser, SignInResult } from './aut
|
|
|
55
56
|
export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
56
57
|
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
57
58
|
export { createQueryClient } from './api/query-client.js';
|
|
58
|
-
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
59
|
+
export { useTableUrlState, type TableUrlStateOptions } from './hooks/use-table-url-state.js';
|
|
59
60
|
export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
|
|
60
61
|
export { useWidgetLayout, createWidgetLayoutStore, mergeWidgetLayout, type SavedWidgetLayout, type WidgetLayout, type WidgetLayoutOptions, type WidgetLayoutStore, } from './hooks/use-widget-layout.js';
|
|
61
62
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ export { DataTable, } from './components/app/data-table.js';
|
|
|
14
14
|
export {} from './components/app/table-groups.js';
|
|
15
15
|
export { byDay } from './lib/group-by-day.js';
|
|
16
16
|
export { EntityForm } from './components/app/entity-form.js';
|
|
17
|
+
export { KeyStats } from './components/app/key-stats.js';
|
|
17
18
|
export { EntityView, } from './components/app/entity-view.js';
|
|
18
19
|
export { editInPlace, } from './components/app/edit-in-place.js';
|
|
19
20
|
export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.47.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|