@cosmicdrift/kumiko-renderer-web 0.335.0 → 0.336.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/primitives/index.js +16 -6
- package/dist/primitives/narrow-pinned-footer.d.ts +10 -0
- package/dist/primitives/narrow-pinned-footer.js +90 -0
- package/dist/primitives/use-narrow-viewport.d.ts +1 -0
- package/dist/primitives/use-narrow-viewport.js +20 -8
- package/dist/widgets/step-bar.d.ts +5 -2
- package/dist/widgets/step-bar.js +29 -9
- package/package.json +7 -7
- package/src/changes.json +14 -0
package/dist/primitives/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import { LocatedTimestampInput } from "./located-timestamp-input.js";
|
|
|
33
33
|
import { DefaultMetric, DefaultMetricBand, InsideScreenFormColumnContext } from "./metric.js";
|
|
34
34
|
import { DefaultModal } from "./modal.js";
|
|
35
35
|
import { currencyDecimals, formatMoney, MoneyInput } from "./money-input.js";
|
|
36
|
+
import { NarrowPinnedFooter } from "./narrow-pinned-footer.js";
|
|
36
37
|
import { NumberInput } from "./number-input.js";
|
|
37
38
|
import { groupOptions, hasDescriptionOrGroup } from "./option-groups.js";
|
|
38
39
|
import { DefaultPageHeader } from "./page-header.js";
|
|
@@ -44,7 +45,7 @@ import { DefaultTabs } from "./tabs.js";
|
|
|
44
45
|
import { TimestampInput } from "./timestamp-input.js";
|
|
45
46
|
import { useToast } from "./toast.js";
|
|
46
47
|
import { TzInput } from "./tz-input.js";
|
|
47
|
-
import { useIsNarrowViewport } from "./use-narrow-viewport.js";
|
|
48
|
+
import { useIsBelowSmViewport, useIsNarrowViewport } from "./use-narrow-viewport.js";
|
|
48
49
|
// ---- Card-Chrome (eine Definition für Form/Section/Card) ----
|
|
49
50
|
// Die eine Card-Surface. Maße (Padding/Radius/Shadow) kommen aus --card-*
|
|
50
51
|
// CSS-Tokens (Defaults in styles.css), damit eine App sie einmal zentral in
|
|
@@ -1405,10 +1406,18 @@ function flattenActionNodes(node) {
|
|
|
1405
1406
|
}
|
|
1406
1407
|
return Children.toArray(node);
|
|
1407
1408
|
}
|
|
1409
|
+
function unsavedStatusText(t, unsavedCount) {
|
|
1410
|
+
if (unsavedCount === 0)
|
|
1411
|
+
return t("kumiko.form.noUnsavedChanges");
|
|
1412
|
+
if (unsavedCount === 1)
|
|
1413
|
+
return t("kumiko.form.unsaved.one");
|
|
1414
|
+
return t("kumiko.form.unsaved.other", { count: unsavedCount });
|
|
1415
|
+
}
|
|
1408
1416
|
// Footer wrapper for DefaultForm's card and chromeless layouts alike — only
|
|
1409
1417
|
// the card-derived horizontal padding/border differs between them.
|
|
1410
1418
|
function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActions, fillHeight, unsavedCount, railed = false, }) {
|
|
1411
1419
|
const t = useTranslation();
|
|
1420
|
+
const isBelowSm = useIsBelowSmViewport();
|
|
1412
1421
|
if (actions === undefined && secondaryActions === undefined)
|
|
1413
1422
|
return null;
|
|
1414
1423
|
// Only split when sticky: the non-sticky (regular, non-wizard) footer must
|
|
@@ -1429,16 +1438,17 @@ function FormFooter({ actions, secondaryActions, testId, chromeless, stickyActio
|
|
|
1429
1438
|
const hasNonPrimaryOverflow = nonPrimaryActionNodes !== undefined && nonPrimaryActionNodes.length > 0;
|
|
1430
1439
|
const renderedSecondary = stickyActions === true && hasPrimaryAction ? (_jsxs(_Fragment, { children: [secondaryActions, nonPrimaryActionNodes] })) : (secondaryActions);
|
|
1431
1440
|
const pinned = isPinnedFooter(stickyActions, fillHeight);
|
|
1441
|
+
if (pinned && isBelowSm) {
|
|
1442
|
+
return (_jsx(NarrowPinnedFooter, { nodes: [...flattenActionNodes(secondaryActions), ...flattenActionNodes(actions)], overflowLabel: unsavedCount !== undefined && unsavedCount > 0
|
|
1443
|
+
? `${t("kumiko.list.row-actions.more")}, ${unsavedStatusText(t, unsavedCount)}`
|
|
1444
|
+
: t("kumiko.list.row-actions.more"), unsavedCount: unsavedCount, testId: testId }));
|
|
1445
|
+
}
|
|
1432
1446
|
return (_jsxs("div", { className: cn(pinned
|
|
1433
1447
|
? cn("flex h-14 shrink-0 items-center justify-end gap-2 border-t border-border bg-card px-6", !railed && "md:pl-10")
|
|
1434
1448
|
: "flex flex-col-reverse gap-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:py-4", !pinned && !chromeless && "px-[var(--card-padding)]", !pinned && !chromeless && cardFooterBorder, !pinned && fillHeight === true && "shrink-0"), children: [unsavedCount !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-unsaved` : undefined, className: cn("mr-auto flex min-w-0 items-center gap-2 text-[13px]",
|
|
1435
1449
|
// Only the stacked (flex-col-reverse) footer moves the status above the
|
|
1436
1450
|
// actions; the pinned single-row footer keeps it on the left.
|
|
1437
|
-
!pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedCount
|
|
1438
|
-
? t("kumiko.form.noUnsavedChanges")
|
|
1439
|
-
: unsavedCount === 1
|
|
1440
|
-
? t("kumiko.form.unsaved.one")
|
|
1441
|
-
: t("kumiko.form.unsaved.other", { count: unsavedCount }) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn(
|
|
1451
|
+
!pinned && "max-sm:order-last", unsavedCount > 0 ? "text-foreground-secondary" : "text-muted-foreground"), children: [unsavedCount > 0 && (_jsx("span", { className: "size-1.5 shrink-0 rounded-full bg-status-active", "aria-hidden": "true" })), _jsx("span", { className: "truncate", children: unsavedStatusText(t, unsavedCount) })] })), (secondaryActions !== undefined || hasNonPrimaryOverflow) && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions-secondary` : undefined, className: cn(
|
|
1442
1452
|
// min-h-11: link-variant buttons (Copy link) are h-auto and would fall
|
|
1443
1453
|
// below the 24px touch-target minimum on narrow viewports.
|
|
1444
1454
|
"flex flex-wrap items-center gap-2 max-sm:[&_button]:min-h-11 max-sm:[&_button]:px-2 max-sm:[&_button]:text-xs", pinned && hasNonPrimaryOverflow && "mr-auto"), children: renderedSecondary })), renderedActions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: cn("flex items-center gap-2", pinned
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/** Pinned footer below `sm`: always one fixed-height row, Back as icon on the left,
|
|
3
|
+
* everything else in a "…" popover (hidden while none of it is enabled), the primary
|
|
4
|
+
* action filling the rest. */
|
|
5
|
+
export declare function NarrowPinnedFooter({ nodes, overflowLabel, unsavedCount, testId, }: {
|
|
6
|
+
readonly nodes: readonly ReactNode[];
|
|
7
|
+
readonly overflowLabel: string;
|
|
8
|
+
readonly unsavedCount: number | undefined;
|
|
9
|
+
readonly testId: string | undefined;
|
|
10
|
+
}): ReactNode;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { FOOTER_ACTION_ROLE_PROP, NARROW_LABEL_PROP, STICKY_PRIMARY_ACTION_PROP, } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
+
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
|
4
|
+
import { MoreHorizontal } from "lucide-react";
|
|
5
|
+
import { cloneElement, isValidElement, useLayoutEffect, useState, } from "react";
|
|
6
|
+
import { cn } from "../lib/cn.js";
|
|
7
|
+
import { Button as UiButton } from "../ui/button.js";
|
|
8
|
+
function asFooterElement(node) {
|
|
9
|
+
return isValidElement(node) ? node : undefined;
|
|
10
|
+
}
|
|
11
|
+
function isBackNode(node) {
|
|
12
|
+
return asFooterElement(node)?.props[FOOTER_ACTION_ROLE_PROP] === "back";
|
|
13
|
+
}
|
|
14
|
+
function isPrimaryNode(node) {
|
|
15
|
+
const props = asFooterElement(node)?.props;
|
|
16
|
+
return props?.[FOOTER_ACTION_ROLE_PROP] === "primary" || props?.type === "submit";
|
|
17
|
+
}
|
|
18
|
+
function isStickyMarkedNode(node) {
|
|
19
|
+
return asFooterElement(node)?.props[STICKY_PRIMARY_ACTION_PROP] === true;
|
|
20
|
+
}
|
|
21
|
+
function lastOf(items) {
|
|
22
|
+
return items[items.length - 1];
|
|
23
|
+
}
|
|
24
|
+
function splitNarrowFooterNodes(nodes) {
|
|
25
|
+
const back = nodes.find(isBackNode);
|
|
26
|
+
const primary = lastOf(nodes.filter(isPrimaryNode)) ?? lastOf(nodes.filter(isStickyMarkedNode));
|
|
27
|
+
return {
|
|
28
|
+
back,
|
|
29
|
+
primary,
|
|
30
|
+
overflow: nodes.filter((node) => node !== back && node !== primary),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function BackIconButton({ node }) {
|
|
34
|
+
const { children, ariaLabel } = node.props;
|
|
35
|
+
return cloneElement(node, {
|
|
36
|
+
size: "icon",
|
|
37
|
+
ariaLabel: typeof children === "string" ? children : ariaLabel,
|
|
38
|
+
children: null,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
function PrimaryButton({ node }) {
|
|
42
|
+
const element = asFooterElement(node);
|
|
43
|
+
if (element === undefined || !isPrimaryNode(node)) {
|
|
44
|
+
return _jsx("div", { className: "min-w-0 flex-1 [&>button]:w-full", children: node });
|
|
45
|
+
}
|
|
46
|
+
const narrowLabel = element.props[NARROW_LABEL_PROP];
|
|
47
|
+
return cloneElement(element, {
|
|
48
|
+
className: "min-w-0 flex-1",
|
|
49
|
+
children: _jsx("span", { className: "min-w-0 truncate", children: narrowLabel ?? element.props.children }),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
// Overflow nodes are arbitrary (slot mounts may render nothing, buttons may be disabled),
|
|
53
|
+
// so "has something to offer" is read from the mounted DOM, not from the elements.
|
|
54
|
+
const ENABLED_ACTION_SELECTOR = "button:not(:disabled):not([aria-disabled='true']), a[href]:not([aria-disabled='true'])";
|
|
55
|
+
function useContainsEnabledAction(container) {
|
|
56
|
+
const [containsEnabledAction, setContainsEnabledAction] = useState(false);
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
if (container === null)
|
|
59
|
+
return;
|
|
60
|
+
const update = () => setContainsEnabledAction(container.querySelector(ENABLED_ACTION_SELECTOR) !== null);
|
|
61
|
+
update();
|
|
62
|
+
const observer = new MutationObserver(update);
|
|
63
|
+
observer.observe(container, {
|
|
64
|
+
subtree: true,
|
|
65
|
+
childList: true,
|
|
66
|
+
attributes: true,
|
|
67
|
+
attributeFilter: ["disabled", "aria-disabled", "href"],
|
|
68
|
+
});
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [container]);
|
|
71
|
+
return containsEnabledAction;
|
|
72
|
+
}
|
|
73
|
+
function OverflowPopover({ nodes, label, unsavedCount, testId, }) {
|
|
74
|
+
const [open, setOpen] = useState(false);
|
|
75
|
+
const [content, setContent] = useState(null);
|
|
76
|
+
const hasEnabledAction = useContainsEnabledAction(content);
|
|
77
|
+
const isDirty = unsavedCount !== undefined && unsavedCount > 0;
|
|
78
|
+
return (_jsxs(PopoverPrimitive.Root, { open: open && hasEnabledAction, onOpenChange: setOpen, children: [_jsx(PopoverPrimitive.Trigger, { asChild: true, children: _jsxs(UiButton, { type: "button", variant: "outline", size: "icon", className: cn("relative shrink-0 border-input hover:bg-muted", !hasEnabledAction && "hidden"), "aria-label": label, "data-testid": testId !== undefined ? `${testId}-overflow` : undefined, children: [_jsx(MoreHorizontal, { className: "size-4", "aria-hidden": "true" }), isDirty && (_jsx("span", { "aria-hidden": "true", "data-testid": testId !== undefined ? `${testId}-overflow-badge` : undefined, className: "absolute top-1 right-1 size-2 rounded-full bg-status-active" }))] }) }), _jsx(PopoverPrimitive.Portal, { forceMount: true, children: _jsx(PopoverPrimitive.Content, { ref: setContent, forceMount: true, side: "top", align: "center", sideOffset: 8, onClick: (event) => {
|
|
79
|
+
if (event.target.closest("button") !== null)
|
|
80
|
+
setOpen(false);
|
|
81
|
+
}, className: cn("z-50 flex w-[calc(100vw-2rem)] flex-col gap-1 rounded-md border border-border bg-card p-1 shadow-md", "[&_button]:w-full [&_button]:justify-start [&_button]:text-left", "data-[state=closed]:hidden"), children: nodes }) })] }));
|
|
82
|
+
}
|
|
83
|
+
/** Pinned footer below `sm`: always one fixed-height row, Back as icon on the left,
|
|
84
|
+
* everything else in a "…" popover (hidden while none of it is enabled), the primary
|
|
85
|
+
* action filling the rest. */
|
|
86
|
+
export function NarrowPinnedFooter({ nodes, overflowLabel, unsavedCount, testId, }) {
|
|
87
|
+
const { back, primary, overflow } = splitNarrowFooterNodes(nodes);
|
|
88
|
+
const backElement = asFooterElement(back);
|
|
89
|
+
return (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "flex h-14 shrink-0 items-center gap-2 border-t border-border bg-card px-4", children: [backElement !== undefined && _jsx(BackIconButton, { node: backElement }), overflow.length > 0 && (_jsx(OverflowPopover, { nodes: overflow, label: overflowLabel, unsavedCount: unsavedCount, testId: testId })), primary !== undefined && _jsx(PrimaryButton, { node: primary })] }));
|
|
90
|
+
}
|
|
@@ -3,14 +3,22 @@ import { useSyncExternalStore } from "react";
|
|
|
3
3
|
// because that file is vendored shadcn (regenerated via scripts/sync-shadcn.ts)
|
|
4
4
|
// and cannot be imported from without risking a future overwrite.
|
|
5
5
|
const MOBILE_BREAKPOINT = 768;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
const SM_BREAKPOINT = 640;
|
|
7
|
+
// Module-level stores keep subscribe stable, so useSyncExternalStore does not
|
|
8
|
+
// resubscribe on every render (FormFooter re-renders on each keystroke).
|
|
9
|
+
function createBelowBreakpointStore(breakpoint) {
|
|
10
|
+
const query = `(max-width: ${breakpoint - 1}px)`;
|
|
11
|
+
return {
|
|
12
|
+
subscribe: (callback) => {
|
|
13
|
+
const mql = window.matchMedia(query);
|
|
14
|
+
mql.addEventListener("change", callback);
|
|
15
|
+
return () => mql.removeEventListener("change", callback);
|
|
16
|
+
},
|
|
17
|
+
getSnapshot: () => window.matchMedia(query).matches,
|
|
18
|
+
};
|
|
13
19
|
}
|
|
20
|
+
const belowMobileStore = createBelowBreakpointStore(MOBILE_BREAKPOINT);
|
|
21
|
+
const belowSmStore = createBelowBreakpointStore(SM_BREAKPOINT);
|
|
14
22
|
function getServerSnapshot() {
|
|
15
23
|
return false;
|
|
16
24
|
}
|
|
@@ -19,5 +27,9 @@ function getServerSnapshot() {
|
|
|
19
27
|
// render regardless of actual viewport, this reads the real value up front
|
|
20
28
|
// via `useSyncExternalStore` — no wrong-then-corrected first render.
|
|
21
29
|
export function useIsNarrowViewport() {
|
|
22
|
-
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
30
|
+
return useSyncExternalStore(belowMobileStore.subscribe, belowMobileStore.getSnapshot, getServerSnapshot);
|
|
31
|
+
}
|
|
32
|
+
// Tailwind's `sm` boundary: true below 640px.
|
|
33
|
+
export function useIsBelowSmViewport() {
|
|
34
|
+
return useSyncExternalStore(belowSmStore.subscribe, belowSmStore.getSnapshot, getServerSnapshot);
|
|
23
35
|
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
2
|
/** Wizard step overview — numbered chips with a connector line between
|
|
3
3
|
* them. With `onStepSelect`, done chips render as buttons for jumping back
|
|
4
4
|
* (every non-current chip with `selectableSteps="all"`); the current chip
|
|
5
|
-
* never does. `doneSteps` overrides the position-based done state.
|
|
5
|
+
* never does. `doneSteps` overrides the position-based done state. With
|
|
6
|
+
* `onStepSelect` and `selectableSteps="all"` the narrow-viewport label becomes
|
|
7
|
+
* a dropdown listing every step (same rows as the rail), so phones can jump
|
|
8
|
+
* too. Three visual states, none conveyed by color alone:
|
|
6
9
|
* done (checkmark replaces the number, `aria-current` absent, a sr-only
|
|
7
10
|
* label says so since the number itself is gone), current (`aria-current
|
|
8
11
|
* ="step"`, own background), upcoming (dimmed, number visible). Below
|
package/dist/widgets/step-bar.js
CHANGED
|
@@ -1,15 +1,38 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
3
|
-
import { Check } from "lucide-react";
|
|
3
|
+
import { Check, ChevronDown } from "lucide-react";
|
|
4
|
+
import { useId, useRef, useState } from "react";
|
|
4
5
|
import { cn } from "../lib/cn.js";
|
|
5
6
|
const CHIP_CLASS = "flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors";
|
|
6
7
|
function chipTestId(testId, index) {
|
|
7
8
|
return testId !== undefined ? `${testId}-step-${index}` : undefined;
|
|
8
9
|
}
|
|
10
|
+
function StepRailRow({ index, label, subtitle, subtitleTestId, rowTestId, state: { isCurrent, isDone, isSelectable }, onSelect, doneLabel, minHeightClass = "min-h-9", }) {
|
|
11
|
+
const rowClass = cn("flex w-full items-center gap-2.5 rounded-md px-2 py-1 text-left text-sm", minHeightClass, isCurrent && "bg-primary/10 font-semibold text-primary", isDone && "text-foreground", !isCurrent && !isDone && "text-foreground-secondary");
|
|
12
|
+
const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: cn("flex size-[22px] shrink-0 items-center justify-center rounded-full border text-xs font-semibold", isCurrent && "border-primary bg-primary text-primary-foreground", isDone && "border-status-ok-surface bg-status-ok-surface text-status-ok", !isCurrent && !isDone && "border-input bg-card text-foreground-secondary"), children: isDone ? _jsx(Check, { className: "size-3" }) : index + 1 }), isDone && _jsx("span", { className: "sr-only", children: doneLabel }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: label }), subtitle !== undefined && (_jsx("span", { "data-testid": subtitleTestId, className: "truncate text-xs font-normal text-foreground-secondary", children: subtitle }))] })] }));
|
|
13
|
+
return isSelectable ? (_jsx("button", { type: "button", onClick: () => onSelect?.(index), "data-testid": rowTestId, className: cn(rowClass, "hover:bg-muted-foreground/10"), children: content })) : (_jsx("span", { "aria-current": isCurrent ? "step" : undefined, "data-testid": rowTestId, className: rowClass, children: content }));
|
|
14
|
+
}
|
|
15
|
+
function CompactStepPicker({ steps, compactLabel, subtitles, stepState, onStepSelect, compactTestId, doneLabel, }) {
|
|
16
|
+
const [open, setOpen] = useState(false);
|
|
17
|
+
const listId = useId();
|
|
18
|
+
const toggleRef = useRef(null);
|
|
19
|
+
return (_jsxs("div", { onKeyDown: (event) => {
|
|
20
|
+
if (event.key === "Escape" && open) {
|
|
21
|
+
setOpen(false);
|
|
22
|
+
toggleRef.current?.focus();
|
|
23
|
+
}
|
|
24
|
+
}, children: [_jsxs("button", { ref: toggleRef, type: "button", "aria-expanded": open, "aria-controls": listId, "data-testid": compactTestId, onClick: () => setOpen((wasOpen) => !wasOpen), className: "flex min-h-11 w-full items-center justify-between gap-2 rounded-md border border-border bg-card px-3 text-left text-sm font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [compactLabel, _jsx(ChevronDown, { "aria-hidden": "true", className: cn("size-4 shrink-0 transition-transform", open && "rotate-180") })] }), open && (_jsx("ol", { id: listId, className: "m-0 mt-1 flex list-none flex-col gap-0.5 rounded-md border border-border bg-card p-1", children: steps.map((label, i) => (_jsx("li", { children: _jsx(StepRailRow, { index: i, label: label, subtitle: subtitles?.[i], subtitleTestId: undefined, rowTestId: chipTestId(compactTestId, i), state: stepState(i), onSelect: (index) => {
|
|
25
|
+
setOpen(false);
|
|
26
|
+
onStepSelect(index);
|
|
27
|
+
}, doneLabel: doneLabel, minHeightClass: "min-h-11" }) }, `${i}-${label}`))) }))] }));
|
|
28
|
+
}
|
|
9
29
|
/** Wizard step overview — numbered chips with a connector line between
|
|
10
30
|
* them. With `onStepSelect`, done chips render as buttons for jumping back
|
|
11
31
|
* (every non-current chip with `selectableSteps="all"`); the current chip
|
|
12
|
-
* never does. `doneSteps` overrides the position-based done state.
|
|
32
|
+
* never does. `doneSteps` overrides the position-based done state. With
|
|
33
|
+
* `onStepSelect` and `selectableSteps="all"` the narrow-viewport label becomes
|
|
34
|
+
* a dropdown listing every step (same rows as the rail), so phones can jump
|
|
35
|
+
* too. Three visual states, none conveyed by color alone:
|
|
13
36
|
* done (checkmark replaces the number, `aria-current` absent, a sr-only
|
|
14
37
|
* label says so since the number itself is gone), current (`aria-current
|
|
15
38
|
* ="step"`, own background), upcoming (dimmed, number visible). Below
|
|
@@ -25,16 +48,13 @@ export function StepBar({ steps, currentIndex, compactLabel, onStepSelect, doneS
|
|
|
25
48
|
const isSelectable = onStepSelect !== undefined && !isCurrent && (selectableSteps === "all" || isDone);
|
|
26
49
|
return { isCurrent, isDone, isSelectable };
|
|
27
50
|
};
|
|
51
|
+
const compactPicker = onStepSelect !== undefined && selectableSteps === "all";
|
|
52
|
+
const pickerElement = compactPicker ? (_jsx(CompactStepPicker, { steps: steps, compactLabel: compactLabel, subtitles: subtitles, stepState: stepState, onStepSelect: onStepSelect, compactTestId: compactTestId, doneLabel: t("kumiko.widget.step-bar.done") })) : null;
|
|
28
53
|
if (orientation === "vertical") {
|
|
29
|
-
return (_jsxs(_Fragment, { children: [_jsxs("nav", { className: "hidden w-[280px] shrink-0 flex-col gap-4 overflow-y-auto border-r border-border bg-muted px-6 py-7 lg:flex", children: [heading !== undefined && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: heading }), description !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: description }))] })), _jsx("ol", { "data-testid": testId, className: "m-0 flex list-none flex-col gap-0.5 p-0", children: steps.map((label, i) => {
|
|
30
|
-
const { isCurrent, isDone, isSelectable } = stepState(i);
|
|
31
|
-
const rowClass = cn("flex min-h-9 w-full items-center gap-2.5 rounded-md px-2 py-1 text-left text-sm", isCurrent && "bg-primary/10 font-semibold text-primary", isDone && "text-foreground", !isCurrent && !isDone && "text-foreground-secondary");
|
|
32
|
-
const content = (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: cn("flex size-[22px] shrink-0 items-center justify-center rounded-full border text-xs font-semibold", isCurrent && "border-primary bg-primary text-primary-foreground", isDone && "border-status-ok-surface bg-status-ok-surface text-status-ok", !isCurrent && !isDone && "border-input bg-card text-foreground-secondary"), children: isDone ? _jsx(Check, { className: "size-3" }) : i + 1 }), isDone && _jsx("span", { className: "sr-only", children: t("kumiko.widget.step-bar.done") }), _jsxs("span", { className: "flex min-w-0 flex-col", children: [_jsx("span", { className: "truncate", children: label }), subtitles?.[i] !== undefined && (_jsx("span", { "data-testid": testId !== undefined ? `${testId}-subtitle-${i}` : undefined, className: "truncate text-xs font-normal text-foreground-secondary", children: subtitles[i] }))] })] }));
|
|
33
|
-
return (_jsx("li", { children: isSelectable ? (_jsx("button", { type: "button", onClick: () => onStepSelect?.(i), "data-testid": chipTestId(testId, i), className: cn(rowClass, "hover:bg-muted-foreground/10"), children: content })) : (_jsx("span", { "aria-current": isCurrent ? "step" : undefined, "data-testid": chipTestId(testId, i), className: rowClass, children: content })) }, `${i}-${label}`));
|
|
34
|
-
}) }), upNext !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-up-next` : undefined, className: "mt-2 flex flex-col gap-1 rounded-md border border-border bg-card px-3 py-2.5", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: upNext.heading }), _jsx("span", { className: "text-sm font-medium text-foreground", children: upNext.title }), upNext.subtitle !== undefined && (_jsx("span", { className: "text-xs text-foreground-secondary", children: upNext.subtitle }))] }))] }), _jsx("p", { "data-testid": compactTestId, className: "px-4 pt-3 text-sm text-muted-foreground lg:hidden", children: compactLabel })] }));
|
|
54
|
+
return (_jsxs(_Fragment, { children: [_jsxs("nav", { className: "hidden w-[280px] shrink-0 flex-col gap-4 overflow-y-auto border-r border-border bg-muted px-6 py-7 lg:flex", children: [heading !== undefined && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-sm font-semibold text-foreground", children: heading }), description !== undefined && (_jsx("span", { className: "text-[13px] text-foreground-secondary", children: description }))] })), _jsx("ol", { "data-testid": testId, className: "m-0 flex list-none flex-col gap-0.5 p-0", children: steps.map((label, i) => (_jsx("li", { children: _jsx(StepRailRow, { index: i, label: label, subtitle: subtitles?.[i], subtitleTestId: testId !== undefined ? `${testId}-subtitle-${i}` : undefined, rowTestId: chipTestId(testId, i), state: stepState(i), onSelect: onStepSelect, doneLabel: t("kumiko.widget.step-bar.done") }) }, `${i}-${label}`))) }), upNext !== undefined && (_jsxs("div", { "data-testid": testId !== undefined ? `${testId}-up-next` : undefined, className: "mt-2 flex flex-col gap-1 rounded-md border border-border bg-card px-3 py-2.5", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: upNext.heading }), _jsx("span", { className: "text-sm font-medium text-foreground", children: upNext.title }), upNext.subtitle !== undefined && (_jsx("span", { className: "text-xs text-foreground-secondary", children: upNext.subtitle }))] }))] }), compactPicker ? (_jsx("div", { className: "px-4 pt-3 lg:hidden", children: pickerElement })) : (_jsx("p", { "data-testid": compactTestId, className: "px-4 pt-3 text-sm text-muted-foreground lg:hidden", children: compactLabel }))] }));
|
|
35
55
|
}
|
|
36
56
|
return (_jsxs(_Fragment, { children: [_jsx("ol", { "data-testid": testId, className: cn("items-center gap-2", narrowLayout === "steps" ? "flex flex-wrap" : "hidden sm:flex sm:flex-wrap"), children: steps.map((label, i) => {
|
|
37
57
|
const { isCurrent, isDone, isSelectable } = stepState(i);
|
|
38
58
|
return (_jsxs("li", { className: "flex items-center gap-2", children: [i > 0 && _jsx("span", { "aria-hidden": "true", className: "h-px w-4 bg-border" }), isSelectable ? (_jsxs("button", { type: "button", onClick: () => onStepSelect?.(i), "data-testid": chipTestId(testId, i), className: cn(CHIP_CLASS, isDone ? "text-primary" : "text-muted-foreground", "max-sm:min-h-11 hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"), children: [isDone ? (_jsxs(_Fragment, { children: [_jsx(Check, { "aria-hidden": "true", className: "size-3.5" }), _jsx("span", { className: "sr-only", children: t("kumiko.widget.step-bar.done") })] })) : (_jsx("span", { className: "text-xs font-semibold", children: i + 1 })), label] })) : (_jsxs("span", { "aria-current": isCurrent ? "step" : undefined, "data-testid": chipTestId(testId, i), className: cn(CHIP_CLASS, isCurrent && "bg-primary font-semibold text-primary-foreground", isDone && "text-primary", !isCurrent && !isDone && "text-muted-foreground"), children: [isDone ? (_jsxs(_Fragment, { children: [_jsx(Check, { "aria-hidden": "true", className: "size-3.5" }), _jsx("span", { className: "sr-only", children: t("kumiko.widget.step-bar.done") })] })) : (_jsx("span", { className: "text-xs font-semibold", children: i + 1 })), label] }))] }, `${i}-${label}`));
|
|
39
|
-
}) }), _jsx("p", { "data-testid": compactTestId, className: cn("text-sm text-muted-foreground", narrowLayout === "steps" ? "hidden" : "sm:hidden"), children: compactLabel })] }));
|
|
59
|
+
}) }), compactPicker ? (_jsx("div", { className: narrowLayout === "steps" ? "hidden" : "sm:hidden", children: pickerElement })) : (_jsx("p", { "data-testid": compactTestId, className: cn("text-sm text-muted-foreground", narrowLayout === "steps" ? "hidden" : "sm:hidden"), children: compactLabel }))] }));
|
|
40
60
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.336.0",
|
|
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.336.0",
|
|
24
|
+
"@cosmicdrift/kumiko-framework": "0.336.0",
|
|
25
|
+
"@cosmicdrift/kumiko-headless": "0.336.0",
|
|
26
|
+
"@cosmicdrift/kumiko-renderer": "0.336.0",
|
|
27
|
+
"@cosmicdrift/kumiko-types": "0.336.0",
|
|
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.336.0"
|
|
76
76
|
},
|
|
77
77
|
"repository": {
|
|
78
78
|
"type": "git",
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.336.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "EventSource live events parse the signal-only frame",
|
|
6
|
+
"detail": "`createEventSourceLiveEvents` forwards the new frame shape without payload and with `eventType`.",
|
|
7
|
+
"migration": "No code change needed."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.336.0",
|
|
11
|
+
"type": "improvement",
|
|
12
|
+
"title": "Update-mode wizards get a step picker on narrow viewports; the pinned footer is a single fixed row on phones",
|
|
13
|
+
"detail": "`StepBar` with `onStepSelect` and `selectableSteps=\"all\"` renders the compact label as a dropdown listing every step. Below `sm` the pinned footer is one row: Back as icon, other actions in a \"…\" popover that only shows while one of them is enabled, primary action filling the rest. New renderer markers `FOOTER_ACTION_ROLE_PROP` and `NARROW_LABEL_PROP` classify custom footer buttons.",
|
|
14
|
+
"migration": "No code change needed."
|
|
15
|
+
},
|
|
2
16
|
{
|
|
3
17
|
"version": "0.335.0",
|
|
4
18
|
"type": "improvement",
|