@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,115 @@
|
|
|
1
|
+
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { hasUnsavedInput, requestLeave } from "../../index.js";
|
|
3
|
+
// Back/Forward cannot be vetoed, so the browser has already moved by the time the guard asks.
|
|
4
|
+
// Each entry we create carries its position in history.state; with it a declined move is
|
|
5
|
+
// undone with history.go(-delta) and a confirmed one replayed with history.go(delta), so the
|
|
6
|
+
// stack keeps its shape (no duplicated or truncated entries).
|
|
7
|
+
const INDEX_KEY = "kumikoNavIndex";
|
|
8
|
+
const committed = { url: "", index: 0 };
|
|
9
|
+
let leaveRequestOpen = false;
|
|
10
|
+
// Each history.go() we issue produces one popstate that must not be treated as user navigation.
|
|
11
|
+
const pendingMoves = [];
|
|
12
|
+
// Entries without a position (written by other code) cannot be undone relatively.
|
|
13
|
+
let fallbackTargetUrl;
|
|
14
|
+
function readUrl() {
|
|
15
|
+
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
16
|
+
}
|
|
17
|
+
function stateAt(index) {
|
|
18
|
+
return { [INDEX_KEY]: index };
|
|
19
|
+
}
|
|
20
|
+
function readIndex() {
|
|
21
|
+
const value = propertyOf(window.history.state, INDEX_KEY);
|
|
22
|
+
return typeof value === "number" ? value : undefined;
|
|
23
|
+
}
|
|
24
|
+
export function committedUrl() {
|
|
25
|
+
return committed.url;
|
|
26
|
+
}
|
|
27
|
+
/** Adopts the entry the browser currently shows as the committed one. */
|
|
28
|
+
export function adoptCurrentEntry() {
|
|
29
|
+
const index = readIndex();
|
|
30
|
+
committed.url = readUrl();
|
|
31
|
+
if (index === undefined) {
|
|
32
|
+
window.history.replaceState(stateAt(committed.index), "", committed.url);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
committed.index = index;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function commitCurrentUrl() {
|
|
39
|
+
committed.url = readUrl();
|
|
40
|
+
committed.index = readIndex() ?? committed.index;
|
|
41
|
+
}
|
|
42
|
+
export function pushEntry(url) {
|
|
43
|
+
window.history.pushState(stateAt(committed.index + 1), "", url);
|
|
44
|
+
committed.index += 1;
|
|
45
|
+
committed.url = readUrl();
|
|
46
|
+
}
|
|
47
|
+
export function replaceEntry(url) {
|
|
48
|
+
window.history.replaceState(stateAt(committed.index), "", url);
|
|
49
|
+
commitCurrentUrl();
|
|
50
|
+
}
|
|
51
|
+
export function handlePopstate(notify) {
|
|
52
|
+
const move = pendingMoves.shift();
|
|
53
|
+
if (move !== undefined) {
|
|
54
|
+
if (move === "replay") {
|
|
55
|
+
commitCurrentUrl();
|
|
56
|
+
notify();
|
|
57
|
+
}
|
|
58
|
+
// skip: this popstate is the echo of our own history.go()
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (fallbackTargetUrl !== undefined) {
|
|
62
|
+
const target = fallbackTargetUrl;
|
|
63
|
+
fallbackTargetUrl = undefined;
|
|
64
|
+
// After a forward move the entry below our restore-push is not the target.
|
|
65
|
+
if (readUrl() !== target)
|
|
66
|
+
window.history.replaceState(stateAt(committed.index), "", target);
|
|
67
|
+
commitCurrentUrl();
|
|
68
|
+
notify();
|
|
69
|
+
// skip: the pending fallback move is settled
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const newIndex = readIndex();
|
|
73
|
+
const delta = newIndex === undefined ? 0 : newIndex - committed.index;
|
|
74
|
+
if (leaveRequestOpen) {
|
|
75
|
+
// Another Back/Forward while the dialog is open must neither stack an entry nor replace the
|
|
76
|
+
// open request: step back to the entry the dialog is about.
|
|
77
|
+
if (delta !== 0) {
|
|
78
|
+
pendingMoves.push("undo");
|
|
79
|
+
window.history.go(-delta);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
window.history.replaceState(stateAt(committed.index), "", committed.url);
|
|
83
|
+
}
|
|
84
|
+
// skip: the open leave dialog keeps the decision
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!hasUnsavedInput()) {
|
|
88
|
+
commitCurrentUrl();
|
|
89
|
+
notify();
|
|
90
|
+
// skip: nothing unsaved, the move stands
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const targetUrl = readUrl();
|
|
94
|
+
leaveRequestOpen = true;
|
|
95
|
+
const settle = () => {
|
|
96
|
+
leaveRequestOpen = false;
|
|
97
|
+
};
|
|
98
|
+
if (delta !== 0) {
|
|
99
|
+
pendingMoves.push("undo");
|
|
100
|
+
window.history.go(-delta);
|
|
101
|
+
requestLeave(() => {
|
|
102
|
+
settle();
|
|
103
|
+
pendingMoves.push("replay");
|
|
104
|
+
window.history.go(delta);
|
|
105
|
+
}, settle);
|
|
106
|
+
// skip: the leave request owns the rest of the move
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
window.history.pushState(stateAt(committed.index), "", committed.url);
|
|
110
|
+
requestLeave(() => {
|
|
111
|
+
settle();
|
|
112
|
+
fallbackTargetUrl = targetUrl;
|
|
113
|
+
window.history.back();
|
|
114
|
+
}, settle);
|
|
115
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Provides the tab-scoped list-state store and drops state remembered under
|
|
3
|
+
* another user or tenant. Mount below the auth gates so the user is known. */
|
|
4
|
+
export declare function ListStateStoreBoot({ children }: {
|
|
5
|
+
readonly children: ReactNode;
|
|
6
|
+
}): ReactNode;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { ListStateStoreProvider } from "../../components/list-state-store.js";
|
|
4
|
+
import { useOptionalCurrentUser } from "../../current-user.js";
|
|
5
|
+
import { resolveListStateStore } from "./session-list-state-store.js";
|
|
6
|
+
/** Provides the tab-scoped list-state store and drops state remembered under
|
|
7
|
+
* another user or tenant. Mount below the auth gates so the user is known. */
|
|
8
|
+
export function ListStateStoreBoot({ children }) {
|
|
9
|
+
const user = useOptionalCurrentUser();
|
|
10
|
+
const userId = user?.id;
|
|
11
|
+
const tenantId = user?.tenantId;
|
|
12
|
+
// Must run before the first list renders; an effect would let it read the previous identity's state.
|
|
13
|
+
const store = useMemo(() => resolveListStateStore(userId === undefined ? undefined : { userId, tenantId }), [userId, tenantId]);
|
|
14
|
+
return _jsx(ListStateStoreProvider, { store: store, children: children });
|
|
15
|
+
}
|
package/dist/web/app/nav.js
CHANGED
|
@@ -12,7 +12,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
12
12
|
// notification triggers (our pushPath and the popstate event), so this is not
|
|
13
13
|
// generalizable.
|
|
14
14
|
import { useCallback, useMemo, useSyncExternalStore, } from "react";
|
|
15
|
+
import { normalizeBasePath, prependBasePath, stripBasePath } from "../../app/base-path.js";
|
|
15
16
|
import { formatPath, hasUnsavedInput, parsePath, requestLeave, resolveTarget, useNav, } from "../../index.js";
|
|
17
|
+
import { adoptCurrentEntry, handlePopstate, pushEntry, replaceEntry, } from "./history-leave-guard.js";
|
|
16
18
|
// basePath allows the app to be mounted under a URL prefix
|
|
17
19
|
// (for example, `/admin`). The read path strips the prefix before parsePath;
|
|
18
20
|
// the write path prepends it before pushState/replaceState/href.
|
|
@@ -22,45 +24,11 @@ import { formatPath, hasUnsavedInput, parsePath, requestLeave, resolveTarget, us
|
|
|
22
24
|
// returns route=undefined, and the app renders its "outside" state
|
|
23
25
|
// (Not-Found, marketing layer, server-routing pickup, …). There is no
|
|
24
26
|
// automatic redirect to the app root—the host app decides.
|
|
25
|
-
function normalizeBasePath(raw) {
|
|
26
|
-
if (raw === undefined || raw === "" || raw === "/")
|
|
27
|
-
return "";
|
|
28
|
-
const withLeading = raw.startsWith("/") ? raw : `/${raw}`;
|
|
29
|
-
return withLeading.endsWith("/") ? withLeading.slice(0, -1) : withLeading;
|
|
30
|
-
}
|
|
31
|
-
// Returns the in-app (basePath-relative) path when `path` is under basePath;
|
|
32
|
-
// otherwise returns `undefined`. The segment boundary is strict: "/administrator"
|
|
33
|
-
// does not match "/admin"—otherwise a string-level prefix check would produce
|
|
34
|
-
// false matches.
|
|
35
|
-
function stripBasePath(path, basePath) {
|
|
36
|
-
if (basePath === "")
|
|
37
|
-
return path;
|
|
38
|
-
if (path === basePath)
|
|
39
|
-
return "/";
|
|
40
|
-
if (path.startsWith(`${basePath}/`))
|
|
41
|
-
return path.slice(basePath.length);
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
function prependBasePath(path, basePath) {
|
|
45
|
-
if (basePath === "")
|
|
46
|
-
return path;
|
|
47
|
-
// formatPath always returns an absolute in-app path: "/" or "/screen-id"
|
|
48
|
-
// or "/screen-id/entity-id". "/" becomes basePath; otherwise, concatenate.
|
|
49
|
-
if (path === "/" || path === "")
|
|
50
|
-
return basePath;
|
|
51
|
-
return `${basePath}${path}`;
|
|
52
|
-
}
|
|
53
27
|
// pushState does not fire popstate, so we maintain a listener set that
|
|
54
28
|
// navigate() notifies. Back/Forward navigation goes through a window listener
|
|
55
29
|
// wired exactly once.
|
|
56
30
|
const listeners = new Set();
|
|
57
31
|
let popstateWired = false;
|
|
58
|
-
// URL the app currently shows; popstate has already moved the browser to the
|
|
59
|
-
// target by the time we can ask, so a declined leave must restore this one.
|
|
60
|
-
let committedUrl = "";
|
|
61
|
-
function readUrl() {
|
|
62
|
-
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
63
|
-
}
|
|
64
32
|
// Stable identity for the omitted-features case — `?? []` would create a
|
|
65
33
|
// fresh array every render, busting the useMemo below on every render for
|
|
66
34
|
// every existing useBrowserNavApi() call site (none of which pass features).
|
|
@@ -71,19 +39,7 @@ function ensurePopstateWired() {
|
|
|
71
39
|
if (typeof window === "undefined")
|
|
72
40
|
return;
|
|
73
41
|
window.addEventListener("popstate", () => {
|
|
74
|
-
|
|
75
|
-
committedUrl = readUrl();
|
|
76
|
-
for (const l of listeners)
|
|
77
|
-
l();
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
const targetUrl = readUrl();
|
|
81
|
-
window.history.pushState(null, "", committedUrl);
|
|
82
|
-
requestLeave(() => {
|
|
83
|
-
// replaceState, not history.back(): the declined entry is already ours
|
|
84
|
-
// and a forward navigation would otherwise land on the wrong entry.
|
|
85
|
-
window.history.replaceState(null, "", targetUrl);
|
|
86
|
-
committedUrl = targetUrl;
|
|
42
|
+
handlePopstate(() => {
|
|
87
43
|
for (const l of listeners)
|
|
88
44
|
l();
|
|
89
45
|
});
|
|
@@ -99,7 +55,7 @@ function ensurePopstateWired() {
|
|
|
99
55
|
function subscribe(listener) {
|
|
100
56
|
ensurePopstateWired();
|
|
101
57
|
if (typeof window !== "undefined" && listeners.size === 0)
|
|
102
|
-
|
|
58
|
+
adoptCurrentEntry();
|
|
103
59
|
listeners.add(listener);
|
|
104
60
|
return () => {
|
|
105
61
|
listeners.delete(listener);
|
|
@@ -146,8 +102,7 @@ function applySearchParamUpdates(updates) {
|
|
|
146
102
|
if (nextSearch === window.location.search)
|
|
147
103
|
return;
|
|
148
104
|
const url = `${window.location.pathname}${nextSearch}${window.location.hash}`;
|
|
149
|
-
|
|
150
|
-
committedUrl = readUrl();
|
|
105
|
+
replaceEntry(url);
|
|
151
106
|
for (const l of listeners)
|
|
152
107
|
l();
|
|
153
108
|
}
|
|
@@ -168,8 +123,7 @@ function pushPath(path, search) {
|
|
|
168
123
|
if (unchanged)
|
|
169
124
|
return;
|
|
170
125
|
requestLeave(() => {
|
|
171
|
-
|
|
172
|
-
committedUrl = readUrl();
|
|
126
|
+
pushEntry(`${path}${search ?? ""}`);
|
|
173
127
|
for (const l of listeners)
|
|
174
128
|
l();
|
|
175
129
|
});
|
|
@@ -185,8 +139,7 @@ function replacePath(path, search) {
|
|
|
185
139
|
const leavesCurrentPage = window.location.pathname !== path ||
|
|
186
140
|
(search !== undefined && window.location.search !== search);
|
|
187
141
|
const apply = () => {
|
|
188
|
-
|
|
189
|
-
committedUrl = readUrl();
|
|
142
|
+
replaceEntry(nextUrl);
|
|
190
143
|
for (const l of listeners)
|
|
191
144
|
l();
|
|
192
145
|
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ListStateStore } from "../../components/list-state-store.tsx";
|
|
2
|
+
export type ListStateIdentity = {
|
|
3
|
+
readonly userId: string;
|
|
4
|
+
readonly tenantId?: string | undefined;
|
|
5
|
+
};
|
|
6
|
+
/** Remembered list state for the lifetime of the browser tab, keyed per tenant
|
|
7
|
+
* and user. Every storage failure (blocked, full) degrades to "nothing remembered". */
|
|
8
|
+
export declare function createSessionListStateStore(storage: Storage, identity: ListStateIdentity): ListStateStore;
|
|
9
|
+
/** Forgets all remembered list state and the identity marker. Logout calls it
|
|
10
|
+
* before a page reload, which would otherwise hide the identity change. */
|
|
11
|
+
export declare function clearRememberedListState(): void;
|
|
12
|
+
/** The store for the current identity. State remembered under another identity,
|
|
13
|
+
* or under one that has since gone away, is dropped first. Without an identity
|
|
14
|
+
* (app without auth) only the in-memory store is used and nothing is persisted. */
|
|
15
|
+
export declare function resolveListStateStore(identity: ListStateIdentity | undefined): ListStateStore;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { getDefaultMemoryListStateStore } from "../../components/list-state-store.js";
|
|
2
|
+
import { parseStoredListStateJson } from "../../hooks/list-state-schema.js";
|
|
3
|
+
const KEY_PREFIX = "kumiko.list.";
|
|
4
|
+
const IDENTITY_KEY = `${KEY_PREFIX}identity`;
|
|
5
|
+
const NO_TENANT = "-";
|
|
6
|
+
function identityMarker(identity) {
|
|
7
|
+
return `${identity.tenantId ?? NO_TENANT}.${identity.userId}`;
|
|
8
|
+
}
|
|
9
|
+
function openSessionStorage() {
|
|
10
|
+
try {
|
|
11
|
+
return typeof window === "undefined" ? undefined : window.sessionStorage;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Access itself throws when storage is blocked (privacy modes, sandboxed frames).
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function prefixedKeys(storage) {
|
|
19
|
+
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter((key) => key !== null && key.startsWith(KEY_PREFIX));
|
|
20
|
+
}
|
|
21
|
+
function removeAllRemembered(storage) {
|
|
22
|
+
for (const key of prefixedKeys(storage))
|
|
23
|
+
storage.removeItem(key);
|
|
24
|
+
}
|
|
25
|
+
/** Remembered list state for the lifetime of the browser tab, keyed per tenant
|
|
26
|
+
* and user. Every storage failure (blocked, full) degrades to "nothing remembered". */
|
|
27
|
+
export function createSessionListStateStore(storage, identity) {
|
|
28
|
+
const keyPrefix = `${KEY_PREFIX}${identityMarker(identity)}.`;
|
|
29
|
+
return {
|
|
30
|
+
read: (key) => {
|
|
31
|
+
try {
|
|
32
|
+
const raw = storage.getItem(`${keyPrefix}${key}`);
|
|
33
|
+
return raw === null ? undefined : parseStoredListStateJson(raw);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
write: (key, state) => {
|
|
40
|
+
try {
|
|
41
|
+
storage.setItem(`${keyPrefix}${key}`, JSON.stringify(state));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Quota or blocked storage: the list keeps working, it just is not remembered.
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
remove: (key) => {
|
|
48
|
+
try {
|
|
49
|
+
storage.removeItem(`${keyPrefix}${key}`);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// See write.
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
clear: () => {
|
|
56
|
+
try {
|
|
57
|
+
for (const key of prefixedKeys(storage).filter((k) => k !== IDENTITY_KEY)) {
|
|
58
|
+
storage.removeItem(key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// See write.
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** Forgets all remembered list state and the identity marker. Logout calls it
|
|
68
|
+
* before a page reload, which would otherwise hide the identity change. */
|
|
69
|
+
export function clearRememberedListState() {
|
|
70
|
+
getDefaultMemoryListStateStore().clear();
|
|
71
|
+
lastMemoryIdentity = undefined;
|
|
72
|
+
const storage = openSessionStorage();
|
|
73
|
+
// skip: no sessionStorage available, memory store already cleared
|
|
74
|
+
if (storage === undefined)
|
|
75
|
+
return;
|
|
76
|
+
try {
|
|
77
|
+
removeAllRemembered(storage);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// See createSessionListStateStore.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// The in-memory store survives SPA identity changes, so it needs its own marker.
|
|
84
|
+
let lastMemoryIdentity;
|
|
85
|
+
/** The store for the current identity. State remembered under another identity,
|
|
86
|
+
* or under one that has since gone away, is dropped first. Without an identity
|
|
87
|
+
* (app without auth) only the in-memory store is used and nothing is persisted. */
|
|
88
|
+
export function resolveListStateStore(identity) {
|
|
89
|
+
const storage = openSessionStorage();
|
|
90
|
+
const memory = getDefaultMemoryListStateStore();
|
|
91
|
+
const marker = identity === undefined ? undefined : identityMarker(identity);
|
|
92
|
+
if (lastMemoryIdentity !== marker) {
|
|
93
|
+
if (lastMemoryIdentity !== undefined)
|
|
94
|
+
memory.clear();
|
|
95
|
+
lastMemoryIdentity = marker;
|
|
96
|
+
}
|
|
97
|
+
if (storage === undefined)
|
|
98
|
+
return memory;
|
|
99
|
+
try {
|
|
100
|
+
const storedMarker = storage.getItem(IDENTITY_KEY);
|
|
101
|
+
if (marker === undefined) {
|
|
102
|
+
if (storedMarker !== null) {
|
|
103
|
+
removeAllRemembered(storage);
|
|
104
|
+
memory.clear();
|
|
105
|
+
}
|
|
106
|
+
return memory;
|
|
107
|
+
}
|
|
108
|
+
if (storedMarker !== marker) {
|
|
109
|
+
removeAllRemembered(storage);
|
|
110
|
+
storage.setItem(IDENTITY_KEY, marker);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return memory;
|
|
115
|
+
}
|
|
116
|
+
return identity === undefined ? memory : createSessionListStateStore(storage, identity);
|
|
117
|
+
}
|
package/dist/web/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { KumikoLink, useBrowserNavApi } from "./app/nav.tsx";
|
|
|
13
13
|
export { PlainContentEditor } from "./app/plain-content-editor.tsx";
|
|
14
14
|
export { useResolvers } from "./app/resolvers-context.tsx";
|
|
15
15
|
export { RichContentEditor } from "./app/rich-content-editor.tsx";
|
|
16
|
+
export { clearRememberedListState } from "./app/session-list-state-store.ts";
|
|
16
17
|
export type { NavIconKey } from "./icons.tsx";
|
|
17
18
|
export { Icon } from "./icons.tsx";
|
|
18
19
|
export type { AppLayoutProps } from "./layout/app-layout.tsx";
|
|
@@ -75,4 +76,4 @@ export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from
|
|
|
75
76
|
export type { KumikoBuild } from "./version/update-checker.tsx";
|
|
76
77
|
export { readLoadedBuild } from "./version/update-checker.tsx";
|
|
77
78
|
export type { AiTextAreaProps, AiTextFieldProps, BooleanFieldProps, ComparisonMetric, DateFieldProps, DrawerProps, FeedRow, FileFieldProps, FloatingPanelGeometry, FloatingPanelProps, InfinityListProps, InfinityListSelection, ModeSwitchVariant, NumberFieldProps, PdfPreviewSource, PhotoSlotSpec, PhotoSlotsProps, PlanCardActionSlot, PlanCardLinkSlot, PlanCardPrice, PlanCardProps, PlanGridProps, ProgressListRow, PublicShellProps, PublicShellVariant, QueryTableColumn, QueryTableProps, RangeFieldProps, ResultColumn, SelectFieldProps, SideBySideTableCell, SideBySideTableColumn, SideBySideTableRow, StatDelta, StatTone, StatusBadgeTone, StatusBarEntry, StatusTone, TextareaFieldProps, TextFieldProps, TimeseriesHeight, TimeseriesPoint, TimeseriesReferenceLine, TimeseriesXAxis, TimeseriesYAxis, UploadZoneProps, } from "./widgets/index.ts";
|
|
78
|
-
export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
|
|
79
|
+
export { AiTextArea, AiTextField, BooleanField, ChoiceCards, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.ts";
|
package/dist/web/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { KumikoLink, useBrowserNavApi } from "./app/nav.js";
|
|
|
12
12
|
export { PlainContentEditor } from "./app/plain-content-editor.js";
|
|
13
13
|
export { useResolvers } from "./app/resolvers-context.js";
|
|
14
14
|
export { RichContentEditor } from "./app/rich-content-editor.js";
|
|
15
|
+
export { clearRememberedListState } from "./app/session-list-state-store.js";
|
|
15
16
|
export { Icon } from "./icons.js";
|
|
16
17
|
export { AppLayout } from "./layout/app-layout.js";
|
|
17
18
|
export { Avatar } from "./layout/avatar.js";
|
|
@@ -50,4 +51,4 @@ export { applyTokensToCssVars, defaultTokens, lightTokens, useBrowserTokensApi,
|
|
|
50
51
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "./ui/resizable.js";
|
|
51
52
|
export { SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider } from "./ui/sidebar.js";
|
|
52
53
|
export { readLoadedBuild } from "./version/update-checker.js";
|
|
53
|
-
export { AiTextArea, AiTextField, BooleanField, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
|
|
54
|
+
export { AiTextArea, AiTextField, BooleanField, ChoiceCards, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, ErrorState, FeedList, FileField, FloatingPanel, InfinityList, LoadingState, MiniStat, ModeSwitch, MoneyField, NumberField, PdfPreview, PercentField, PhotoSlots, PlanCard, PlanGrid, ProgressBar, ProgressList, PublicShell, QueryTable, RangeField, ResultPanel, ResultTable, SectionCard, SelectField, SideBySideTable, Sparkline, STATUS_TONE_TEXT, StatCard, StatusBadge, StatusBarChart, StepBar, smoothPath, TextareaField, TextField, TimeseriesChart, UploadZone, useDraft, } from "./widgets/index.js";
|
|
@@ -19,6 +19,7 @@ import { HeaderOverflowMenuContext, headerOverflowMenuItemClass } from "./header
|
|
|
19
19
|
import { buildNavRegistrySliceForApp, lastSegment } from "./nav-tree.js";
|
|
20
20
|
import { usePageHeaderSlot } from "./page-header-slot.js";
|
|
21
21
|
import { resolveDetailBreadcrumb, screenTitleKey, } from "./shell-breadcrumb.js";
|
|
22
|
+
import { UNIFORM_BAR_CONTROLS_CLASSES } from "./uniform-bar-controls.js";
|
|
22
23
|
export function ShellHeader({ schema, user, headerActions, }) {
|
|
23
24
|
const nav = useNav();
|
|
24
25
|
const t = useTranslation();
|
|
@@ -68,7 +69,7 @@ export function ShellHeader({ schema, user, headerActions, }) {
|
|
|
68
69
|
e.preventDefault();
|
|
69
70
|
nav.navigate({ screenId });
|
|
70
71
|
}, children: crumb.label })) : isLast ? (_jsx("h1", { "aria-current": "page", title: titleOverride ?? crumb.label, className: "truncate text-lg font-semibold text-foreground", children: titleOverride ?? crumb.label })) : (_jsx(BreadcrumbPage, { className: "whitespace-nowrap text-foreground-secondary", children: crumb.label })) })] }, screenId ?? crumb.label));
|
|
71
|
-
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn("flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
72
|
+
}) }) })), _jsx("div", { ref: setStatusElement, "data-kumiko-layout": "page-header-status", className: cn("flex items-center gap-2 empty:hidden", compact ? "min-w-0 overflow-hidden" : "shrink-0") })] }), _jsxs("div", { className: cn("ml-auto flex items-center gap-2", compact ? "min-w-0 max-w-[60%]" : "min-w-0 max-w-[50%] shrink-0 sm:max-w-none"), children: [_jsx("div", { ref: setActionsElement, "data-kumiko-layout": "page-header-actions", className: cn(UNIFORM_BAR_CONTROLS_CLASSES, "flex min-w-0 items-center gap-2 empty:hidden [&_*]:min-w-0 [&_*]:max-w-full [&_button]:overflow-hidden [&_button]:whitespace-nowrap", compact && "overflow-hidden") }), !compact && headerActions !== undefined && (_jsx("div", { "data-kumiko-layout": "header-actions", className: "flex items-center gap-2", children: headerActions })), hasOverflow && _jsx(HeaderOverflow, { items: overflowItems, headerActions: headerActions })] })] }));
|
|
72
73
|
}
|
|
73
74
|
// A hand-rolled menu instead of Radix DropdownMenu: app headerActions are opaque
|
|
74
75
|
// nodes with their own popovers, and the panel stays mounted while closed so their
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Header and toolbar controls share one height whatever size a screen, app or
|
|
2
|
+
// primitive asked for: the descendant selector outranks the button's own
|
|
3
|
+
// size utilities. Icon-only buttons keep their square size.
|
|
4
|
+
export const UNIFORM_BAR_CONTROLS_CLASSES = "[&_[data-slot=button]:not([data-size^=icon])]:h-9 [&_[data-slot=input]]:h-9";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DataTableExport } from "../../data-table-view-types.ts";
|
|
2
|
+
export type XlsxExportResult = {
|
|
3
|
+
readonly status: "ok";
|
|
4
|
+
readonly blob: Blob;
|
|
5
|
+
} | {
|
|
6
|
+
readonly status: "too-many";
|
|
7
|
+
readonly maxRows: number | undefined;
|
|
8
|
+
} | {
|
|
9
|
+
readonly status: "failed";
|
|
10
|
+
};
|
|
11
|
+
export declare function requestXlsxExport(source: DataTableExport): Promise<XlsxExportResult>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
2
|
+
import { CSRF_HEADER_NAME, LOCALE_HEADER_NAME, readActiveLocale, readCsrfToken, } from "../../live/index.js";
|
|
3
|
+
const TOO_MANY_ROWS_HTTP_STATUS = 413;
|
|
4
|
+
async function readMaxRows(res) {
|
|
5
|
+
try {
|
|
6
|
+
const body = await res.json();
|
|
7
|
+
const error = isPlainObject(body) ? body["error"] : undefined;
|
|
8
|
+
const details = isPlainObject(error) ? error["details"] : undefined;
|
|
9
|
+
const maxRows = isPlainObject(details) ? details["maxRows"] : undefined;
|
|
10
|
+
return typeof maxRows === "number" ? maxRows : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function requestXlsxExport(source) {
|
|
17
|
+
const headers = { "Content-Type": "application/json" };
|
|
18
|
+
const csrf = readCsrfToken();
|
|
19
|
+
if (csrf !== undefined)
|
|
20
|
+
headers[CSRF_HEADER_NAME] = csrf;
|
|
21
|
+
const locale = readActiveLocale();
|
|
22
|
+
if (locale !== undefined)
|
|
23
|
+
headers[LOCALE_HEADER_NAME] = locale;
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch("/api/export/xlsx", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
credentials: "include",
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify({
|
|
30
|
+
screen: source.screen,
|
|
31
|
+
payload: source.payload,
|
|
32
|
+
columns: source.columns,
|
|
33
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
if (res.ok)
|
|
37
|
+
return { status: "ok", blob: await res.blob() };
|
|
38
|
+
if (res.status === TOO_MANY_ROWS_HTTP_STATUS) {
|
|
39
|
+
return { status: "too-many", maxRows: await readMaxRows(res) };
|
|
40
|
+
}
|
|
41
|
+
return { status: "failed" };
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { status: "failed" };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -37,6 +37,9 @@ export async function fetchFileRefBlob(fileRefId) {
|
|
|
37
37
|
fileName: fileNameFromContentDisposition(res.headers.get("content-disposition")),
|
|
38
38
|
};
|
|
39
39
|
}
|
|
40
|
+
// The browser starts the download after click() returns; Safari and some
|
|
41
|
+
// Firefox versions fail it when the blob URL is already revoked.
|
|
42
|
+
const OBJECT_URL_REVOKE_DELAY_MS = 40_000;
|
|
40
43
|
export function saveBlobAsFile(fileName, blob, options) {
|
|
41
44
|
const payload = options?.bom === true ? new Blob([UTF8_BOM, blob], { type: blob.type }) : blob;
|
|
42
45
|
const url = URL.createObjectURL(payload);
|
|
@@ -53,7 +56,7 @@ export function saveBlobAsFile(fileName, blob, options) {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
finally {
|
|
56
|
-
URL.revokeObjectURL(url);
|
|
59
|
+
setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_REVOKE_DELAY_MS);
|
|
57
60
|
}
|
|
58
61
|
}
|
|
59
62
|
export async function downloadFileRef(fileRefId, options) {
|
|
@@ -23,7 +23,7 @@ export function ActionMenu({ trigger, triggerLabel, items, align = "end", minWid
|
|
|
23
23
|
return _jsx(DropdownMenuSeparator, {}, `sep-${idx}`);
|
|
24
24
|
}
|
|
25
25
|
if (item.kind === "label") {
|
|
26
|
-
// biome-ignore lint/suspicious/noArrayIndexKey:
|
|
26
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: labels have no ID either, so their position provides identity
|
|
27
27
|
return _jsx(DropdownMenuLabel, { children: item.label }, `label-${idx}`);
|
|
28
28
|
}
|
|
29
29
|
return (_jsxs(DropdownMenuItem, { onSelect: item.onSelect, disabled: item.disabled === true, "data-testid": `action-menu-item-${item.id}`, className: item.variant === "danger" ? "text-destructive focus:text-destructive" : undefined, children: [item.icon !== undefined && _jsx("span", { className: "size-4 shrink-0", children: item.icon }), _jsx("span", { className: "flex-1", children: item.label }), item.shortcut !== undefined && (_jsx("span", { className: "ml-auto text-xs text-muted-foreground tracking-wider font-mono", children: item.shortcut }))] }, item.id));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useContext } from "react";
|
|
3
|
+
import { cn } from "../lib/cn.js";
|
|
4
|
+
import { FieldDescriptionContext } from "./contexts.js";
|
|
5
|
+
import { useFocusOnMount } from "./use-focus-on-mount.js";
|
|
6
|
+
const MIN_LENGTH_FOR_SEPARATOR = 6;
|
|
7
|
+
export function CodeInput(props) {
|
|
8
|
+
const { length, value, hasError, disabled } = props;
|
|
9
|
+
const describedBy = useContext(FieldDescriptionContext);
|
|
10
|
+
const inputRef = useFocusOnMount(props.autoFocus);
|
|
11
|
+
const activeIndex = Math.min(value.length, length - 1);
|
|
12
|
+
const hasSeparator = length >= MIN_LENGTH_FOR_SEPARATOR && length % 2 === 0;
|
|
13
|
+
const handleChange = (e) => {
|
|
14
|
+
const next = e.target.value.replace(/\D/g, "").slice(0, length);
|
|
15
|
+
props.onChange(next);
|
|
16
|
+
if (next.length === length && next !== value)
|
|
17
|
+
props.onComplete?.(next);
|
|
18
|
+
};
|
|
19
|
+
const cells = [];
|
|
20
|
+
for (let index = 0; index < length; index++) {
|
|
21
|
+
const isActive = index === activeIndex;
|
|
22
|
+
const char = value[index];
|
|
23
|
+
if (hasSeparator && index === length / 2) {
|
|
24
|
+
cells.push(_jsx("span", { "aria-hidden": "true", className: "shrink-0 text-muted-foreground", children: "\u2013" }, "separator"));
|
|
25
|
+
}
|
|
26
|
+
cells.push(_jsxs("div", { "data-active": isActive, "aria-hidden": "true", className: cn("flex h-12 min-w-0 flex-1 items-center justify-center rounded-md border border-input bg-transparent text-xl font-semibold tabular-nums shadow-xs transition-[color,box-shadow] dark:bg-input/30", hasError === true && "border-destructive", "data-[active=true]:group-focus-within:ring-[3px]", hasError === true
|
|
27
|
+
? "data-[active=true]:group-focus-within:border-destructive data-[active=true]:group-focus-within:ring-destructive/20 dark:data-[active=true]:group-focus-within:ring-destructive/40"
|
|
28
|
+
: "data-[active=true]:group-focus-within:border-ring data-[active=true]:group-focus-within:ring-ring/50"), children: [char, isActive && char === undefined ? (_jsx("span", { className: "hidden h-6 w-px animate-pulse bg-foreground group-focus-within:block" })) : null] }, index));
|
|
29
|
+
}
|
|
30
|
+
return (_jsxs("div", { className: cn("group relative flex w-full items-center gap-2", disabled === true && "cursor-not-allowed opacity-50"), children: [cells, _jsx("input", { ref: inputRef, type: "text", id: props.id, name: props.name, value: value, onChange: handleChange, disabled: disabled, inputMode: "numeric", autoComplete: props.autoComplete ?? "one-time-code", spellCheck: false, "data-testid": props.testId, "aria-describedby": describedBy, "aria-required": props.required, "aria-invalid": hasError === true ? true : undefined, className: "absolute inset-0 h-full w-full bg-transparent text-transparent caret-transparent outline-none selection:bg-transparent" })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
2
|
+
export declare const ACTIONS_CONTENT_ATTRIBUTE = "data-actions-content";
|
|
3
|
+
export declare function stickyActionsCellClass(fillsHeight: boolean): string;
|
|
4
|
+
export declare function ActionsHeaderCell({ label, style, absorbsLeftover, }: {
|
|
5
|
+
readonly label: string;
|
|
6
|
+
readonly style?: CSSProperties | undefined;
|
|
7
|
+
/** Fixed table layout: `w-px` would pin this column to 1px, so it must size as auto and take the leftover width. */
|
|
8
|
+
readonly absorbsLeftover: boolean;
|
|
9
|
+
}): ReactNode;
|
|
10
|
+
export declare function ActionsBodyCell({ testId, fillsHeight, measureContent, children, }: {
|
|
11
|
+
readonly testId: string;
|
|
12
|
+
readonly fillsHeight: boolean;
|
|
13
|
+
/** Wraps the content so the column's natural width stays measurable under a fixed table layout. */
|
|
14
|
+
readonly measureContent: boolean;
|
|
15
|
+
readonly children: ReactNode;
|
|
16
|
+
}): ReactNode;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "../../lib/cn.js";
|
|
3
|
+
import { TableCell, TableHead } from "../../ui/table.js";
|
|
4
|
+
import { ROW_FOCUS_RING_CELL_CLASS } from "./use-row-keyboard-navigation.js";
|
|
5
|
+
export const ACTIONS_CONTENT_ATTRIBUTE = "data-actions-content";
|
|
6
|
+
export function stickyActionsCellClass(fillsHeight) {
|
|
7
|
+
return cn("md:sticky md:right-0 md:z-10 md:bg-background", fillsHeight && "md:bg-card");
|
|
8
|
+
}
|
|
9
|
+
export function ActionsHeaderCell({ label, style, absorbsLeftover, }) {
|
|
10
|
+
return (_jsx(TableHead, { "data-testid": "column-actions",
|
|
11
|
+
// From md: sticky right-0 + bg-muted (= header tone) so the
|
|
12
|
+
// action column stays at the right edge during horizontal
|
|
13
|
+
// scroll on wider screens. Below md, actions scroll in
|
|
14
|
+
// normal flow — sticky there would pin the column over the
|
|
15
|
+
// preceding data column instead of the (already narrow)
|
|
16
|
+
// page edge. No border-l: a permanent divider looks heavy;
|
|
17
|
+
// the sticky bg already sets the column apart during scroll.
|
|
18
|
+
className: "w-px text-right text-muted-foreground md:sticky md:right-0 md:z-10 md:bg-muted", "aria-label": label, style: absorbsLeftover ? { ...style, width: "auto" } : style }));
|
|
19
|
+
}
|
|
20
|
+
export function ActionsBodyCell({ testId, fillsHeight, measureContent, children, }) {
|
|
21
|
+
return (_jsx(TableCell, { "data-testid": testId,
|
|
22
|
+
// From md: sticky-right so the actions stay visible on the
|
|
23
|
+
// right edge during horizontal scroll. Below md, actions
|
|
24
|
+
// scroll with the row like any other cell — sticky there
|
|
25
|
+
// would hide the neighboring data column on narrow
|
|
26
|
+
// viewports. bg-background sets the column apart during
|
|
27
|
+
// scroll — no border-l (divider too heavy).
|
|
28
|
+
className: cn("whitespace-nowrap text-right", stickyActionsCellClass(fillsHeight), ROW_FOCUS_RING_CELL_CLASS),
|
|
29
|
+
// Action-cell events must not trigger the row click/activation
|
|
30
|
+
// (typically "Open Detail" — the user wanted the action,
|
|
31
|
+
// not navigation). We stopPropagation for mouse and
|
|
32
|
+
// keyboard so a11y stays consistent.
|
|
33
|
+
onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: measureContent ? (_jsx("div", { [ACTIONS_CONTENT_ATTRIBUTE]: "", className: "ml-auto w-max", children: children })) : (children) }));
|
|
34
|
+
}
|