@cosmicdrift/kumiko-renderer 0.387.0 → 0.389.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/dist/app/action-form-body.js +10 -2
- package/dist/app/action-form-support.d.ts +1 -0
- package/dist/app/action-form-support.js +1 -1
- package/dist/app/base-path-context.d.ts +9 -0
- package/dist/app/base-path-context.js +13 -0
- package/dist/app/base-path.d.ts +3 -0
- package/dist/app/base-path.js +27 -0
- package/dist/app/config-edit-body.d.ts +1 -1
- package/dist/app/config-edit-body.js +19 -6
- package/dist/app/config-edit-without-secret-fields.d.ts +2 -0
- package/dist/app/config-edit-without-secret-fields.js +28 -0
- package/dist/app/entity-edit-prefill.d.ts +11 -0
- package/dist/app/entity-edit-prefill.js +9 -4
- package/dist/app/entity-list-facet-specs.d.ts +3 -0
- package/dist/app/entity-list-facet-specs.js +65 -0
- package/dist/app/entity-list-screen.js +74 -206
- package/dist/app/extension-sections.d.ts +5 -3
- package/dist/app/form-schema.js +1 -1
- package/dist/app/header-actions-bar.js +1 -1
- package/dist/app/kumiko-screen.js +6 -14
- package/dist/app/list-bulk-actions.d.ts +18 -0
- package/dist/app/list-bulk-actions.js +54 -0
- package/dist/app/locked-screen-header.d.ts +9 -0
- package/dist/app/locked-screen-header.js +33 -0
- package/dist/app/projection-list-body.js +51 -46
- package/dist/app/row-actions.d.ts +3 -0
- package/dist/app/row-actions.js +10 -5
- package/dist/app/screen-shared.js +9 -2
- package/dist/app/use-entity-range-facets.d.ts +24 -0
- package/dist/app/use-entity-range-facets.js +64 -0
- package/dist/app/use-projection-date-range.d.ts +12 -0
- package/dist/app/use-projection-date-range.js +21 -0
- package/dist/app/use-screen-list-rows.d.ts +37 -0
- package/dist/app/use-screen-list-rows.js +104 -0
- package/dist/app/use-screen-list-table.d.ts +24 -0
- package/dist/app/use-screen-list-table.js +46 -0
- package/dist/components/edit-mounts.d.ts +2 -2
- package/dist/components/embedded-list-field.js +1 -1
- package/dist/components/list-group-page-bridges.d.ts +3 -0
- package/dist/components/list-group-page-bridges.js +31 -0
- package/dist/components/list-state-store.d.ts +16 -0
- package/dist/components/list-state-store.js +31 -0
- package/dist/components/related-list-section.js +51 -90
- package/dist/components/render-edit-action-button.d.ts +1 -3
- package/dist/components/render-edit-action-button.js +2 -2
- package/dist/components/render-edit.js +11 -8
- package/dist/components/render-field.js +11 -7
- package/dist/components/render-list-toolbar-action.d.ts +27 -0
- package/dist/components/render-list-toolbar-action.js +40 -0
- package/dist/components/render-list.d.ts +13 -29
- package/dist/components/render-list.js +72 -67
- package/dist/current-user.d.ts +2 -0
- package/dist/data-table-aggregate-types.d.ts +47 -0
- package/dist/data-table-aggregate-types.js +1 -0
- package/dist/data-table-bulk-types.d.ts +27 -0
- package/dist/data-table-bulk-types.js +1 -0
- package/dist/data-table-facet-types.d.ts +36 -0
- package/dist/data-table-facet-types.js +1 -0
- package/dist/data-table-view-types.d.ts +73 -0
- package/dist/data-table-view-types.js +1 -0
- package/dist/flow-diagram/render.js +9 -1
- package/dist/headless/form/form-controller.js +8 -0
- package/dist/headless/form/types.d.ts +1 -0
- package/dist/headless/format/index.js +89 -64
- package/dist/headless/view-model/edit.js +5 -1
- package/dist/headless/view-model/list.js +184 -149
- package/dist/headless/view-model/types.d.ts +7 -2
- package/dist/hooks/list-column-layout.d.ts +19 -0
- package/dist/hooks/list-column-layout.js +36 -0
- package/dist/hooks/list-group-pages.d.ts +26 -0
- package/dist/hooks/list-group-pages.js +37 -0
- package/dist/hooks/list-reset.d.ts +8 -0
- package/dist/hooks/list-reset.js +15 -0
- package/dist/hooks/list-server-groups.d.ts +39 -0
- package/dist/hooks/list-server-groups.js +84 -0
- package/dist/hooks/list-state-schema.d.ts +28 -0
- package/dist/hooks/list-state-schema.js +76 -0
- package/dist/hooks/live-refetch-scheduler.js +4 -0
- package/dist/hooks/use-bulk-run.d.ts +41 -0
- package/dist/hooks/use-bulk-run.js +65 -0
- package/dist/hooks/use-group-load-state.d.ts +11 -0
- package/dist/hooks/use-group-load-state.js +31 -0
- package/dist/hooks/use-list-aggregates.d.ts +38 -0
- package/dist/hooks/use-list-aggregates.js +107 -0
- package/dist/hooks/use-list-table.d.ts +38 -0
- package/dist/hooks/use-list-table.js +91 -0
- package/dist/hooks/use-list-view-state.d.ts +43 -0
- package/dist/hooks/use-list-view-state.js +161 -0
- package/dist/hooks/use-reference-lookup.d.ts +9 -1
- package/dist/hooks/use-reference-lookup.js +17 -3
- package/dist/hooks/use-related-list-rows.d.ts +27 -0
- package/dist/hooks/use-related-list-rows.js +81 -0
- package/dist/hooks/use-related-list-state.d.ts +15 -0
- package/dist/hooks/use-related-list-state.js +31 -0
- package/dist/hooks/use-remembered-list-params.d.ts +13 -0
- package/dist/hooks/use-remembered-list-params.js +53 -0
- package/dist/hooks/use-remembered-local-list-state.d.ts +26 -0
- package/dist/hooks/use-remembered-local-list-state.js +63 -0
- package/dist/i18n-defaults.js +59 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/primitives.d.ts +30 -47
- package/dist/web/app/create-app.d.ts +5 -0
- package/dist/web/app/create-app.js +15 -7
- package/dist/web/app/dashboard-body.js +17 -1
- package/dist/web/app/dashboard-panels.js +12 -2
- package/dist/web/app/history-leave-guard.d.ts +6 -0
- package/dist/web/app/history-leave-guard.js +115 -0
- package/dist/web/app/list-state-store-boot.d.ts +6 -0
- package/dist/web/app/list-state-store-boot.js +15 -0
- package/dist/web/app/nav.js +7 -54
- package/dist/web/app/session-list-state-store.d.ts +15 -0
- package/dist/web/app/session-list-state-store.js +117 -0
- package/dist/web/index.d.ts +2 -1
- package/dist/web/index.js +2 -1
- package/dist/web/layout/shell-header.js +2 -1
- package/dist/web/layout/uniform-bar-controls.d.ts +1 -0
- package/dist/web/layout/uniform-bar-controls.js +4 -0
- package/dist/web/lib/export-xlsx-request.d.ts +11 -0
- package/dist/web/lib/export-xlsx-request.js +46 -0
- package/dist/web/lib/file-ref-download.js +4 -1
- package/dist/web/primitives/action-menu.js +1 -1
- package/dist/web/primitives/code-input.d.ts +6 -0
- package/dist/web/primitives/code-input.js +31 -0
- package/dist/web/primitives/data-table/actions-column.d.ts +16 -0
- package/dist/web/primitives/data-table/actions-column.js +34 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.d.ts +20 -0
- package/dist/web/primitives/data-table/bulk-selection-bar.js +37 -0
- package/dist/web/primitives/data-table/card-sort-select.d.ts +14 -0
- package/dist/web/primitives/data-table/card-sort-select.js +40 -0
- package/dist/web/primitives/data-table/column-layout-mode.d.ts +5 -0
- package/dist/web/primitives/data-table/column-layout-mode.js +10 -0
- package/dist/web/primitives/data-table/column-picker-section.d.ts +7 -0
- package/dist/web/primitives/data-table/column-picker-section.js +35 -0
- package/dist/web/primitives/data-table/column-resize-handle.d.ts +14 -0
- package/dist/web/primitives/data-table/column-resize-handle.js +46 -0
- package/dist/web/primitives/data-table/density.d.ts +14 -0
- package/dist/web/primitives/data-table/density.js +20 -0
- package/dist/web/primitives/data-table/export-button.d.ts +5 -0
- package/dist/web/primitives/data-table/export-button.js +37 -0
- package/dist/web/primitives/data-table/facet-cluster.d.ts +8 -0
- package/dist/web/primitives/data-table/facet-cluster.js +37 -0
- package/dist/web/primitives/data-table/group-band.d.ts +28 -0
- package/dist/web/primitives/data-table/group-band.js +53 -0
- package/dist/web/primitives/data-table/measure-value.d.ts +10 -0
- package/dist/web/primitives/data-table/measure-value.js +28 -0
- package/dist/web/primitives/data-table/pin-section.d.ts +8 -0
- package/dist/web/primitives/data-table/pin-section.js +20 -0
- package/dist/web/primitives/data-table/reset-all-button.d.ts +4 -0
- package/dist/web/primitives/data-table/reset-all-button.js +8 -0
- package/dist/web/primitives/data-table/server-group-header.d.ts +11 -0
- package/dist/web/primitives/data-table/server-group-header.js +51 -0
- package/dist/web/primitives/data-table/sortable-header.d.ts +17 -0
- package/dist/web/primitives/data-table/sortable-header.js +41 -0
- package/dist/web/primitives/data-table/table-chrome.d.ts +13 -0
- package/dist/web/primitives/data-table/table-chrome.js +30 -0
- package/dist/web/primitives/data-table/table-footer.d.ts +7 -0
- package/dist/web/primitives/data-table/table-footer.js +20 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.d.ts +10 -0
- package/dist/web/primitives/data-table/toolbar-end-cluster.js +13 -0
- package/dist/web/primitives/data-table/totals-footer.d.ts +24 -0
- package/dist/web/primitives/data-table/totals-footer.js +48 -0
- package/dist/web/primitives/data-table/use-bulk-selection.d.ts +21 -0
- package/dist/web/primitives/data-table/use-bulk-selection.js +66 -0
- package/dist/web/primitives/data-table/use-column-pins.d.ts +32 -0
- package/dist/web/primitives/data-table/use-column-pins.js +117 -0
- package/dist/web/primitives/data-table/use-column-sizing.d.ts +22 -0
- package/dist/web/primitives/data-table/use-column-sizing.js +44 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.d.ts +15 -0
- package/dist/web/primitives/data-table/use-measured-column-widths.js +83 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.d.ts +22 -0
- package/dist/web/primitives/data-table/use-row-keyboard-navigation.js +65 -0
- package/dist/web/primitives/data-table/use-row-selection.d.ts +14 -0
- package/dist/web/primitives/data-table/use-row-selection.js +48 -0
- package/dist/web/primitives/data-table/use-view-columns.d.ts +6 -0
- package/dist/web/primitives/data-table/use-view-columns.js +17 -0
- package/dist/web/primitives/data-table/view-menu.d.ts +8 -0
- package/dist/web/primitives/data-table/view-menu.js +45 -0
- package/dist/web/primitives/data-table-cell.d.ts +0 -22
- package/dist/web/primitives/data-table-cell.js +1 -58
- package/dist/web/primitives/data-table.d.ts +1 -1
- package/dist/web/primitives/data-table.js +125 -146
- package/dist/web/primitives/facet-filter.js +1 -1
- package/dist/web/primitives/file-thumbnail.d.ts +3 -0
- package/dist/web/primitives/file-thumbnail.js +5 -0
- package/dist/web/primitives/file-value-list.d.ts +1 -1
- package/dist/web/primitives/file-value-list.js +5 -5
- package/dist/web/primitives/input.js +6 -1
- package/dist/web/primitives/layout-elements.js +6 -4
- package/dist/web/primitives/number-range-facet.d.ts +7 -0
- package/dist/web/primitives/number-range-facet.js +33 -0
- package/dist/web/primitives/page-header.js +3 -1
- package/dist/web/primitives/row-actions.js +3 -3
- package/dist/web/primitives/use-focus-on-mount.d.ts +2 -0
- package/dist/web/primitives/use-focus-on-mount.js +9 -0
- package/dist/web/widgets/bounded-load-cache.d.ts +5 -1
- package/dist/web/widgets/bounded-load-cache.js +49 -21
- package/dist/web/widgets/choice-cards.d.ts +17 -0
- package/dist/web/widgets/choice-cards.js +12 -0
- package/dist/web/widgets/collapsible-section.js +1 -1
- package/dist/web/widgets/detail-list.d.ts +5 -1
- package/dist/web/widgets/detail-list.js +5 -1
- package/dist/web/widgets/index.d.ts +1 -0
- package/dist/web/widgets/index.js +1 -0
- package/dist/web/widgets/pdf-preview.js +10 -5
- package/dist/web/widgets/plan-card.d.ts +2 -0
- package/dist/web/widgets/plan-card.js +1 -1
- package/dist/web/widgets/upload-zone.js +13 -5
- package/package.json +5 -5
- package/src/changes.json +158 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableBulkAction } from "../../../data-table-bulk-types.ts";
|
|
3
|
+
import type { Translate } from "../../../headless/index.ts";
|
|
4
|
+
import type { BulkRunState } from "../../../hooks/use-bulk-run.ts";
|
|
5
|
+
export declare function BulkSelectionBar({ flush, actions, selectedCount, isFull, runState, resultActionLabel, pendingAction, t, onRequest, onConfirm, onConfirmClose, onCancel, onClear, onDismissResult, }: {
|
|
6
|
+
readonly flush: boolean;
|
|
7
|
+
readonly actions: readonly DataTableBulkAction[];
|
|
8
|
+
readonly selectedCount: number;
|
|
9
|
+
readonly isFull: boolean;
|
|
10
|
+
readonly runState: BulkRunState;
|
|
11
|
+
readonly resultActionLabel: string;
|
|
12
|
+
readonly pendingAction: DataTableBulkAction | undefined;
|
|
13
|
+
readonly t: Translate;
|
|
14
|
+
readonly onRequest: (action: DataTableBulkAction) => void;
|
|
15
|
+
readonly onConfirm: (action: DataTableBulkAction) => void;
|
|
16
|
+
readonly onConfirmClose: () => void;
|
|
17
|
+
readonly onCancel: () => void;
|
|
18
|
+
readonly onClear: () => void;
|
|
19
|
+
readonly onDismissResult: () => void;
|
|
20
|
+
}): ReactNode;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MAX_BULK_SELECTION } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
3
|
+
import { cn } from "../../lib/cn.js";
|
|
4
|
+
import { DefaultBanner } from "../banner.js";
|
|
5
|
+
import { DefaultButton } from "../button.js";
|
|
6
|
+
import { DefaultDialog } from "../dialog.js";
|
|
7
|
+
const BAR_CLASS = "flex flex-wrap items-center gap-2 bg-muted/60 py-2 text-sm text-foreground";
|
|
8
|
+
// A scrollBody table has no frame of its own, so the bar spans it like the toolbar does.
|
|
9
|
+
const FLUSH_BAR_CLASS = "border-t px-6 max-md:px-3";
|
|
10
|
+
const FRAMED_BAR_CLASS = "rounded-lg border px-3";
|
|
11
|
+
function ResultBanner({ result, actionLabel, t, onDismiss, }) {
|
|
12
|
+
const tallies = [
|
|
13
|
+
{ key: "succeeded", text: "kumiko.list.bulk.result.succeeded", ids: result.succeededIds },
|
|
14
|
+
{ key: "failed", text: "kumiko.list.bulk.result.failed", ids: result.failedIds },
|
|
15
|
+
{ key: "skipped", text: "kumiko.list.bulk.result.skipped", ids: result.skippedIds },
|
|
16
|
+
{ key: "not-started", text: "kumiko.list.bulk.result.not-started", ids: result.notStartedIds },
|
|
17
|
+
];
|
|
18
|
+
return (_jsxs(DefaultBanner, { variant: result.failedIds.length > 0 ? "warning" : "info", title: t("kumiko.list.bulk.result.title", { action: actionLabel }), testId: "bulk-result", actions: _jsx(DefaultButton, { variant: "secondary", size: "sm", onClick: onDismiss, testId: "bulk-result-dismiss", children: t("kumiko.list.bulk.result.dismiss") }), children: [_jsx("ul", { className: "m-0 flex list-none flex-wrap gap-x-4 p-0", children: tallies
|
|
19
|
+
.filter((tally) => tally.key === "succeeded" || tally.ids.length > 0)
|
|
20
|
+
.map((tally) => (_jsx("li", { "data-testid": `bulk-result-${tally.key}`, children: t(tally.text, { count: tally.ids.length }) }, tally.key))) }), result.firstFailureMessage !== undefined && (_jsx("p", { "data-testid": "bulk-result-error", className: "m-0 mt-1 text-foreground-secondary", children: result.firstFailureMessage }))] }));
|
|
21
|
+
}
|
|
22
|
+
function RunProgress({ state, t, onCancel, barClassName, }) {
|
|
23
|
+
return (_jsxs("div", { "data-testid": "bulk-bar", "data-phase": "running", className: barClassName, children: [_jsx("span", { "data-testid": "bulk-progress", className: "tabular-nums", "aria-live": "polite", children: t("kumiko.list.bulk.progress", { settled: state.settled, total: state.total }) }), _jsx(DefaultButton, { variant: "secondary", size: "sm", onClick: onCancel, disabled: state.cancelRequested, testId: "bulk-cancel", className: "ms-auto", children: t(state.cancelRequested ? "kumiko.list.bulk.cancelling" : "kumiko.list.bulk.cancel") })] }));
|
|
24
|
+
}
|
|
25
|
+
export function BulkSelectionBar({ flush, actions, selectedCount, isFull, runState, resultActionLabel, pendingAction, t, onRequest, onConfirm, onConfirmClose, onCancel, onClear, onDismissResult, }) {
|
|
26
|
+
const confirmText = pendingAction?.confirmText?.(selectedCount);
|
|
27
|
+
// skip: nothing selected, no run and no result leaves no bar in the DOM
|
|
28
|
+
if (runState.phase === "idle" && selectedCount === 0 && pendingAction === undefined)
|
|
29
|
+
return null;
|
|
30
|
+
const barClassName = cn(BAR_CLASS, flush ? FLUSH_BAR_CLASS : FRAMED_BAR_CLASS);
|
|
31
|
+
return (_jsxs("div", { className: flush ? "flex flex-col" : "flex flex-col gap-2", children: [runState.phase === "finished" && (_jsx("div", { className: flush ? "px-6 pb-2 max-md:px-3" : undefined, children: _jsx(ResultBanner, { result: runState.result, actionLabel: resultActionLabel, t: t, onDismiss: onDismissResult }) })), runState.phase === "running" ? (_jsx(RunProgress, { state: runState, t: t, onCancel: onCancel, barClassName: barClassName })) : (selectedCount > 0 && (_jsxs("div", { "data-testid": "bulk-bar", "data-phase": "idle", className: barClassName, children: [_jsx("span", { "data-testid": "bulk-selected-count", className: "font-medium tabular-nums", children: t("kumiko.list.bulk.selected", { count: selectedCount }) }), actions.map((action) => (_jsx(DefaultButton, { variant: action.style === "danger" ? "danger" : "secondary", size: "sm", onClick: () => onRequest(action), testId: `bulk-action-${action.id}`, ...(action.icon !== undefined && { icon: action.icon }), children: action.label }, action.id))), isFull && (_jsx("span", { "data-testid": "bulk-limit", className: "text-foreground-secondary", children: t("kumiko.list.bulk.limit", { max: MAX_BULK_SELECTION }) })), _jsx(DefaultButton, { variant: "ghost", size: "sm", onClick: onClear, testId: "bulk-clear", className: "ms-auto", children: t("kumiko.list.bulk.clear") })] }))), pendingAction !== undefined && (_jsx(DefaultDialog, { open: true, onOpenChange: (open) => {
|
|
32
|
+
if (!open)
|
|
33
|
+
onConfirmClose();
|
|
34
|
+
}, title: pendingAction.label, ...(confirmText !== undefined && { description: confirmText }), confirmLabel: pendingAction.confirmLabel ?? pendingAction.label, variant: pendingAction.style === "danger" ? "danger" : "default",
|
|
35
|
+
// The run must not hold the dialog open: the bar shows its progress.
|
|
36
|
+
onConfirm: () => onConfirm(pendingAction), testId: "bulk-confirm" }))] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableSort } from "../../../index.ts";
|
|
3
|
+
type SortableColumn = {
|
|
4
|
+
readonly field: string;
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly sortable?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function CardSortSelect({ columns, sort, onSortChange, testId, }: {
|
|
9
|
+
readonly columns: readonly SortableColumn[];
|
|
10
|
+
readonly sort: DataTableSort | null | undefined;
|
|
11
|
+
readonly onSortChange: ((next: DataTableSort | null) => void) | undefined;
|
|
12
|
+
readonly testId: string | undefined;
|
|
13
|
+
}): ReactNode;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowDown, ArrowUp } from "lucide-react";
|
|
3
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
// No column headers below the breakpoint, so the click-to-sort affordance
|
|
5
|
+
// on SortableHeader has nothing to attach to. A single native <select> is
|
|
6
|
+
// the whole fix — no custom widget, no menu — fed straight from the
|
|
7
|
+
// columns marked sortable in the ViewModel. Options carry the resolved
|
|
8
|
+
// {field, dir} directly so onChange can look the pick up by value instead
|
|
9
|
+
// of parsing/casting the option string back apart.
|
|
10
|
+
export function CardSortSelect({ columns, sort, onSortChange, testId, }) {
|
|
11
|
+
const translate = useOptionalTranslation();
|
|
12
|
+
const sortableColumns = columns.filter((col) => col.sortable);
|
|
13
|
+
if (onSortChange === undefined || sortableColumns.length === 0)
|
|
14
|
+
return null;
|
|
15
|
+
const sortOptions = sortableColumns.flatMap((col) => [
|
|
16
|
+
{ value: `${col.field}:asc`, field: col.field, dir: "asc", label: `${col.label} ↑` },
|
|
17
|
+
{ value: `${col.field}:desc`, field: col.field, dir: "desc", label: `${col.label} ↓` },
|
|
18
|
+
]);
|
|
19
|
+
const sortLabel = sort !== undefined && sort !== null
|
|
20
|
+
? (translate?.("kumiko.list.sort.by", {
|
|
21
|
+
column: columns.find((col) => col.field === sort.field)?.label ?? sort.field,
|
|
22
|
+
}) ?? `Sorted by ${sort.field}`)
|
|
23
|
+
: (translate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
|
|
24
|
+
const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
|
|
25
|
+
// URL state can carry a sort on a column that is not sortable; without an
|
|
26
|
+
// option for it the select would claim "Unsorted" while the list is sorted.
|
|
27
|
+
const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
|
|
28
|
+
const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
|
|
29
|
+
return (_jsxs("div", { className: "relative flex h-9 items-center gap-1.5 px-4 text-[13px] text-foreground-secondary", children: [_jsx("span", { "aria-hidden": "true", children: sortLabel }), sort !== undefined && sort !== null && _jsx(SortArrow, { className: "size-3.5", "aria-hidden": "true" }), _jsxs("select", { "aria-label": translate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: currentSortValue, onChange: (e) => {
|
|
30
|
+
const raw = e.target.value;
|
|
31
|
+
if (raw === "") {
|
|
32
|
+
onSortChange(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const picked = sortOptions.find((o) => o.value === raw);
|
|
36
|
+
if (picked === undefined)
|
|
37
|
+
return;
|
|
38
|
+
onSortChange({ field: picked.field, dir: picked.dir });
|
|
39
|
+
}, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: translate?.("kumiko.list.sort.unsorted") ?? "Unsorted" }), currentSortIsUnlisted && (_jsx("option", { value: currentSortValue, children: `${columns.find((col) => col.field === sort.field)?.label ?? sort.field} ${sort.dir === "desc" ? "↓" : "↑"}` })), sortOptions.map((o) => (_jsx("option", { value: o.value, children: o.label }, o.value)))] })] }));
|
|
40
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { DataTableViewOptions } from "../../../data-table-view-types.ts";
|
|
2
|
+
/** Pinning, resizing and keyboard navigation only apply to the table layout (md and up). */
|
|
3
|
+
export declare function isPinningActive(view: DataTableViewOptions | undefined, isNarrow: boolean): boolean;
|
|
4
|
+
export declare function isResizingActive(view: DataTableViewOptions | undefined, isNarrow: boolean): boolean;
|
|
5
|
+
export declare function isKeyboardNavigationActive(view: DataTableViewOptions | undefined, isNarrow: boolean): boolean;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Pinning, resizing and keyboard navigation only apply to the table layout (md and up). */
|
|
2
|
+
export function isPinningActive(view, isNarrow) {
|
|
3
|
+
return !isNarrow && view?.pins !== undefined;
|
|
4
|
+
}
|
|
5
|
+
export function isResizingActive(view, isNarrow) {
|
|
6
|
+
return !isNarrow && view?.widths !== undefined && view.minWidths !== undefined;
|
|
7
|
+
}
|
|
8
|
+
export function isKeyboardNavigationActive(view, isNarrow) {
|
|
9
|
+
return !isNarrow && view?.keyboardNavigation === true;
|
|
10
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableViewColumn } from "../../../data-table-view-types.ts";
|
|
3
|
+
export declare function ColumnPickerSection({ columns, labels, onChange, }: {
|
|
4
|
+
readonly columns: readonly DataTableViewColumn[];
|
|
5
|
+
readonly labels: ReadonlyMap<string, string>;
|
|
6
|
+
readonly onChange: (next: readonly DataTableViewColumn[]) => void;
|
|
7
|
+
}): ReactNode;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ArrowDown, ArrowUp, GripVertical } from "lucide-react";
|
|
3
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
5
|
+
import { Checkbox } from "../../ui/checkbox.js";
|
|
6
|
+
import { usePointerDrag } from "../../widgets/use-pointer-drag.js";
|
|
7
|
+
const ROW_HEIGHT_PX = 32;
|
|
8
|
+
function moveColumn(columns, from, to) {
|
|
9
|
+
const target = Math.max(0, Math.min(columns.length - 1, to));
|
|
10
|
+
if (target === from)
|
|
11
|
+
return columns;
|
|
12
|
+
const next = [...columns];
|
|
13
|
+
const [moved] = next.splice(from, 1);
|
|
14
|
+
if (moved !== undefined)
|
|
15
|
+
next.splice(target, 0, moved);
|
|
16
|
+
return next;
|
|
17
|
+
}
|
|
18
|
+
function PickerRow({ columns, index, label, isLastVisible, onChange }) {
|
|
19
|
+
const translate = useOptionalTranslation();
|
|
20
|
+
const column = columns[index];
|
|
21
|
+
// Positions are relative to the order at pointer-down, so every move is idempotent.
|
|
22
|
+
const drag = usePointerDrag({
|
|
23
|
+
onStart: () => ({ snapshot: columns, from: index }),
|
|
24
|
+
onMove: (start, { dy }) => onChange(moveColumn(start.snapshot, start.from, start.from + Math.round(dy / ROW_HEIGHT_PX))),
|
|
25
|
+
});
|
|
26
|
+
if (column === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
const checkboxId = `list-view-column-${column.field}`;
|
|
29
|
+
return (_jsxs("li", { className: "flex h-8 items-center gap-1.5", "data-testid": `list-view-column-row-${column.field}`, children: [_jsx("span", { ...drag, "aria-hidden": "true", className: "flex size-6 shrink-0 cursor-grab touch-none items-center justify-center text-muted-foreground", children: _jsx(GripVertical, { className: "size-4" }) }), _jsx(Checkbox, { id: checkboxId, checked: column.visible, disabled: column.visible && isLastVisible, onCheckedChange: (checked) => onChange(columns.map((c, i) => (i === index ? { ...c, visible: checked === true } : c))) }), _jsx("label", { htmlFor: checkboxId, className: "min-w-0 flex-1 truncate text-sm", children: label }), _jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-6", disabled: index === 0, "aria-label": translate?.("kumiko.list.view.column.move-up", { column: label }) ?? `Move ${label} up`, onClick: () => onChange(moveColumn(columns, index, index - 1)), children: _jsx(ArrowUp, { className: "size-3.5", "aria-hidden": "true" }) }), _jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-6", disabled: index === columns.length - 1, "aria-label": translate?.("kumiko.list.view.column.move-down", { column: label }) ??
|
|
30
|
+
`Move ${label} down`, onClick: () => onChange(moveColumn(columns, index, index + 1)), children: _jsx(ArrowDown, { className: "size-3.5", "aria-hidden": "true" }) })] }));
|
|
31
|
+
}
|
|
32
|
+
export function ColumnPickerSection({ columns, labels, onChange, }) {
|
|
33
|
+
const visibleCount = columns.filter((column) => column.visible).length;
|
|
34
|
+
return (_jsx("ul", { className: "m-0 flex list-none flex-col p-0", "data-testid": "list-view-columns", children: columns.map((column, index) => (_jsx(PickerRow, { columns: columns, index: index, label: labels.get(column.field) ?? column.field, isLastVisible: visibleCount === 1, onChange: onChange }, column.field))) }));
|
|
35
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { ColumnSizing } from "./use-column-sizing.ts";
|
|
3
|
+
export declare function ColumnWidthGroup({ fields, sizing, hasExpandColumn, hasSelectColumn, hasActionsColumn, }: {
|
|
4
|
+
readonly fields: readonly string[];
|
|
5
|
+
readonly sizing: ColumnSizing;
|
|
6
|
+
readonly hasExpandColumn: boolean;
|
|
7
|
+
readonly hasSelectColumn?: boolean;
|
|
8
|
+
readonly hasActionsColumn: boolean;
|
|
9
|
+
}): ReactNode;
|
|
10
|
+
export declare function ColumnResizeHandle({ field, label, sizing, }: {
|
|
11
|
+
readonly field: string;
|
|
12
|
+
readonly label: string;
|
|
13
|
+
readonly sizing: ColumnSizing;
|
|
14
|
+
}): ReactNode;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { MAX_COLUMN_WIDTH_PX } from "../../../hooks/list-column-layout.js";
|
|
4
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
5
|
+
import { arrowKeyDelta, usePointerDrag } from "../../widgets/use-pointer-drag.js";
|
|
6
|
+
import { EXPAND_COLUMN_WIDTH_PX, SELECT_COLUMN_WIDTH_PX } from "./use-measured-column-widths.js";
|
|
7
|
+
export function ColumnWidthGroup({ fields, sizing, hasExpandColumn, hasSelectColumn = false, hasActionsColumn, }) {
|
|
8
|
+
return (_jsxs("colgroup", { children: [hasSelectColumn && _jsx("col", { style: { width: SELECT_COLUMN_WIDTH_PX } }), hasExpandColumn && _jsx("col", { style: { width: EXPAND_COLUMN_WIDTH_PX } }), fields.map((field) => (_jsx("col", { style: { width: sizing.widthOf(field) } }, field))), hasActionsColumn && _jsx("col", {})] }));
|
|
9
|
+
}
|
|
10
|
+
// Visible while the header is hovered or the grip has focus; the wider 8px hit area stays transparent.
|
|
11
|
+
const HANDLE_CLASS = "absolute inset-y-0 right-0 z-10 m-0 h-full w-2 border-0 bg-transparent cursor-col-resize touch-none outline-none " +
|
|
12
|
+
"after:absolute after:inset-y-1 after:right-0 after:w-0.5 after:rounded-full after:bg-transparent " +
|
|
13
|
+
"group-hover/header:after:bg-border hover:after:bg-ring focus-visible:after:bg-ring active:after:bg-ring";
|
|
14
|
+
export function ColumnResizeHandle({ field, label, sizing, }) {
|
|
15
|
+
const translate = useOptionalTranslation();
|
|
16
|
+
const width = sizing.widthOf(field);
|
|
17
|
+
const minWidth = sizing.minWidthOf(field);
|
|
18
|
+
const draggedWidth = useRef(null);
|
|
19
|
+
const clampToBounds = (value) => Math.min(MAX_COLUMN_WIDTH_PX, Math.max(minWidth, value));
|
|
20
|
+
const drag = usePointerDrag({
|
|
21
|
+
onStart: () => width,
|
|
22
|
+
onMove: (startWidth, { dx }) => {
|
|
23
|
+
const next = clampToBounds(startWidth + dx);
|
|
24
|
+
draggedWidth.current = next;
|
|
25
|
+
sizing.setLiveWidth(field, next);
|
|
26
|
+
},
|
|
27
|
+
onEnd: () => {
|
|
28
|
+
const final = draggedWidth.current;
|
|
29
|
+
draggedWidth.current = null;
|
|
30
|
+
if (final !== null)
|
|
31
|
+
sizing.commit(field, final);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const onKeyDown = (event) => {
|
|
35
|
+
event.stopPropagation();
|
|
36
|
+
const delta = arrowKeyDelta(event);
|
|
37
|
+
if (delta === undefined || delta.dx === 0)
|
|
38
|
+
return;
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
sizing.commit(field, clampToBounds(width + delta.dx));
|
|
41
|
+
};
|
|
42
|
+
return (_jsx("hr", { "aria-orientation": "vertical", "aria-valuenow": Math.round(width), "aria-valuemin": minWidth, "aria-valuemax": MAX_COLUMN_WIDTH_PX, "aria-label": translate?.("kumiko.list.column.resize", { column: label }) ?? `Resize ${label}`, tabIndex: 0, "data-testid": `column-resize-${field}`, className: HANDLE_CLASS, onClick: (event) => event.stopPropagation(), onKeyDown: onKeyDown, ...drag, onPointerDown: (event) => {
|
|
43
|
+
event.stopPropagation();
|
|
44
|
+
drag.onPointerDown(event);
|
|
45
|
+
} }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DataTableViewOptions } from "../../../data-table-view-types.ts";
|
|
2
|
+
type DensityClasses = {
|
|
3
|
+
/** Table body row. */
|
|
4
|
+
readonly row: string;
|
|
5
|
+
/** Table body cell; `!` beats the fill-height table's own cell padding. */
|
|
6
|
+
readonly cell: string;
|
|
7
|
+
/** Header cell; keeps the horizontal inset in line with the body cells. */
|
|
8
|
+
readonly header: string;
|
|
9
|
+
/** Card row below the md breakpoint. */
|
|
10
|
+
readonly card: string;
|
|
11
|
+
};
|
|
12
|
+
/** Comfortable (and a table without `view`) adds no class at all, so the markup stays as it was. */
|
|
13
|
+
export declare function densityClasses(view: DataTableViewOptions | undefined, keepsFillEdgeInset: boolean): DensityClasses;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const NO_DENSITY_CLASSES = { row: "", cell: "", header: "", card: "" };
|
|
2
|
+
// A fill-height table insets its first and last column by 24px; compact keeps that edge.
|
|
3
|
+
const FILL_EDGE_INSET = " first:pl-6! last:pr-6!";
|
|
4
|
+
const DENSITY_CLASSES = {
|
|
5
|
+
comfortable: NO_DENSITY_CLASSES,
|
|
6
|
+
compact: { row: "h-8", cell: "p-1!", header: "px-1!", card: "min-h-14 py-2" },
|
|
7
|
+
};
|
|
8
|
+
/** Comfortable (and a table without `view`) adds no class at all, so the markup stays as it was. */
|
|
9
|
+
export function densityClasses(view, keepsFillEdgeInset) {
|
|
10
|
+
if (view === undefined)
|
|
11
|
+
return NO_DENSITY_CLASSES;
|
|
12
|
+
const classes = DENSITY_CLASSES[view.density];
|
|
13
|
+
if (!keepsFillEdgeInset || classes.cell === "")
|
|
14
|
+
return classes;
|
|
15
|
+
return {
|
|
16
|
+
...classes,
|
|
17
|
+
cell: classes.cell + FILL_EDGE_INSET,
|
|
18
|
+
header: classes.header + FILL_EDGE_INSET,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Download } from "lucide-react";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
5
|
+
import { requestXlsxExport } from "../../lib/export-xlsx-request.js";
|
|
6
|
+
import { saveBlobAsFile } from "../../lib/file-ref-download.js";
|
|
7
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
8
|
+
function localDateStamp(now) {
|
|
9
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
10
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
11
|
+
}
|
|
12
|
+
export function ExportButton({ source }) {
|
|
13
|
+
const translate = useOptionalTranslation();
|
|
14
|
+
const [busy, setBusy] = useState(false);
|
|
15
|
+
const [outcome, setOutcome] = useState(undefined);
|
|
16
|
+
const run = async () => {
|
|
17
|
+
setBusy(true);
|
|
18
|
+
setOutcome(undefined);
|
|
19
|
+
const result = await requestXlsxExport(source);
|
|
20
|
+
setBusy(false);
|
|
21
|
+
if (result.status === "ok") {
|
|
22
|
+
saveBlobAsFile(`${source.screenShortId}-${localDateStamp(new Date())}.xlsx`, result.blob);
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
setOutcome(result.status === "too-many" ? { kind: "too-many", maxRows: result.maxRows } : "failed");
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const hint = outcome === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: outcome === "failed"
|
|
31
|
+
? (translate?.("kumiko.list.export-failed") ?? "The export failed. Try again.")
|
|
32
|
+
: (translate?.("kumiko.list.export-too-many", { max: outcome.maxRows ?? "" }) ??
|
|
33
|
+
"Too many rows. Narrow the list with a filter and export again.");
|
|
34
|
+
return (_jsxs(_Fragment, { children: [_jsxs(UiButton, { type: "button", variant: "outline", size: "sm", className: "h-9", disabled: busy, onClick: () => void run(), "data-testid": "list-export", children: [_jsx(Download, { "aria-hidden": "true" }), busy
|
|
35
|
+
? (translate?.("kumiko.list.export-busy") ?? "Exporting…")
|
|
36
|
+
: (translate?.("kumiko.list.export") ?? "Export")] }), hint !== undefined && (_jsx("span", { role: outcome === "failed" ? "alert" : "status", className: "text-sm text-muted-foreground", "data-testid": "list-export-hint", children: hint }))] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { type DataTableProps } from "../../../index.ts";
|
|
3
|
+
type FacetClusterProps = Pick<DataTableProps, "filterFacets" | "filterValues" | "onFilterChange" | "onFilterReset" | "dateRangeFacets" | "onDateRangeChange" | "rangeFacets" | "onRangeChange" | "resetAll" | "notice"> & {
|
|
4
|
+
readonly dense: boolean;
|
|
5
|
+
};
|
|
6
|
+
/** The toolbar's filter controls; undefined when the list has none. */
|
|
7
|
+
export declare function buildFacetCluster({ filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, rangeFacets, onRangeChange, resetAll, notice, dense, }: FacetClusterProps): ReactNode | undefined;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
5
|
+
import { DateRangeFacetFilter, FacetFilter } from "../facet-filter.js";
|
|
6
|
+
import { NumberRangeFacetFilter } from "../number-range-facet.js";
|
|
7
|
+
import { ResetAllButton } from "./reset-all-button.js";
|
|
8
|
+
function ListNotice({ notice }) {
|
|
9
|
+
const translate = useOptionalTranslation();
|
|
10
|
+
const fallback = notice === "truncated"
|
|
11
|
+
? "Not every group is shown. Please narrow the list with a filter."
|
|
12
|
+
: "The search matched too many entries; groups and totals cover only part of them. Please filter further.";
|
|
13
|
+
return (_jsx("p", { role: "status", "data-testid": "list-notice", "data-notice": notice, className: "m-0 basis-full text-xs text-muted-foreground", children: translate?.(`kumiko.list.notice.${notice}`) ?? fallback }));
|
|
14
|
+
}
|
|
15
|
+
function FacetResetButton({ onReset }) {
|
|
16
|
+
const translate = useOptionalTranslation();
|
|
17
|
+
return (_jsxs(UiButton, { variant: "ghost", size: "sm", className: "h-9 px-2", onClick: onReset, "data-testid": "facet-reset", children: [translate?.("kumiko.list.filter.reset") ?? "Reset", _jsx(X, {})] }));
|
|
18
|
+
}
|
|
19
|
+
/** The toolbar's filter controls; undefined when the list has none. */
|
|
20
|
+
export function buildFacetCluster({ filterFacets, filterValues, onFilterChange, onFilterReset, dateRangeFacets, onDateRangeChange, rangeFacets, onRangeChange, resetAll, notice, dense, }) {
|
|
21
|
+
const hasFacets = filterFacets !== undefined && filterFacets.length > 0 && onFilterChange !== undefined;
|
|
22
|
+
const hasDateRangeFacets = dateRangeFacets !== undefined && dateRangeFacets.length > 0 && onDateRangeChange !== undefined;
|
|
23
|
+
const hasRangeFacets = rangeFacets !== undefined && rangeFacets.length > 0 && onRangeChange !== undefined;
|
|
24
|
+
const hasActiveFilters = filterValues !== undefined && Object.values(filterValues).some((v) => v.length > 0);
|
|
25
|
+
const showsResetAll = resetAll?.active === true;
|
|
26
|
+
if (!hasFacets &&
|
|
27
|
+
!hasDateRangeFacets &&
|
|
28
|
+
!hasRangeFacets &&
|
|
29
|
+
!showsResetAll &&
|
|
30
|
+
notice === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [hasFacets &&
|
|
34
|
+
filterFacets.map((facet) => (_jsx(FacetFilter, { facet: facet, selected: filterValues?.[facet.field] ?? [], onChange: onFilterChange, dense: dense }, facet.field))), hasDateRangeFacets &&
|
|
35
|
+
dateRangeFacets.map((facet) => (_jsx(DateRangeFacetFilter, { facet: facet, onChange: onDateRangeChange }, facet.field))), hasRangeFacets &&
|
|
36
|
+
rangeFacets.map((facet) => (_jsx(NumberRangeFacetFilter, { facet: facet, onChange: onRangeChange }, facet.field))), resetAll === undefined && hasActiveFilters && onFilterReset !== undefined && (_jsx(FacetResetButton, { onReset: onFilterReset })), showsResetAll && _jsx(ResetAllButton, { onReset: resetAll.onReset }), notice !== undefined && _jsx(ListNotice, { notice: notice })] }));
|
|
37
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableServerGroup, DataTableServerGroups } from "../../../data-table-aggregate-types.ts";
|
|
3
|
+
import type { ListColumnViewModel, ListRowViewModel } from "../../../headless/index.ts";
|
|
4
|
+
type ServerGroupItem = {
|
|
5
|
+
readonly group: DataTableServerGroup;
|
|
6
|
+
readonly owner: DataTableServerGroups;
|
|
7
|
+
};
|
|
8
|
+
export type DataTableItem = {
|
|
9
|
+
readonly kind: "row";
|
|
10
|
+
readonly row: ListRowViewModel;
|
|
11
|
+
} | ({
|
|
12
|
+
readonly kind: "serverGroup";
|
|
13
|
+
} & ServerGroupItem) | ({
|
|
14
|
+
readonly kind: "groupMore";
|
|
15
|
+
} & ServerGroupItem);
|
|
16
|
+
export type DataTableBandItem = Exclude<DataTableItem, {
|
|
17
|
+
readonly kind: "row";
|
|
18
|
+
}>;
|
|
19
|
+
/** Server groups slice the flat `rows` by each group's `rowCount`, in group order. */
|
|
20
|
+
export declare function buildDataTableItems(rows: readonly ListRowViewModel[], serverGroups: DataTableServerGroups | undefined): readonly DataTableItem[];
|
|
21
|
+
export declare function bandReactKey(item: DataTableBandItem): string;
|
|
22
|
+
export declare function bandTestId(item: DataTableBandItem): string;
|
|
23
|
+
export declare function bandClassName(item: DataTableBandItem): string;
|
|
24
|
+
export declare function GroupBand({ item, columnOf, }: {
|
|
25
|
+
readonly item: DataTableBandItem;
|
|
26
|
+
readonly columnOf: (field: string) => ListColumnViewModel | undefined;
|
|
27
|
+
}): ReactNode;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
3
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
4
|
+
import { ServerGroupHeader } from "./server-group-header.js";
|
|
5
|
+
/** Server groups slice the flat `rows` by each group's `rowCount`, in group order. */
|
|
6
|
+
export function buildDataTableItems(rows, serverGroups) {
|
|
7
|
+
if (serverGroups === undefined)
|
|
8
|
+
return rows.map((row) => ({ kind: "row", row }));
|
|
9
|
+
const items = [];
|
|
10
|
+
let offset = 0;
|
|
11
|
+
for (const group of serverGroups.groups) {
|
|
12
|
+
items.push({ kind: "serverGroup", group, owner: serverGroups });
|
|
13
|
+
const members = rows.slice(offset, offset + group.rowCount);
|
|
14
|
+
offset += group.rowCount;
|
|
15
|
+
if (group.collapsed)
|
|
16
|
+
continue;
|
|
17
|
+
for (const row of members)
|
|
18
|
+
items.push({ kind: "row", row });
|
|
19
|
+
if (group.hasMore || group.loading)
|
|
20
|
+
items.push({ kind: "groupMore", group, owner: serverGroups });
|
|
21
|
+
}
|
|
22
|
+
return items;
|
|
23
|
+
}
|
|
24
|
+
function bandGroupKey(item) {
|
|
25
|
+
return item.group.key;
|
|
26
|
+
}
|
|
27
|
+
export function bandReactKey(item) {
|
|
28
|
+
return `group-${bandGroupKey(item)}${item.kind === "groupMore" ? "-more" : ""}`;
|
|
29
|
+
}
|
|
30
|
+
export function bandTestId(item) {
|
|
31
|
+
return `row-group-${bandGroupKey(item)}${item.kind === "groupMore" ? "-more" : ""}`;
|
|
32
|
+
}
|
|
33
|
+
export function bandClassName(item) {
|
|
34
|
+
return item.kind === "groupMore" ? "p-0" : "bg-muted/40 p-0";
|
|
35
|
+
}
|
|
36
|
+
function GroupLoadMore({ item }) {
|
|
37
|
+
const translate = useOptionalTranslation();
|
|
38
|
+
const { group, owner } = item;
|
|
39
|
+
return (_jsxs("div", { className: "flex items-center gap-3 px-4 py-1.5 text-xs text-muted-foreground", children: [_jsx(UiButton, { type: "button", variant: "ghost", size: "sm", disabled: group.loading, onClick: () => owner.onLoadMore(group.key), "data-testid": `row-group-${group.key}-load-more`, children: group.loading
|
|
40
|
+
? (translate?.("kumiko.list.group.loading") ?? "Loading…")
|
|
41
|
+
: (translate?.("kumiko.list.group.load-more") ?? "Load more") }), _jsx("span", { className: "tabular-nums", children: translate?.("kumiko.list.group.loaded", {
|
|
42
|
+
loaded: group.rowCount.toLocaleString(),
|
|
43
|
+
count: group.count.toLocaleString(),
|
|
44
|
+
}) ?? `${group.rowCount} / ${group.count}` })] }));
|
|
45
|
+
}
|
|
46
|
+
export function GroupBand({ item, columnOf, }) {
|
|
47
|
+
switch (item.kind) {
|
|
48
|
+
case "serverGroup":
|
|
49
|
+
return (_jsx(ServerGroupHeader, { group: item.group, groupColumn: columnOf(item.owner.field), columnOf: columnOf, onToggle: item.owner.onToggle, onVisible: item.owner.onHeaderVisible }));
|
|
50
|
+
case "groupMore":
|
|
51
|
+
return _jsx(GroupLoadMore, { item: item });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableMeasure } from "../../../data-table-aggregate-types.ts";
|
|
3
|
+
import type { ListColumnViewModel } from "../../../headless/index.ts";
|
|
4
|
+
export declare function measureLabel(measure: DataTableMeasure, column: ListColumnViewModel | undefined, translate: ((key: string, params?: Readonly<Record<string, unknown>>) => string) | undefined): string;
|
|
5
|
+
export declare function measureKindLabel(kind: DataTableMeasure["kind"], translate: ((key: string, params?: Readonly<Record<string, unknown>>) => string) | undefined): string;
|
|
6
|
+
/** One measure formatted like its column; a money measure lists every currency. */
|
|
7
|
+
export declare function MeasureValue({ measure, column, }: {
|
|
8
|
+
readonly measure: DataTableMeasure;
|
|
9
|
+
readonly column: ListColumnViewModel | undefined;
|
|
10
|
+
}): ReactNode;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { isListAggregateCurrencyAmounts } from "@cosmicdrift/kumiko-types/list-aggregate-types";
|
|
3
|
+
import { useOptionalLocale, useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
import { DataTableCell } from "../data-table-cell.js";
|
|
5
|
+
// Money measures arrive in the storage scale (minor units, ×100), one entry per currency.
|
|
6
|
+
const MONEY_STORAGE_SCALE = 100;
|
|
7
|
+
const NO_ROW = {};
|
|
8
|
+
function cellTypeOf(measure, column) {
|
|
9
|
+
return measure.kind === "count" ? "number" : (column?.type ?? "number");
|
|
10
|
+
}
|
|
11
|
+
export function measureLabel(measure, column, translate) {
|
|
12
|
+
const label = column?.label ?? measure.field;
|
|
13
|
+
return (translate?.(`kumiko.list.measure.${measure.kind}`, { label }) ?? `${measure.kind} ${label}`);
|
|
14
|
+
}
|
|
15
|
+
export function measureKindLabel(kind, translate) {
|
|
16
|
+
return translate?.(`kumiko.list.measure.kind.${kind}`) ?? kind;
|
|
17
|
+
}
|
|
18
|
+
/** One measure formatted like its column; a money measure lists every currency. */
|
|
19
|
+
export function MeasureValue({ measure, column, }) {
|
|
20
|
+
const translate = useOptionalTranslation();
|
|
21
|
+
const locale = useOptionalLocale();
|
|
22
|
+
const type = cellTypeOf(measure, column);
|
|
23
|
+
const cell = (value, key) => (_jsx(DataTableCell, { value: value, row: NO_ROW, field: measure.field, type: type, renderer: measure.kind === "count" ? undefined : column?.renderer, translate: translate, locale: locale, ...(column?.optionLabels !== undefined && { optionLabels: column.optionLabels }), ...(column?.grouping !== undefined && { grouping: column.grouping }) }, key));
|
|
24
|
+
const { value } = measure;
|
|
25
|
+
if (!isListAggregateCurrencyAmounts(value))
|
|
26
|
+
return cell(value, measure.kind);
|
|
27
|
+
return (_jsx("span", { className: "inline-flex flex-col items-end", children: value.map((entry) => cell({ amount: entry.value / MONEY_STORAGE_SCALE, currency: entry.currency }, entry.currency)) }));
|
|
28
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { DataTableColumnPins } from "../../../data-table-view-types.ts";
|
|
3
|
+
export declare function PinSection({ fields, labels, pins, onChange, }: {
|
|
4
|
+
readonly fields: readonly string[];
|
|
5
|
+
readonly labels: ReadonlyMap<string, string>;
|
|
6
|
+
readonly pins: DataTableColumnPins;
|
|
7
|
+
readonly onChange: (next: DataTableColumnPins) => void;
|
|
8
|
+
}): ReactNode;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { PanelLeft, PanelRight } from "lucide-react";
|
|
3
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
5
|
+
function togglePin(pins, field, side) {
|
|
6
|
+
const { [field]: current, ...others } = pins;
|
|
7
|
+
return current === side ? others : { ...others, [field]: side };
|
|
8
|
+
}
|
|
9
|
+
function PinButton({ field, label, side, pinned, onClick, }) {
|
|
10
|
+
const translate = useOptionalTranslation();
|
|
11
|
+
const Icon = side === "left" ? PanelLeft : PanelRight;
|
|
12
|
+
const ariaLabel = translate?.(`kumiko.list.view.pin-${side}`, { column: label }) ?? `Pin ${label} ${side}`;
|
|
13
|
+
return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-6 aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90", "aria-label": ariaLabel, "aria-pressed": pinned, "data-testid": `list-view-pin-${field}-${side}`, onClick: onClick, children: _jsx(Icon, { className: "size-3.5", "aria-hidden": "true" }) }));
|
|
14
|
+
}
|
|
15
|
+
export function PinSection({ fields, labels, pins, onChange, }) {
|
|
16
|
+
return (_jsx("ul", { className: "m-0 flex list-none flex-col p-0", "data-testid": "list-view-pins", children: fields.map((field) => {
|
|
17
|
+
const label = labels.get(field) ?? field;
|
|
18
|
+
return (_jsxs("li", { className: "flex h-8 items-center gap-1.5", children: [_jsx("span", { className: "min-w-0 flex-1 truncate text-sm", children: label }), ["left", "right"].map((side) => (_jsx(PinButton, { field: field, label: label, side: side, pinned: pins[field] === side, onClick: () => onChange(togglePin(pins, field, side)) }, side)))] }, field));
|
|
19
|
+
}) }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { X } from "lucide-react";
|
|
3
|
+
import { useOptionalTranslation } from "../../../index.js";
|
|
4
|
+
import { Button as UiButton } from "../../ui/button.js";
|
|
5
|
+
export function ResetAllButton({ onReset }) {
|
|
6
|
+
const translate = useOptionalTranslation();
|
|
7
|
+
return (_jsxs(UiButton, { variant: "ghost", size: "sm", className: "h-9 px-2", onClick: onReset, "data-testid": "list-reset-all", children: [translate?.("kumiko.list.reset-all") ?? "Reset", _jsx(X, {})] }));
|
|
8
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { DataTableServerGroup } from "../../../data-table-aggregate-types.ts";
|
|
3
|
+
import type { ListColumnViewModel } from "../../../headless/index.ts";
|
|
4
|
+
/** Header of a server group: toggle, value, server row count and the group's measures. */
|
|
5
|
+
export declare function ServerGroupHeader({ group, groupColumn, columnOf, onToggle, onVisible, }: {
|
|
6
|
+
readonly group: DataTableServerGroup;
|
|
7
|
+
readonly groupColumn: ListColumnViewModel | undefined;
|
|
8
|
+
readonly columnOf: (field: string) => ListColumnViewModel | undefined;
|
|
9
|
+
readonly onToggle: (key: string) => void;
|
|
10
|
+
readonly onVisible: (key: string) => void;
|
|
11
|
+
}): ReactNode;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { useOptionalLocale, useOptionalTranslation } from "../../../index.js";
|
|
5
|
+
import { DataTableCell } from "../data-table-cell.js";
|
|
6
|
+
import { MeasureValue, measureLabel } from "./measure-value.js";
|
|
7
|
+
const NO_ROW = {};
|
|
8
|
+
// Reports the header once it scrolls near the viewport; without an observer
|
|
9
|
+
// (tests, old engines) every header counts as visible right away.
|
|
10
|
+
function useReportWhenVisible(onVisible) {
|
|
11
|
+
const ref = useRef(null);
|
|
12
|
+
const onVisibleRef = useRef(onVisible);
|
|
13
|
+
onVisibleRef.current = onVisible;
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const node = ref.current;
|
|
16
|
+
if (node === null)
|
|
17
|
+
return;
|
|
18
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
19
|
+
onVisibleRef.current();
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const observer = new IntersectionObserver((entries) => {
|
|
23
|
+
if (!entries.some((entry) => entry.isIntersecting))
|
|
24
|
+
return;
|
|
25
|
+
observer.disconnect();
|
|
26
|
+
onVisibleRef.current();
|
|
27
|
+
}, { rootMargin: "200px" });
|
|
28
|
+
observer.observe(node);
|
|
29
|
+
return () => observer.disconnect();
|
|
30
|
+
}, []);
|
|
31
|
+
return ref;
|
|
32
|
+
}
|
|
33
|
+
function GroupValueLabel({ group, column, }) {
|
|
34
|
+
const translate = useOptionalTranslation();
|
|
35
|
+
const locale = useOptionalLocale();
|
|
36
|
+
if (group.label !== undefined)
|
|
37
|
+
return group.label;
|
|
38
|
+
if (group.value === null || group.value === "") {
|
|
39
|
+
return translate?.("kumiko.list.group.empty") ?? "(empty)";
|
|
40
|
+
}
|
|
41
|
+
return (_jsx(DataTableCell, { value: group.value, row: NO_ROW, field: column?.field ?? "", type: column?.type ?? "text", translate: translate, locale: locale, ...(column?.optionLabels !== undefined && { optionLabels: column.optionLabels }), ...(column?.optionTones !== undefined && { optionTones: column.optionTones }) }));
|
|
42
|
+
}
|
|
43
|
+
/** Header of a server group: toggle, value, server row count and the group's measures. */
|
|
44
|
+
export function ServerGroupHeader({ group, groupColumn, columnOf, onToggle, onVisible, }) {
|
|
45
|
+
const translate = useOptionalTranslation();
|
|
46
|
+
const reportRef = useReportWhenVisible(() => onVisible(group.key));
|
|
47
|
+
return (_jsxs("div", { ref: reportRef, className: "flex flex-wrap items-center gap-x-4 gap-y-1 pe-4 text-xs text-foreground-secondary", children: [_jsxs("button", { type: "button", "aria-expanded": !group.collapsed, "data-testid": `row-group-${group.key}-toggle`, onClick: () => onToggle(group.key), className: "flex items-center gap-2 py-2 ps-4 text-left font-medium", children: [group.collapsed ? (_jsx(ChevronRight, { className: "size-3.5", "aria-hidden": "true" })) : (_jsx(ChevronDown, { className: "size-3.5", "aria-hidden": "true" })), _jsx(GroupValueLabel, { group: group, column: groupColumn }), _jsx("span", { "data-testid": `row-group-${group.key}-count`, className: "rounded-full bg-muted px-2 py-0.5 tabular-nums text-muted-foreground", children: group.count.toLocaleString() })] }), group.measures.map((measure) => {
|
|
48
|
+
const column = columnOf(measure.field);
|
|
49
|
+
return (_jsxs("span", { "data-testid": `row-group-${group.key}-${measure.kind}-${measure.field}`, className: "inline-flex items-center gap-1.5 tabular-nums", children: [_jsx("span", { className: "text-muted-foreground", children: measureLabel(measure, column, translate) }), _jsx(MeasureValue, { measure: measure, column: column })] }, `${measure.kind}-${measure.field}`));
|
|
50
|
+
})] }));
|
|
51
|
+
}
|