@cosmicdrift/kumiko-renderer-web 0.336.0 → 0.337.1
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/create-app.js +4 -2
- package/dist/app/dashboard-body.js +1 -3
- package/dist/icons.js +0 -1
- package/dist/layout/nav-tree.js +10 -6
- package/dist/lib/resize-image.js +3 -1
- package/dist/primitives/index.d.ts +1 -1
- package/dist/primitives/index.js +68 -41
- package/dist/sse/live-events.d.ts +4 -1
- package/dist/sse/live-events.js +48 -1
- package/dist/widgets/drawer.js +5 -6
- package/dist/widgets/floating-panel.js +6 -2
- package/package.json +7 -7
- package/src/changes.json +37 -0
- package/src/styles.css +1 -0
package/dist/app/create-app.js
CHANGED
|
@@ -233,7 +233,7 @@ function KumikoAppRoot(props) {
|
|
|
233
233
|
warnMissingCustomScreens(loaded, props.customScreens);
|
|
234
234
|
setApp(loaded);
|
|
235
235
|
}, schemaIsRoleProjected: isFetchMode, ...(isFetchMode && { onUnmountResetSchema: () => setApp(undefined) }), ...(props.translate !== undefined && { translate: props.translate }), ...(props.onRowClick !== undefined && { onRowClick: props.onRowClick }), ...(props.shell !== undefined && { shell: props.shell }) }));
|
|
236
|
-
return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth
|
|
236
|
+
return (_jsx(TokensBoot, { children: _jsxs(LocaleProvider, { resolver: props.localeResolver, fallbackBundles: fallbackBundles, children: [_jsx(DocumentLangSync, { resolver: props.localeResolver }), _jsx(PrimitivesProvider, { value: props.primitives, children: _jsx(ScreenWidthProvider, { width: props.screenWidth, children: _jsx(AppFeaturesProvider, { features: app?.features ?? EMPTY_FEATURES, children: _jsx(DispatcherProvider, { dispatcher: props.dispatcher, children: _jsx(DraftStorageProvider, { value: props.draftStorage, children: _jsx(LiveEventsProvider, { value: props.liveEvents, children: _jsx(DashboardBodyProvider, { value: WebDashboardBody, children: _jsx(CustomScreensProvider, { value: props.customScreens, children: _jsx(ColumnRenderersProvider, { value: props.columnRenderers, children: _jsx(ContentEditorsProvider, { value: props.contentEditors, children: _jsx(ExtensionSectionsProvider, { value: props.extensionSectionComponents, children: _jsx(NavProvidersProvider, { value: navProviders, entities: navEntities, children: _jsx(ResolversProvider, { resolvers: props.resolvers, children: _jsxs(ToastProvider, { children: [_jsx(UpdateChecker, {}), _jsx(SessionEndedSignalProvider, { signal: props.sessionEndedSignal, children: stackWrappers(props.providers, stackWrappers(props.gates, screenNode)) })] }) }) }) }) }) }) }) }) }) }) }) }) }) })] }) }));
|
|
237
237
|
}
|
|
238
238
|
// Sits INSIDE the clientFeature gates (e.g. an auth gate) — a gate that
|
|
239
239
|
// renders a login placeholder instead of children keeps this component
|
|
@@ -288,7 +288,9 @@ export function createKumikoApp(options = {}) {
|
|
|
288
288
|
const dispatcher = withSessionEndedDetection(options.dispatcher ?? createLiveDispatcher(), sessionEndedSignal);
|
|
289
289
|
const draftStorage = options.draftStorage ?? createBrowserDraftStorage();
|
|
290
290
|
const primitives = { ...defaultPrimitives, ...(options.primitives ?? {}) };
|
|
291
|
-
const liveEvents = createEventSourceLiveEvents(
|
|
291
|
+
const liveEvents = createEventSourceLiveEvents({
|
|
292
|
+
onSessionEnded: () => sessionEndedSignal.notify(),
|
|
293
|
+
});
|
|
292
294
|
// Feature plugins: providers stack outermost (every gate + screen sees
|
|
293
295
|
// every provider), gates stack between the renderer providers and the
|
|
294
296
|
// shell/screen. Array order: first element = outermost wrapper.
|
|
@@ -307,9 +307,7 @@ function useTimeRange(screen, translate) {
|
|
|
307
307
|
if (timeRange === undefined)
|
|
308
308
|
return { params: {}, control: null };
|
|
309
309
|
const fromUrl = nav.searchParams[timeRange.id];
|
|
310
|
-
const value = timeRange.options.
|
|
311
|
-
? fromUrl
|
|
312
|
-
: timeRange.default;
|
|
310
|
+
const value = timeRange.options.find((o) => o.value === fromUrl)?.value ?? timeRange.default;
|
|
313
311
|
return {
|
|
314
312
|
// Metric buckets are cut server-side; without the user's zone they default
|
|
315
313
|
// to UTC while the axis labels are formatted in the user's zone.
|
package/dist/icons.js
CHANGED
package/dist/layout/nav-tree.js
CHANGED
|
@@ -300,18 +300,22 @@ function ActionGlyph({ icon }) {
|
|
|
300
300
|
// provider-emitted TreeNode reaches here without going through boot — warn
|
|
301
301
|
// once per label instead of rendering a dead button.
|
|
302
302
|
const warnedTreeActionLabels = new Set();
|
|
303
|
-
function
|
|
304
|
-
|
|
305
|
-
|
|
303
|
+
function warnTreeActionMisconfigured(label, problem) {
|
|
304
|
+
const key = `${label}:${problem}`;
|
|
305
|
+
// skip: already warned for this label and problem — suppresses the repeat, not the warning itself.
|
|
306
|
+
if (warnedTreeActionLabels.has(key))
|
|
306
307
|
return;
|
|
307
|
-
warnedTreeActionLabels.add(
|
|
308
|
+
warnedTreeActionLabels.add(key);
|
|
308
309
|
// biome-ignore lint/suspicious/noConsole: dev-warning for a setup error
|
|
309
|
-
console.warn(`[kumiko] Nav action "${label}"
|
|
310
|
+
console.warn(`[kumiko] Nav action "${label}" ${problem}`);
|
|
310
311
|
}
|
|
311
312
|
// An action button carries screen XOR target (see TreeAction) — `screen`
|
|
312
313
|
// renders a KumikoLink to the route, `target` a dispatch button. Same
|
|
313
314
|
// classes/size either way, only the element changes.
|
|
314
315
|
function TreeActionControl({ action, ariaLabel, className, dispatch, workspaceId, children, }) {
|
|
316
|
+
if (action.screen !== undefined && action.target !== undefined) {
|
|
317
|
+
warnTreeActionMisconfigured(action.label, 'has both "screen" and "target" set — using "screen", "target" is ignored.');
|
|
318
|
+
}
|
|
315
319
|
if (action.screen !== undefined) {
|
|
316
320
|
const screenId = lastSegment(action.screen);
|
|
317
321
|
return (_jsx(KumikoLink, { to: { ...(workspaceId !== undefined && { workspaceId }), screenId }, "aria-label": ariaLabel, className: className, onClick: (e) => e.stopPropagation(), children: children }));
|
|
@@ -323,7 +327,7 @@ function TreeActionControl({ action, ariaLabel, className, dispatch, workspaceId
|
|
|
323
327
|
dispatch(target);
|
|
324
328
|
}, children: children }));
|
|
325
329
|
}
|
|
326
|
-
|
|
330
|
+
warnTreeActionMisconfigured(action.label, 'has neither "screen" nor "target" set — it will not render.');
|
|
327
331
|
return null;
|
|
328
332
|
}
|
|
329
333
|
// Hover-actions + the "+" affordance, positioned absolute right.
|
package/dist/lib/resize-image.js
CHANGED
|
@@ -38,8 +38,10 @@ export async function resizeImageBeforeUpload(file, maxEdge = DEFAULT_MAX_EDGE)
|
|
|
38
38
|
const height = Math.round(bitmap.height * scale);
|
|
39
39
|
const canvas = new OffscreenCanvas(width, height);
|
|
40
40
|
const ctx = canvas.getContext("2d");
|
|
41
|
-
if (ctx === null)
|
|
41
|
+
if (ctx === null) {
|
|
42
|
+
bitmap.close();
|
|
42
43
|
return file;
|
|
44
|
+
}
|
|
43
45
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
44
46
|
bitmap.close();
|
|
45
47
|
const blob = await canvas.convertToBlob({ type: pickOutputType(file.type), quality: 0.85 });
|
|
@@ -14,7 +14,7 @@ export declare function BareFormProvider({ children }: {
|
|
|
14
14
|
children: ReactNode;
|
|
15
15
|
}): ReactNode;
|
|
16
16
|
export declare function ScreenWidthProvider({ width, children, }: {
|
|
17
|
-
readonly width: FormWidth;
|
|
17
|
+
readonly width: FormWidth | undefined;
|
|
18
18
|
readonly children: ReactNode;
|
|
19
19
|
}): ReactNode;
|
|
20
20
|
export type FormScreenShellWidth = FormWidth;
|
package/dist/primitives/index.js
CHANGED
|
@@ -3,7 +3,9 @@ import { applyFormatSpec, isSafeHref } from "@cosmicdrift/kumiko-headless";
|
|
|
3
3
|
import { InsideDrawerProvider, needsActionConfirm, STICKY_PRIMARY_ACTION_PROP, shouldRenderActionsIconOnly, statusToneForOptionTone, statusToneForValue, useColumnRenderer, useInsideDrawer, useOptionalLocale, useOptionalTranslation, useTranslation, WriteFailedError, } from "@cosmicdrift/kumiko-renderer";
|
|
4
4
|
import { cva } from "class-variance-authority";
|
|
5
5
|
import { ArrowDown, ArrowUp, ArrowUpDown, Building, CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Globe, Hash, KeyRound, Link, Loader2, Lock, Mail, MapPin, MoreHorizontal, Phone, Search, SlidersHorizontal, Tag, Undo2, User, X, } from "lucide-react";
|
|
6
|
-
|
|
6
|
+
// The main entry pulls Node-only deps (yargs/pngjs) under bundlers that ignore
|
|
7
|
+
// package.json#browser (Metro), hence the explicit browser subpath.
|
|
8
|
+
import QRCode from "qrcode/lib/browser.js";
|
|
7
9
|
import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useId, useRef, useState, } from "react";
|
|
8
10
|
import { Icon, NAV_ICONS } from "../icons.js";
|
|
9
11
|
import { cn } from "../lib/cn.js";
|
|
@@ -191,25 +193,18 @@ function fieldIconFor(icon) {
|
|
|
191
193
|
// `icon` carries a known FIELD_ICONS key. `pl-8` overrides (via
|
|
192
194
|
// tailwind-merge) only the left padding of the vendored ui/input.tsx —
|
|
193
195
|
// right padding and other defaults stay untouched.
|
|
196
|
+
const FIELD_ICON_CLASS = "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground";
|
|
194
197
|
function withFieldIcon(icon, input) {
|
|
195
198
|
const Icon = fieldIconFor(icon);
|
|
196
199
|
if (Icon === undefined)
|
|
197
200
|
return input;
|
|
198
|
-
return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className:
|
|
201
|
+
return (_jsxs("div", { className: "relative", children: [_jsx(Icon, { "aria-hidden": "true", className: FIELD_ICON_CLASS }), input] }));
|
|
199
202
|
}
|
|
200
|
-
// Mirrors withFieldIcon on the right side: a muted, non-interactive unit
|
|
201
|
-
// suffix rendered inside the input's visual box. Pure decoration — never
|
|
202
|
-
// focusable, never touches the input's value.
|
|
203
203
|
// The unit changes the meaning of the value ("58 mi" vs "58 km"), so it is
|
|
204
204
|
// exposed as the input's description rather than hidden from assistive tech.
|
|
205
205
|
function unitSuffixId(inputId) {
|
|
206
206
|
return `${inputId}-unit`;
|
|
207
207
|
}
|
|
208
|
-
function withUnitSuffix(inputId, unit, input) {
|
|
209
|
-
if (unit === undefined)
|
|
210
|
-
return input;
|
|
211
|
-
return (_jsxs("div", { className: "relative", children: [input, _jsx("span", { id: unitSuffixId(inputId), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: unit })] }));
|
|
212
|
-
}
|
|
213
208
|
const TRAILING_ACTION_ICONS = { clear: X, undo: Undo2 };
|
|
214
209
|
// In-field icon button on the right (same look as the date input's calendar
|
|
215
210
|
// trigger). The wrapped input must reserve the room itself (`pr-9`).
|
|
@@ -336,8 +331,13 @@ function DefaultInput(props) {
|
|
|
336
331
|
return (_jsx(UiInput, { type: "email", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "email" }));
|
|
337
332
|
case "password":
|
|
338
333
|
return withTrailingAction(props.trailingAction, _jsx(UiInput, { type: "password", ...common, "data-testid": props.testId, value: props.value, onChange: (e) => props.onChange(e.target.value), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), autoComplete: props.autoComplete ?? "current-password", className: props.trailingAction !== undefined ? "pr-9" : undefined }), true);
|
|
339
|
-
case "number":
|
|
340
|
-
|
|
334
|
+
case "number": {
|
|
335
|
+
const NumberIcon = fieldIconFor(props.icon);
|
|
336
|
+
// Icon, input and unit share this one box, and the grid cell sizes the
|
|
337
|
+
// box rather than the bare input: anchored to a wider wrapper, the unit
|
|
338
|
+
// would sit next to a narrower input instead of inside it.
|
|
339
|
+
return (_jsxs("div", { "data-slot": "number-field", className: "relative", children: [NumberIcon !== undefined && (_jsx(NumberIcon, { "aria-hidden": "true", className: FIELD_ICON_CLASS })), _jsx(NumberInput, { id: props.id, name: props.name, value: props.value, onChange: props.onChange, disabled: props.disabled, required: props.required, hasError: props.hasError, testId: props.testId, ...(props.unit !== undefined && { ariaDescribedBy: unitSuffixId(props.id) }), ...(props.locale !== undefined && { locale: props.locale }), ...(props.grouping !== undefined && { grouping: props.grouping }), ...(props.integer !== undefined && { integer: props.integer }), ...(props.placeholder !== undefined && { placeholder: props.placeholder }), className: cn(NumberIcon !== undefined ? "pl-8" : undefined, props.unit !== undefined ? "pr-8" : undefined) }), props.unit !== undefined && (_jsx("span", { id: unitSuffixId(props.id), className: "pointer-events-none absolute right-2.5 top-1/2 max-w-[40%] -translate-y-1/2 truncate text-sm text-muted-foreground", children: props.unit }))] }));
|
|
340
|
+
}
|
|
341
341
|
case "range":
|
|
342
342
|
return (_jsx("input", { type: "range", ...common, min: props.min, max: props.max, step: props.step ?? 1, value: props.value, onChange: (e) => props.onChange(Number(e.target.value)), className: "w-full accent-primary" }));
|
|
343
343
|
case "boolean":
|
|
@@ -658,7 +658,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
658
658
|
const hasMenu = menuActions !== undefined && menuActions.length > 0;
|
|
659
659
|
const body = (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("div", { className: "flex min-w-0 items-start justify-between gap-2", children: [cardTitleColumn !== undefined && (_jsx("span", { "data-testid": getCellTestId?.(row, cardTitleColumn.field) ??
|
|
660
660
|
`cell-${row.id}-${cardTitleColumn.field}`, className: "min-w-0 truncate text-base font-semibold text-foreground", children: cardCell(row, cardTitleColumn) })), showStatus && (_jsx("span", { "data-testid": getCellTestId?.(row, cardStatusColumn.field) ??
|
|
661
|
-
`cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 max-h-10 overflow-hidden text-[13px] leading-5 tabular-nums text-foreground-secondary", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-
|
|
661
|
+
`cell-${row.id}-${cardStatusColumn.field}`, className: "shrink-0", children: cardCell(row, cardStatusColumn) }))] }), metaColumns.length > 0 && (_jsx("div", { className: "min-w-0 max-h-10 overflow-hidden text-[13px] leading-5 tabular-nums text-foreground-secondary", children: _jsx("div", { "data-testid": `card-meta-${row.id}`, className: "-ms-3 flex flex-wrap items-center", children: metaColumns.map((col) => (_jsxs("span", { className: "flex min-w-0 max-w-full items-center", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 shrink-0 text-center", children: "\u00B7" }), _jsx("span", { "data-testid": getCellTestId?.(row, col.field) ?? `cell-${row.id}-${col.field}`, className: "min-w-0 truncate", children: isBadgeColumn(col) ? (_jsx("span", { className: "inline-flex align-middle", children: cardCell(row, col) })) : (cardCell(row, col)) })] }, col.field))) }) }))] }), rowIsLink && (_jsx(ChevronRight, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": "true" }))] }));
|
|
662
662
|
const rowItem = (_jsxs("li", { "data-testid": rowTestId(row), "data-tone": rowTone?.(row), className: cn("flex min-h-[72px] items-center gap-2 border-b border-border-row py-3", rowToneClass(rowTone?.(row)), isRowExpandable ? "pl-2" : "pl-4", hasMenu ? "pr-2" : "pr-4"), ...(rowIsLink && { onClick: () => onRowClick(row) }), children: [renderExpandToggle(row), rowIsLink ? (_jsx("button", { type: "button", className: "flex min-w-0 flex-1 cursor-pointer items-center gap-2 bg-transparent p-0 text-left", onKeyDown: (e) => {
|
|
663
663
|
if (e.key !== "Enter")
|
|
664
664
|
return;
|
|
@@ -681,17 +681,21 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
|
|
|
681
681
|
}) ?? `Sorted by ${sort.field}`)
|
|
682
682
|
: (tableTranslate?.("kumiko.list.sort.unsorted") ?? "Unsorted");
|
|
683
683
|
const SortArrow = sort?.dir === "desc" ? ArrowDown : ArrowUp;
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
684
|
+
// URL state can carry a sort on a column that is not sortable; without an
|
|
685
|
+
// option for it the select would claim "Unsorted" while the list is sorted.
|
|
686
|
+
const currentSortValue = sort !== undefined && sort !== null ? `${sort.field}:${sort.dir}` : "";
|
|
687
|
+
const currentSortIsUnlisted = sort !== undefined && sort !== null && !sortOptions.some((o) => o.value === currentSortValue);
|
|
688
|
+
return (_jsx("div", { "data-testid": testId, className: "contents", children: _jsxs("div", { "data-testid": testId !== undefined ? `${testId}-cards` : "render-list-cards", className: "flex flex-col", children: [onSortChange !== undefined && sortableColumns.length > 0 && (_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": tableTranslate?.("kumiko.list.sort.label") ?? "Sort", "data-testid": testId !== undefined ? `${testId}-sort` : "render-list-sort", value: currentSortValue, onChange: (e) => {
|
|
689
|
+
const raw = e.target.value;
|
|
690
|
+
if (raw === "") {
|
|
691
|
+
onSortChange(null);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
const picked = sortOptions.find((o) => o.value === raw);
|
|
695
|
+
if (picked === undefined)
|
|
696
|
+
return;
|
|
697
|
+
onSortChange({ field: picked.field, dir: picked.dir });
|
|
698
|
+
}, className: "absolute inset-0 size-full cursor-pointer opacity-0", children: [_jsx("option", { value: "", children: tableTranslate?.("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)))] })] })), _jsx("ul", { className: "m-0 list-none border-t border-border-row p-0", children: tableItems.map((item) => item.kind === "group" ? (_jsx("li", { "data-testid": `row-group-${item.key}`, className: "border-b border-border-row bg-muted/40", children: renderGroupToggle(item) }, `group-${item.key}`)) : (renderCard(item.row))) })] }) }));
|
|
695
699
|
}
|
|
696
700
|
const tableContent = isNarrow ? cardsInner() : tableInner();
|
|
697
701
|
// Pager wird IMMER unter der Tabelle gerendert (auch bei rows=[]),
|
|
@@ -919,7 +923,7 @@ function RowActionsKebab({ row, actions, }) {
|
|
|
919
923
|
}, className: cn(action.style === "danger" && "text-destructive focus:text-destructive"), children: action.label }, action.id))) })] }), pendingConfirm !== null && (_jsx(DefaultDialog, { open: true, onOpenChange: (open) => {
|
|
920
924
|
if (!open)
|
|
921
925
|
setPendingConfirm(null);
|
|
922
|
-
}, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
|
|
926
|
+
}, title: pendingConfirm.label, ...(pendingConfirm.confirm !== undefined && { description: pendingConfirm.confirm }), confirmLabel: pendingConfirm.confirmLabel ?? pendingConfirm.label, ...(pendingConfirm.style === "danger" && { variant: "danger" }), onConfirm: async () => {
|
|
923
927
|
const action = pendingConfirm;
|
|
924
928
|
setPendingConfirm(null);
|
|
925
929
|
await triggerNow(action);
|
|
@@ -1182,9 +1186,12 @@ function buildTableItems(rows, grouping, toggledGroups) {
|
|
|
1182
1186
|
members.push(row);
|
|
1183
1187
|
}
|
|
1184
1188
|
return [...groups].flatMap(([key, members]) => {
|
|
1189
|
+
const label = grouping.headerLabel(key, members);
|
|
1190
|
+
if (label === undefined)
|
|
1191
|
+
return members.map((row) => ({ kind: "row", row }));
|
|
1185
1192
|
const collapsed = grouping.startsCollapsed(key) !== toggledGroups.has(key);
|
|
1186
1193
|
return [
|
|
1187
|
-
{ kind: "group", key, label
|
|
1194
|
+
{ kind: "group", key, label, collapsed },
|
|
1188
1195
|
...(collapsed ? [] : members.map((row) => ({ kind: "row", row }))),
|
|
1189
1196
|
];
|
|
1190
1197
|
});
|
|
@@ -1225,7 +1232,8 @@ function DataTableCell({ value, row, field, type, renderer, optionLabels, option
|
|
|
1225
1232
|
return (_jsx("span", { "data-empty-cell": "true", className: "text-muted-foreground", children: EMPTY_CELL_PLACEHOLDER }));
|
|
1226
1233
|
}
|
|
1227
1234
|
if (typeof renderer === "object" && renderer !== null && "format" in renderer) {
|
|
1228
|
-
|
|
1235
|
+
const spec = renderer;
|
|
1236
|
+
return applyFormatSpec({ ...spec, locale: spec.locale ?? locale }, value, translate);
|
|
1229
1237
|
}
|
|
1230
1238
|
if (typeof renderer === "function") {
|
|
1231
1239
|
const fn = renderer;
|
|
@@ -1293,9 +1301,10 @@ export function BareFormProvider({ children }) {
|
|
|
1293
1301
|
return _jsx(BareFormContext.Provider, { value: true, children: children });
|
|
1294
1302
|
}
|
|
1295
1303
|
// App-wide default for FormScreenShell's width when a screen doesn't set its
|
|
1296
|
-
// own `layout.width`.
|
|
1297
|
-
//
|
|
1298
|
-
|
|
1304
|
+
// own `layout.width`. Undefined (no `createKumikoApp({ screenWidth })`) lets each
|
|
1305
|
+
// shell keep its own default: "4xl" for FormScreenShell, 640px for the
|
|
1306
|
+
// screen-form column.
|
|
1307
|
+
const ScreenWidthContext = createContext(undefined);
|
|
1299
1308
|
export function ScreenWidthProvider({ width, children, }) {
|
|
1300
1309
|
return _jsx(ScreenWidthContext.Provider, { value: width, children: children });
|
|
1301
1310
|
}
|
|
@@ -1307,6 +1316,12 @@ export function ScreenWidthProvider({ width, children, }) {
|
|
|
1307
1316
|
// into the URL (fw#2312, fw#2705). Degrading to a <div> when already inside
|
|
1308
1317
|
// a form avoids the nesting; the captured click still routes to THIS form's
|
|
1309
1318
|
// onSubmit instead of activating the real ancestor <form>.
|
|
1319
|
+
const SUBMIT_CONTROL_SELECTOR = [
|
|
1320
|
+
"button[type=submit]:not(:disabled)",
|
|
1321
|
+
"button:not([type]):not(:disabled)",
|
|
1322
|
+
"input[type=submit]:not(:disabled)",
|
|
1323
|
+
"input[type=image]:not(:disabled)",
|
|
1324
|
+
].join(", ");
|
|
1310
1325
|
function FormRoot({ onSubmit, testId, className, children, }) {
|
|
1311
1326
|
if (useContext(InsideFormContext)) {
|
|
1312
1327
|
return (_jsx("div", { "data-kumiko-form-root": "", onKeyDownCapture: (e) => {
|
|
@@ -1327,7 +1342,7 @@ function FormRoot({ onSubmit, testId, className, children, }) {
|
|
|
1327
1342
|
}, onClickCapture: (e) => {
|
|
1328
1343
|
// @cast-boundary dom-event-target: closest() needs an Element, and
|
|
1329
1344
|
// click targets are always one in the browser/happy-dom.
|
|
1330
|
-
const submitButton = e.target.closest(
|
|
1345
|
+
const submitButton = e.target.closest(SUBMIT_CONTROL_SELECTOR);
|
|
1331
1346
|
if (submitButton === null)
|
|
1332
1347
|
return;
|
|
1333
1348
|
// React events bubble through portals and nested degraded roots; a
|
|
@@ -1514,13 +1529,14 @@ function DrawerFormLayout({ onSubmit, subtitle, actions, secondaryActions, summa
|
|
|
1514
1529
|
function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryActions, testId, width, stickyActions, headerRegion, titleAction, fillHeight, chromeless, screenForm, unsavedCount, sectionNav, sideRail, summary, }) {
|
|
1515
1530
|
const insideDrawer = useInsideDrawer();
|
|
1516
1531
|
const isEmbeddedForm = useContext(EmbeddedFormContext);
|
|
1532
|
+
const contextWidth = useContext(ScreenWidthContext);
|
|
1517
1533
|
// Eingebettet (AuthCard etc.): nacktes <form>, gestapelte Felder mit gap —
|
|
1518
1534
|
// der Container trägt Card/Titel selbst, sonst Card-in-Card.
|
|
1519
1535
|
if (useContext(BareFormContext)) {
|
|
1520
1536
|
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col gap-4",
|
|
1521
1537
|
// Bare forms stack sections without a card; without a divider a
|
|
1522
1538
|
// section boundary reads as a layout gap, not structure.
|
|
1523
|
-
"[&>section:not(:first-child)]:border-t"), children: [_jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
|
|
1539
|
+
"[&>section:not(:first-child)]:border-t"), children: [headerRegion, _jsx(InsideFormContext.Provider, { value: true, children: children }), (secondaryActions !== undefined || actions !== undefined) && (_jsxs("div", { className: "flex items-center justify-end gap-2", children: [secondaryActions, actions] }))] }));
|
|
1524
1540
|
}
|
|
1525
1541
|
if (insideDrawer) {
|
|
1526
1542
|
return (_jsx(DrawerFormLayout, { onSubmit: onSubmit, subtitle: subtitle, actions: actions, secondaryActions: secondaryActions, summary: summary, testId: testId, children: children }));
|
|
@@ -1529,13 +1545,16 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1529
1545
|
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex w-full flex-col gap-6", children: [_jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), _jsx(FormSections, { chromeless: true, stickyActions: undefined, fillHeight: false, screenForm: true, children: children }), (actions !== undefined || secondaryActions !== undefined) && (_jsx("div", { className: "max-w-4xl border-t border-border", children: _jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: true, stickyActions: undefined, fillHeight: false, ...(unsavedCount !== undefined && { unsavedCount }) }) }))] }));
|
|
1530
1546
|
}
|
|
1531
1547
|
const isScreenForm = screenForm === true && fillHeight === true && chromeless !== true;
|
|
1548
|
+
const screenFormWidth = width ?? contextWidth;
|
|
1532
1549
|
const sections = (_jsx(FormSections, { chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, screenForm: isScreenForm, children: children }));
|
|
1533
1550
|
const footer = (_jsx(FormFooter, { actions: actions, secondaryActions: secondaryActions, testId: testId, chromeless: chromeless === true, stickyActions: stickyActions, fillHeight: fillHeight, ...(unsavedCount !== undefined && { unsavedCount }), railed: sideRail !== undefined }));
|
|
1534
1551
|
// Screen form: no card. A padded scroll surface holds the form column and,
|
|
1535
1552
|
// from xl, the section navigation; the footer stays pinned below it.
|
|
1536
1553
|
if (isScreenForm) {
|
|
1537
1554
|
const showsSectionNav = sectionNav !== undefined && sectionNav.length >= SECTION_NAV_MIN_ITEMS;
|
|
1538
|
-
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9",
|
|
1555
|
+
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: "flex h-full min-h-0 w-full flex-col", children: [_jsxs("div", { className: "flex min-h-0 flex-1 flex-col lg:flex-row", children: [sideRail, _jsx("div", { "data-testid": testId !== undefined ? `${testId}-scroll` : undefined, className: cn("min-h-0 flex-1 overflow-y-auto", screenPaddingClassName), children: _jsxs("div", { className: "flex gap-18", children: [_jsxs("div", { className: cn("flex w-full min-w-0 flex-col gap-9", screenFormWidth !== undefined
|
|
1556
|
+
? screenFormColumnWidthClassName[screenFormWidth]
|
|
1557
|
+
: "max-w-[640px]"), children: [_jsx(InsideScreenFormColumnContext.Provider, { value: true, children: headerRegion }), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: false }), sections] }), showsSectionNav && _jsx(FormSectionNav, { items: sectionNav })] }) })] }), footer] }));
|
|
1539
1558
|
}
|
|
1540
1559
|
// Pinned footer (fillHeight + stickyActions): the footer is a full-bleed
|
|
1541
1560
|
// sibling BELOW the padded shell, so the shell's bottom inset does not float
|
|
@@ -1555,8 +1574,9 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1555
1574
|
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { maxWidth: "full", ...(fillHeight === true && { fillHeight: true }), className: cn("!p-0", pinnedShellClassName), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col", fillHeight === true && "shrink-0"), children: headerRegion })), _jsx(FormTitleBlock, { title: title, subtitle: subtitle, titleAction: titleAction, testId: testId, bordered: false, fillHeight: fillHeight }), sections, !pinnedFooter && footer] }), pinnedFooter && footer] }));
|
|
1556
1575
|
}
|
|
1557
1576
|
// One card form: title as header (no divider under it), sections divided
|
|
1558
|
-
// between each other, muted action footer. Shell width
|
|
1559
|
-
// (
|
|
1577
|
+
// between each other, muted action footer. Shell width comes from
|
|
1578
|
+
// FormScreenShell (layout.width, else the app-wide screenWidth default, else
|
|
1579
|
+
// 4xl).
|
|
1560
1580
|
return (_jsxs(FormRoot, { onSubmit: onSubmit, testId: testId, className: cn("flex flex-col w-full", fillHeight === true && "h-full min-h-0"), children: [_jsxs(FormScreenShell, { ...(width !== undefined && { maxWidth: width }), ...(fillHeight === true && { fillHeight: true }), ...(pinnedShellClassName !== undefined && { className: pinnedShellClassName }), children: [headerRegion !== undefined && (_jsx("div", { className: cn("flex flex-col gap-6 mb-8", fillHeight === true && "shrink-0"), children: headerRegion })), _jsxs("div", { "data-slot": "card", className: cn(cardSurface(), "overflow-hidden",
|
|
1561
1581
|
// No flex-1: a fillHeight card sizes to its content (flex's
|
|
1562
1582
|
// initial 0 1 auto) and only claims more than that once its
|
|
@@ -1568,7 +1588,7 @@ function DefaultForm({ onSubmit, children, title, subtitle, actions, secondaryAc
|
|
|
1568
1588
|
}
|
|
1569
1589
|
export function FormScreenShell({ children, className, testId, maxWidth, fillHeight, }) {
|
|
1570
1590
|
const contextWidth = useContext(ScreenWidthContext);
|
|
1571
|
-
const width = maxWidth ?? contextWidth;
|
|
1591
|
+
const width = maxWidth ?? contextWidth ?? "4xl";
|
|
1572
1592
|
return (_jsx("div", { "data-testid": testId, className: cn(screenPaddingClassName, "w-full", screenWidthClassName[width], fillHeight === true && "h-full flex flex-col min-h-0", className), children: children }));
|
|
1573
1593
|
}
|
|
1574
1594
|
function DefaultSection({ id, title, subtitle, children, actions, variant = "default", layout = "stacked", testId, icon, }) {
|
|
@@ -1618,7 +1638,7 @@ const DRAWER_FIELD_CELL_WIDTH_CLASS = {
|
|
|
1618
1638
|
};
|
|
1619
1639
|
const FIELD_CELL_WIDTH_CLASS = {
|
|
1620
1640
|
text: "w-full sm:w-60",
|
|
1621
|
-
number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&
|
|
1641
|
+
number: "w-fit min-w-24 shrink-0 [&_label]:whitespace-nowrap [&_[data-slot=number-field]]:w-32",
|
|
1622
1642
|
money: "w-40 shrink-0 [&_label]:whitespace-nowrap",
|
|
1623
1643
|
date: "w-full sm:w-[200px]",
|
|
1624
1644
|
timestamp: "w-full sm:w-[328px]",
|
|
@@ -1655,7 +1675,7 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
|
|
|
1655
1675
|
// CSS-Variable; Tailwind-Klasse liest die Variable mit
|
|
1656
1676
|
// `grid-template-columns: var(--grid-cols)`. Saubere Lösung weil
|
|
1657
1677
|
// Tailwind JIT keinen dynamischen `grid-cols-${N}` auflösen kann.
|
|
1658
|
-
const rowCount = Math.ceil(Children.
|
|
1678
|
+
const rowCount = Math.ceil(Children.toArray(children).length / columns);
|
|
1659
1679
|
const clipped = maxRows !== undefined && rowCount > maxRows;
|
|
1660
1680
|
// gridAutoRows is a minmax MIN, not a fixed height, so wrapped labels grow
|
|
1661
1681
|
// their row instead of being clipped mid-row. maxHeight is therefore only
|
|
@@ -1671,12 +1691,12 @@ function DefaultGrid({ columns, children, testId, maxRows, flow, list, ariaLabel
|
|
|
1671
1691
|
"--grid-cols": `repeat(${columns}, minmax(0, 1fr))`,
|
|
1672
1692
|
...(clipped && {
|
|
1673
1693
|
gridAutoRows: "minmax(var(--kumiko-grid-row-h, 2.5rem), auto)",
|
|
1674
|
-
maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * 1rem)`,
|
|
1694
|
+
maxHeight: `calc(${maxRows} * var(--kumiko-grid-row-h, 2.5rem) + ${maxRows - 1} * var(--kumiko-grid-gap, 1rem))`,
|
|
1675
1695
|
overflowY: "auto",
|
|
1676
1696
|
}),
|
|
1677
1697
|
// workaround: duplicate @types/react instances break direct CSSProperties cast
|
|
1678
1698
|
};
|
|
1679
|
-
return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-
|
|
1699
|
+
return (_jsx("div", { ...groupProps, "data-testid": testId, className: "grid gap-[var(--kumiko-grid-gap,1rem)] grid-cols-1 sm:[grid-template-columns:var(--grid-cols)]", style: style, children: children }));
|
|
1680
1700
|
}
|
|
1681
1701
|
function DefaultGridCell({ span, width, children }) {
|
|
1682
1702
|
const insideDrawerBody = useContext(DrawerBodyContext);
|
|
@@ -1823,6 +1843,13 @@ function QrSecretValue({ value }) {
|
|
|
1823
1843
|
// create-app.tsx.
|
|
1824
1844
|
function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
|
|
1825
1845
|
const [copiedIndex, setCopiedIndex] = useState(null);
|
|
1846
|
+
// Labels are not unique per reveal; suffix repeats so React keys stay distinct.
|
|
1847
|
+
const seenLabels = new Map();
|
|
1848
|
+
const rowKeys = values.map((v) => {
|
|
1849
|
+
const occurrence = seenLabels.get(v.label) ?? 0;
|
|
1850
|
+
seenLabels.set(v.label, occurrence + 1);
|
|
1851
|
+
return `${v.label}#${occurrence}`;
|
|
1852
|
+
});
|
|
1826
1853
|
return (_jsx("div", { "data-testid": testId, className: "flex flex-col gap-3", children: values.map((v, i) => (_jsxs("div", { className: "flex flex-col gap-1.5", children: [_jsx("span", { className: "text-sm font-medium text-muted-foreground", children: v.label }), _jsxs("div", { className: "flex items-start gap-2", children: [v.qr === true ? (_jsx(QrSecretValue, { value: v.value })) : v.multiline ? (_jsx("pre", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })) : (_jsx("code", { className: "flex-1 overflow-x-auto whitespace-pre-wrap break-all rounded bg-muted px-3 py-2 font-mono text-sm", children: v.value })), v.copyable && (_jsx(UiButton, { type: "button", variant: "outline", size: "sm", onClick: async () => {
|
|
1827
1854
|
try {
|
|
1828
1855
|
await navigator.clipboard.writeText(v.value);
|
|
@@ -1831,7 +1858,7 @@ function DefaultSecretReveal({ values, copyLabel, copiedLabel, testId, }) {
|
|
|
1831
1858
|
catch {
|
|
1832
1859
|
// clipboard blocked (non-secure context) — no fallback UI needed here
|
|
1833
1860
|
}
|
|
1834
|
-
}, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] },
|
|
1861
|
+
}, children: copiedIndex === i ? copiedLabel : copyLabel }))] })] }, rowKeys[i]))) }));
|
|
1835
1862
|
}
|
|
1836
1863
|
export const defaultPrimitives = {
|
|
1837
1864
|
Button: DefaultButton,
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type LiveEventSubscriber } from "@cosmicdrift/kumiko-renderer";
|
|
2
2
|
export type CreateEventSourceLiveEventsOptions = {
|
|
3
3
|
/** URL des SSE-Endpoints. Default: /api/sse (das ist wo
|
|
4
4
|
* createSseRoute im Kumiko-Server mountet). Override wenn der
|
|
5
5
|
* Mountpath divergiert. */
|
|
6
6
|
readonly url?: string;
|
|
7
|
+
/** Fires when the server refuses the SSE handshake because the session
|
|
8
|
+
* ended (same 401 codes as the dispatcher path). */
|
|
9
|
+
readonly onSessionEnded?: () => void;
|
|
7
10
|
};
|
|
8
11
|
/** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
|
|
9
12
|
* Normalerweise einmal im App-Bootstrap gerufen und als value an
|
package/dist/sse/live-events.js
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
import { isSessionEndedError, } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
function hasErrorCode(body) {
|
|
3
|
+
if (typeof body !== "object" || body === null || !("error" in body))
|
|
4
|
+
return false;
|
|
5
|
+
const { error } = body;
|
|
6
|
+
return (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string");
|
|
7
|
+
}
|
|
8
|
+
// EventSource never exposes the HTTP status, so one plain fetch of the same
|
|
9
|
+
// URL tells a session end apart from an outage. Aborted right after the
|
|
10
|
+
// headers/body so a 200 doesn't hold a second stream open.
|
|
11
|
+
async function isSessionEndedRejection(url) {
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
try {
|
|
14
|
+
// guard-allow: same-origin fetch
|
|
15
|
+
const res = await fetch(url, {
|
|
16
|
+
credentials: "same-origin",
|
|
17
|
+
headers: { Accept: "text/event-stream" },
|
|
18
|
+
signal: controller.signal,
|
|
19
|
+
});
|
|
20
|
+
if (res.status !== 401)
|
|
21
|
+
return false;
|
|
22
|
+
const body = await res.json();
|
|
23
|
+
return hasErrorCode(body) && isSessionEndedError({ code: body.error.code, httpStatus: 401 });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// skip: probe failed (offline, unparseable body) — not evidence of a session end
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
controller.abort();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
1
33
|
/** Liefert einen `LiveEventSubscriber` der EventSource-backed ist.
|
|
2
34
|
* Normalerweise einmal im App-Bootstrap gerufen und als value an
|
|
3
35
|
* `<LiveEventsProvider>` durchgereicht — createKumikoApp tut das. */
|
|
@@ -32,7 +64,22 @@ export function createEventSourceLiveEvents(options = {}) {
|
|
|
32
64
|
// skip: no window/EventSource available (SSR/non-browser), nothing to connect
|
|
33
65
|
if (typeof window === "undefined" || typeof EventSource === "undefined")
|
|
34
66
|
return;
|
|
35
|
-
|
|
67
|
+
const connection = new EventSource(url);
|
|
68
|
+
source = connection;
|
|
69
|
+
const { onSessionEnded } = options;
|
|
70
|
+
// skip: no session-end consumer, a refused handshake needs no probe
|
|
71
|
+
if (onSessionEnded === undefined)
|
|
72
|
+
return;
|
|
73
|
+
connection.addEventListener("error", () => {
|
|
74
|
+
// skip: CONNECTING means a dropped stream the browser retries itself;
|
|
75
|
+
// only a refused handshake (non-200) closes the EventSource for good.
|
|
76
|
+
if (connection.readyState !== EventSource.CLOSED)
|
|
77
|
+
return;
|
|
78
|
+
void isSessionEndedRejection(url).then((sessionEnded) => {
|
|
79
|
+
if (sessionEnded)
|
|
80
|
+
onSessionEnded();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
36
83
|
};
|
|
37
84
|
const ensureListenersForEntity = (entityName) => {
|
|
38
85
|
// skip: not connected yet, listeners get wired once ensureConnected runs
|
package/dist/widgets/drawer.js
CHANGED
|
@@ -107,6 +107,10 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
|
|
|
107
107
|
const verticalOffsetStyle = belowHeader && variant === "flush" && !narrow && (side === "left" || side === "right")
|
|
108
108
|
? { top: "var(--shell-header-height)", bottom: 0 }
|
|
109
109
|
: undefined;
|
|
110
|
+
const widthStyle = canResize && !narrow ? { width: effectiveWidthPx, maxWidth: "none" } : customWidthStyle;
|
|
111
|
+
const sheetStyle = widthStyle === undefined && verticalOffsetStyle === undefined
|
|
112
|
+
? undefined
|
|
113
|
+
: { ...widthStyle, ...verticalOffsetStyle };
|
|
110
114
|
const handleDrag = usePointerDrag({
|
|
111
115
|
onStart: () => {
|
|
112
116
|
setMaximized(false);
|
|
@@ -134,10 +138,5 @@ export function Drawer({ open, onOpenChange, side = "right", title, description,
|
|
|
134
138
|
// Radix already drops the overlay and the focus trap for a
|
|
135
139
|
// non-modal Root; only the dismiss-on-outside-interaction is left
|
|
136
140
|
// to suppress, so a click into the page behind keeps the panel open.
|
|
137
|
-
onInteractOutside: modal ? undefined : (event) => event.preventDefault(), showCloseButton: showCloseButton, className: cn(sidePanelClass(side, narrow, variant, belowHeader), fillBody && "gap-0", !narrow && panelClassName), style: {
|
|
138
|
-
...(canResize && !narrow
|
|
139
|
-
? { width: effectiveWidthPx, maxWidth: "none" }
|
|
140
|
-
: customWidthStyle),
|
|
141
|
-
...verticalOffsetStyle,
|
|
142
|
-
}, children: [canResize && !narrow && (_jsx("button", { type: "button", onClick: () => setMaximized((m) => !m), "aria-pressed": maximized, "aria-label": maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize"), className: "absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: maximized ? (_jsx(Minimize2Icon, { className: "size-4" })) : (_jsx(Maximize2Icon, { className: "size-4" })) })), (title !== undefined || description !== undefined) && (_jsxs(SheetHeader, { children: [title !== undefined && _jsx(SheetTitle, { children: title }), description !== undefined && _jsx(SheetDescription, { children: description })] })), _jsx("div", { className: fillBody ? "flex min-h-0 flex-1 flex-col" : "flex-1 overflow-y-auto px-4", children: children }), footer !== undefined && (_jsx(SheetFooter, { className: DRAWER_FOOTER_CLASS, children: footer })), canResize && !narrow && (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t("kumiko.widget.drawer.resize"), "aria-valuenow": effectiveWidthPx, "aria-valuemin": minWidthPx, "aria-valuemax": effectiveMaxWidthPx(), tabIndex: 0, ...handleDrag, onKeyDown: onHandleKeyDown, className: cn("absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border", side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2") }))] }) }));
|
|
141
|
+
onInteractOutside: modal ? undefined : (event) => event.preventDefault(), showCloseButton: showCloseButton, className: cn(sidePanelClass(side, narrow, variant, belowHeader), fillBody && "gap-0", !narrow && panelClassName), style: sheetStyle, children: [canResize && !narrow && (_jsx("button", { type: "button", onClick: () => setMaximized((m) => !m), "aria-pressed": maximized, "aria-label": maximized ? t("kumiko.widget.drawer.restore") : t("kumiko.widget.drawer.maximize"), className: "absolute top-4 right-14 z-10 rounded-xs p-1 text-muted-foreground opacity-70 transition-opacity hover:opacity-100 hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: maximized ? (_jsx(Minimize2Icon, { className: "size-4" })) : (_jsx(Maximize2Icon, { className: "size-4" })) })), (title !== undefined || description !== undefined) && (_jsxs(SheetHeader, { children: [title !== undefined && _jsx(SheetTitle, { children: title }), description !== undefined && _jsx(SheetDescription, { children: description })] })), _jsx("div", { className: fillBody ? "flex min-h-0 flex-1 flex-col" : "flex-1 overflow-y-auto px-4", children: children }), footer !== undefined && (_jsx(SheetFooter, { className: DRAWER_FOOTER_CLASS, children: footer })), canResize && !narrow && (_jsx("div", { role: "separator", "aria-orientation": "vertical", "aria-label": t("kumiko.widget.drawer.resize"), "aria-valuenow": effectiveWidthPx, "aria-valuemin": minWidthPx, "aria-valuemax": effectiveMaxWidthPx(), tabIndex: 0, ...handleDrag, onKeyDown: onHandleKeyDown, className: cn("absolute inset-y-0 z-10 w-1 cursor-col-resize touch-none after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2 hover:bg-border", side === "right" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2") }))] }) }));
|
|
143
142
|
}
|
|
@@ -42,6 +42,7 @@ const EDGE_LABEL_KEYS = {
|
|
|
42
42
|
bottom: "kumiko.widget.floatingPanel.resizeBottom",
|
|
43
43
|
left: "kumiko.widget.floatingPanel.resizeLeft",
|
|
44
44
|
};
|
|
45
|
+
const DRAG_HANDLE_SELECTOR = "[data-drag-handle]";
|
|
45
46
|
const INTERACTIVE_HEADER_TARGET = "button, a, input, select, textarea, [role=button]";
|
|
46
47
|
function readLimits(options) {
|
|
47
48
|
const viewportWidth = window.innerWidth;
|
|
@@ -182,8 +183,11 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
|
|
|
182
183
|
// React events from portalled children (menus) bubble through the header although their DOM is elsewhere.
|
|
183
184
|
if (!(event.target instanceof Node) || !event.currentTarget.contains(event.target))
|
|
184
185
|
return;
|
|
185
|
-
if (event.target instanceof Element &&
|
|
186
|
+
if (event.target instanceof Element &&
|
|
187
|
+
event.target.closest(INTERACTIVE_HEADER_TARGET) &&
|
|
188
|
+
!event.target.closest(DRAG_HANDLE_SELECTOR)) {
|
|
186
189
|
return;
|
|
190
|
+
}
|
|
187
191
|
moveDrag.onPointerDown(event);
|
|
188
192
|
};
|
|
189
193
|
const onGripKeyDown = (event) => {
|
|
@@ -212,7 +216,7 @@ export function FloatingPanel({ title, headerActions, children, storageKey, defa
|
|
|
212
216
|
top: geometry.y,
|
|
213
217
|
width: geometry.width,
|
|
214
218
|
height: geometry.height,
|
|
215
|
-
}, children: [_jsxs("div", { ...moveDrag, onPointerDown: onHeaderPointerDown, className: "flex shrink-0 cursor-grab touch-none select-none items-center gap-2 border-b bg-muted px-3 py-2 active:cursor-grabbing", children: [_jsx("button", { type: "button", "aria-label": t("kumiko.widget.floatingPanel.move"), onKeyDown: onGripKeyDown, className: "rounded-xs p-1 text-muted-foreground hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { id: titleId, className: "min-w-0 flex-1 truncate font-medium", children: title }), headerActions] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4", children: children }), EDGES.map((edge) => (_jsx(PanelResizeHandle, { edges: [edge], className: EDGE_HANDLE_CLASS[edge], context: handleContext, separator: { label: t(EDGE_LABEL_KEYS[edge]), geometry, limits } }, edge))), CORNER_HANDLES.map((corner) => (_jsx(PanelResizeHandle, { edges: corner.edges, className: corner.className, context: handleContext }, corner.edges.join("-"))))] }), document.body);
|
|
219
|
+
}, children: [_jsxs("div", { ...moveDrag, onPointerDown: onHeaderPointerDown, className: "flex shrink-0 cursor-grab touch-none select-none items-center gap-2 border-b bg-muted px-3 py-2 active:cursor-grabbing", children: [_jsx("button", { type: "button", "data-drag-handle": "", "aria-label": t("kumiko.widget.floatingPanel.move"), onKeyDown: onGripKeyDown, className: "rounded-xs p-1 text-muted-foreground hover:bg-secondary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-hidden", children: _jsx(GripVerticalIcon, { className: "size-4" }) }), _jsx("div", { id: titleId, className: "min-w-0 flex-1 truncate font-medium", children: title }), headerActions] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4", children: children }), EDGES.map((edge) => (_jsx(PanelResizeHandle, { edges: [edge], className: EDGE_HANDLE_CLASS[edge], context: handleContext, separator: { label: t(EDGE_LABEL_KEYS[edge]), geometry, limits } }, edge))), CORNER_HANDLES.map((corner) => (_jsx(PanelResizeHandle, { edges: corner.edges, className: corner.className, context: handleContext }, corner.edges.join("-"))))] }), document.body);
|
|
216
220
|
}
|
|
217
221
|
function PanelResizeHandle({ edges, className, context, separator, }) {
|
|
218
222
|
const drag = usePointerDrag({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.337.1",
|
|
4
4
|
"description": "Web-platform bindings for @cosmicdrift/kumiko-renderer. HTML default-primitives, browser history-based navigation, EventSource-backed live events, and a one-call createKumikoApp that mounts the whole stack via react-dom.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
}
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
26
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
27
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
23
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.337.1",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.337.1",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.337.1",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.337.1",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.337.1",
|
|
28
28
|
"@radix-ui/react-dialog": "^1.1.15",
|
|
29
29
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
30
30
|
"@radix-ui/react-label": "^2.1.8",
|
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
"@types/react-dom": "^19.2.3",
|
|
73
73
|
"jsdom": "^29.1.1",
|
|
74
74
|
"tailwindcss": "^4.3.0",
|
|
75
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
75
|
+
"@cosmicdrift/kumiko-locale-de": "0.337.1"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.337.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "A number field's unit stays inside the field when the label is wider than the input",
|
|
6
|
+
"detail": "Icon, input and unit of a `kind: \"number\"` input share one box (`data-slot=\"number-field\"`), and the form grid's number cell sizes that box to 8rem instead of the bare input. Before, a label wider than the input (a long label, or the \"changed\" marker appearing while editing) widened the cell, and the unit was anchored to the cell's right edge, next to the field.",
|
|
7
|
+
"migration": "No code change needed. Custom CSS that sized number inputs through `[&_input]` inside the number cell targets `[data-slot=number-field]` now."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.337.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "A rejected SSE connection ends the web session",
|
|
13
|
+
"detail": "When the server refuses the live-events EventSource with a session 401, createKumikoApp now raises the same session-ended signal as a 401 from the dispatcher, so the user sees the session-end notice instead of silently losing live updates."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.337.0",
|
|
17
|
+
"type": "fix",
|
|
18
|
+
"title": "Kebab confirmLabel, cell locale fallback, card-list sort select and bare-form headerRegion",
|
|
19
|
+
"detail": "The row-actions kebab confirm dialog uses `confirmLabel` like the inline path. A list cell with `renderer.locale` explicitly set to `undefined` falls back to the app locale. The narrow-viewport card list keeps the bare `testId` on a wrapper, and its sort select shows a sort that targets a non-sortable column instead of \"Unsorted\". A bare form renders its `headerRegion`. The dashboard time range no longer needs a type assertion."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.337.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "FloatingPanel grip pointer drag, Drawer style undefined, resize bitmap release, nav action screen+target warning",
|
|
25
|
+
"detail": "The FloatingPanel grip button drags the panel with the pointer again. Drawer passes `style` as `undefined` when no width or offset applies. Image resize releases the decoded bitmap when no 2d context is available. Nav tree actions that set both `screen` and `target` warn once that `target` is ignored."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.337.0",
|
|
29
|
+
"type": "fix",
|
|
30
|
+
"title": "Secret reveal keys, nested-form input submit routing, Grid maxRows/gap, RTL card meta, browser QR import",
|
|
31
|
+
"detail": "Secret reveal rows with equal labels get distinct React keys. A nested form's submit routing also covers `input[type=submit]` and `input[type=image]`. `Grid` ignores null and boolean children when computing `maxRows`, and its gap is the themeable `--kumiko-grid-gap` variable shared with the clip height. Card meta rows clip at the inline start so RTL layouts hide the leading separator too. The QR secret value imports `qrcode/lib/browser.js` so Metro does not pull Node-only dependencies."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.337.0",
|
|
35
|
+
"type": "fix",
|
|
36
|
+
"title": "screenWidth setting now widens the default entityEdit form too",
|
|
37
|
+
"detail": "`createKumikoApp({ screenWidth })` now also applies to the default entityEdit form, which previously stayed at a fixed 640px column. Without the setting the column keeps its 640px width."
|
|
38
|
+
},
|
|
2
39
|
{
|
|
3
40
|
"version": "0.336.0",
|
|
4
41
|
"type": "improvement",
|
package/src/styles.css
CHANGED