@cosmicdrift/kumiko-renderer-web 0.349.0 → 0.350.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/nav.js CHANGED
@@ -121,25 +121,34 @@ function applySearchParamUpdates(updates) {
121
121
  for (const l of listeners)
122
122
  l();
123
123
  }
124
- function pushPath(path) {
124
+ // undefined = the caller passed no search params; "" = an explicitly empty query.
125
+ function formatSearch(options) {
126
+ if (options?.searchParams === undefined)
127
+ return undefined;
128
+ const query = new URLSearchParams(options.searchParams).toString();
129
+ return query === "" ? "" : `?${query}`;
130
+ }
131
+ function pushPath(path, search) {
125
132
  if (typeof window === "undefined")
126
133
  return;
127
- // Nur pushen wenn sich der Pfad wirklich ändert — doppelte navigate()
128
- // Aufrufe mit demselben Ziel sollen nicht die History fluten.
129
- if (window.location.pathname === path)
134
+ // Repeated navigate() calls to the same URL must not flood the history.
135
+ // Without explicit search params the current query counts as unchanged.
136
+ const unchanged = window.location.pathname === path &&
137
+ (search === undefined || window.location.search === search);
138
+ if (unchanged)
130
139
  return;
131
- window.history.pushState(null, "", path);
140
+ window.history.pushState(null, "", `${path}${search ?? ""}`);
132
141
  for (const l of listeners)
133
142
  l();
134
143
  }
135
- function replacePath(path) {
144
+ function replacePath(path, search) {
136
145
  if (typeof window === "undefined")
137
146
  return;
138
147
  // No path-change short-circuit here: callers explicitly chose replace
139
148
  // to avoid creating a history entry, even when the URL is identical.
140
149
  // (pushPath skips no-op pushes; replacePath honors the call so the
141
150
  // entry-stack semantics stay predictable.)
142
- window.history.replaceState(null, "", path);
151
+ window.history.replaceState(null, "", `${path}${search ?? ""}`);
143
152
  for (const l of listeners)
144
153
  l();
145
154
  }
@@ -197,9 +206,9 @@ export function useBrowserNavApi(options) {
197
206
  const toPath = (target) => prependBasePath(formatPath(inCurrentWorkspace(resolveTarget(features, target))), basePath);
198
207
  return {
199
208
  route,
200
- navigate: (target) => pushPath(toPath(target)),
201
- replace: (target) => replacePath(toPath(target)),
202
- hrefFor: (target) => toPath(target),
209
+ navigate: (target, navigateOptions) => pushPath(toPath(target), formatSearch(navigateOptions)),
210
+ replace: (target, navigateOptions) => replacePath(toPath(target), formatSearch(navigateOptions)),
211
+ hrefFor: (target, navigateOptions) => `${toPath(target)}${formatSearch(navigateOptions) ?? ""}`,
203
212
  searchParams,
204
213
  setSearchParams: applySearchParamUpdates,
205
214
  };
@@ -504,7 +504,7 @@ const EXPAND_TOGGLE_CELL_CLASS = "w-10 px-1! py-0!";
504
504
  const EXPANSION_CELL_CLASS = "whitespace-normal bg-muted p-0! align-top md:[&_td:last-child]:bg-muted";
505
505
  const EXPANSION_CONTENT_CLASS = "pt-1 pr-4 pb-3.5 pl-[52px]";
506
506
  function RowExpandToggle({ expanded, controlsId, label, onToggle, testId, }) {
507
- return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
507
+ return (_jsx(UiButton, { type: "button", variant: "ghost", size: "icon", className: "size-8 shrink-0 max-md:size-11", "aria-expanded": expanded, ...(expanded && { "aria-controls": controlsId }), "aria-label": label, "data-testid": testId, onClick: (e) => {
508
508
  e.stopPropagation();
509
509
  onToggle();
510
510
  }, onKeyDown: (e) => e.stopPropagation(), children: _jsx(ChevronRight, { className: cn("size-4 transition-transform", expanded && "rotate-90"), "aria-hidden": "true" }) }));
@@ -670,7 +670,7 @@ function DefaultDataTable({ columns, rows, onRowClick, sort, onSortChange, empty
670
670
  e.preventDefault();
671
671
  e.stopPropagation();
672
672
  onRowClick(row);
673
- }, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink }) }))] }, row.id));
673
+ }, children: body })) : (body), hasMenu && (_jsx("div", { onClick: (e) => e.stopPropagation(), onKeyDown: (e) => e.stopPropagation(), children: _jsx(RowActionsCell, { row: row, actions: menuActions, mode: rowActionMode, rowIsLink: rowIsLink, compact: true }) }))] }, row.id));
674
674
  if (!isRowExpandable)
675
675
  return rowItem;
676
676
  // The expansion is a sibling <li>, outside the row's click target, so
@@ -764,7 +764,7 @@ function primaryRowAction(actions) {
764
764
  // The isVisible filter runs here; an action hidden for a given row never
765
765
  // reaches render. If every action is hidden, the cell stays empty (no
766
766
  // phantom column).
767
- function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, }) {
767
+ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, compact = false, }) {
768
768
  const visible = actions.filter((a) => a.isVisible === undefined || a.isVisible(row));
769
769
  if (visible.length === 0)
770
770
  return null;
@@ -774,12 +774,12 @@ function RowActionsCell({ row, actions, mode = "adaptive", rowIsLink = false, })
774
774
  // otherwise "inline" is exactly the wall-to-wall text-button problem
775
775
  // this feature exists to fix. An author-set `display` takes that choice over.
776
776
  const iconOnly = !hasExplicitDisplay && shouldRenderActionsIconOnly(visible);
777
- return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly }, a.id))) }));
777
+ return (_jsx("div", { className: "flex w-full items-center gap-1 justify-start", children: visible.map((a) => (_jsx(RowActionButton, { row: row, action: a, iconOnly: iconOnly, compact: compact }, a.id))) }));
778
778
  }
779
779
  if (hasExplicitDisplay) {
780
780
  const inline = visible.filter((a) => a.display !== undefined);
781
781
  const rest = visible.filter((a) => a.display === undefined);
782
- return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
782
+ return (_jsxs("div", { className: "inline-flex items-center gap-1 justify-end", children: [inline.map((a) => (_jsx(RowActionButton, { row: row, action: a, compact: compact }, a.id))), rest.length > 0 && _jsx(RowActionsKebab, { row: row, actions: rest })] }));
783
783
  }
784
784
  if (rowIsLink) {
785
785
  return (_jsx("div", { className: "inline-flex items-center justify-end", children: _jsx(RowActionsKebab, { row: row, actions: visible }) }));
@@ -890,12 +890,20 @@ function useRowActionTrigger(row) {
890
890
  };
891
891
  return { busy, triggerNow };
892
892
  }
893
- function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
893
+ // An icon-only form without a resolvable icon would render an empty button.
894
+ // `compact` is the narrow card layout, where "responsive" collapses to its icon.
895
+ function effectiveRowActionDisplay(display, hasIcon, compact) {
896
+ if (display === "responsive")
897
+ return hasIcon && compact ? "icon" : "button";
898
+ if (display === "icon" && !hasIcon)
899
+ return "link";
900
+ return display;
901
+ }
902
+ function RowActionButton({ row, action, iconOnly = false, asLink = false, compact = false, }) {
894
903
  const { busy, triggerNow } = useRowActionTrigger(row);
895
904
  const [confirmOpen, setConfirmOpen] = useState(false);
896
905
  const resolvedIcon = actionIconFor(action.icon);
897
- // "icon" without a resolvable icon would render an empty button.
898
- const display = action.display === "icon" && resolvedIcon === undefined ? "link" : action.display;
906
+ const display = effectiveRowActionDisplay(action.display, resolvedIcon !== undefined, compact);
899
907
  const isBorderedButton = display === "button";
900
908
  const showIconOnly = display === "icon" || (display === undefined && iconOnly && resolvedIcon !== undefined);
901
909
  const linkLike = display === "link" || (display === undefined && asLink);
@@ -924,7 +932,7 @@ function RowActionButton({ row, action, iconOnly = false, asLink = false, }) {
924
932
  ? "h-8 rounded-md px-3 font-medium max-md:h-11"
925
933
  : linkLike
926
934
  ? "h-7 rounded-md px-2.5 max-md:h-11"
927
- : "h-8 rounded-sm", showIconOnly ? "w-8" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
935
+ : "h-8 rounded-sm max-md:h-11", showIconOnly ? "w-8 max-md:w-11" : !linkLike && !isBorderedButton && "px-2", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", "disabled:opacity-50 disabled:pointer-events-none", variantClass), children: busy ? (_jsx(Loader2, { className: "size-3.5 animate-spin", "aria-hidden": "true" })) : resolvedIcon === undefined || (linkLike && !showIconOnly) ? (action.label) : showIconOnly ? (_jsx(Icon, { name: resolvedIcon, className: "size-4" })) : (_jsxs(_Fragment, { children: [_jsx(Icon, { name: resolvedIcon, className: "size-4" }), action.label] })) }), _jsx(DefaultDialog, { open: confirmOpen, onOpenChange: setConfirmOpen, title: action.label, ...(action.confirm !== undefined && { description: action.confirm }), confirmLabel: action.confirmLabel ?? action.label, ...(action.style === "danger" && { variant: "danger" }), onConfirm: () => triggerNow(action), testId: `row-${row.id}-action-${action.id}-dialog` })] }));
928
936
  }
929
937
  // Kebab dropdown for >2 actions, one inline confirm dialog per item like
930
938
  // the inline-button path. Menu is controlled so it closes explicitly on
@@ -6,7 +6,7 @@ import { cn } from "../lib/cn.js";
6
6
  import { ModalShell } from "./modal-shell.js";
7
7
  const NAV_BUTTON_CLASS = "absolute top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full border border-border bg-card text-foreground opacity-80 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring";
8
8
  export function DefaultLightbox(props) {
9
- const { open, onOpenChange, testId } = props;
9
+ const { open, onOpenChange, testId, actions, showPosition = true } = props;
10
10
  const t = useTranslation();
11
11
  const images = "images" in props ? props.images : [{ src: props.src, alt: props.alt }];
12
12
  const onIndexChange = "images" in props ? props.onIndexChange : undefined;
@@ -31,5 +31,5 @@ export function DefaultLightbox(props) {
31
31
  goTo(index - 1);
32
32
  }
33
33
  };
34
- return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, ...(canNavigate && { onContentKeyDown: handleKeyDown }), contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: current.alt }), _jsx("img", { src: current.src, alt: current.alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" }), canNavigate && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.previous"), onClick: () => goTo(index - 1), className: cn(NAV_BUTTON_CLASS, "left-2"), children: _jsx(ChevronLeft, { className: "size-5" }) }), _jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.next"), onClick: () => goTo(index + 1), className: cn(NAV_BUTTON_CLASS, "right-2"), children: _jsx(ChevronRight, { className: "size-5" }) }), _jsx("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-card px-3 py-1 text-xs text-foreground shadow-sm", children: t("kumiko.lightbox.position", { current: index + 1, total }) })] }))] }));
34
+ return (_jsxs(ModalShell, { open: open, onOpenChange: onOpenChange, testId: testId, closeLabel: t("kumiko.dialog.close"), noAriaDescription: true, ...(canNavigate && { onContentKeyDown: handleKeyDown }), contentClassName: cn("border-0 bg-transparent p-0 shadow-none", "max-w-[95vw] max-h-[90vh] w-auto"), children: [_jsx(DialogPrimitive.Title, { className: "sr-only", children: current.alt }), _jsx("img", { src: current.src, alt: current.alt, className: "block max-h-[85vh] max-w-[90vw] w-auto rounded-lg border border-border bg-card shadow-lg" }), canNavigate && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.previous"), onClick: () => goTo(index - 1), className: cn(NAV_BUTTON_CLASS, "left-2"), children: _jsx(ChevronLeft, { className: "size-5" }) }), _jsx("button", { type: "button", "aria-label": t("kumiko.lightbox.next"), onClick: () => goTo(index + 1), className: cn(NAV_BUTTON_CLASS, "right-2"), children: _jsx(ChevronRight, { className: "size-5" }) }), showPosition && (_jsx("div", { className: "absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-card px-3 py-1 text-xs text-foreground shadow-sm", children: t("kumiko.lightbox.position", { current: index + 1, total }) }))] })), actions !== undefined && (_jsx("div", { "data-testid": testId !== undefined ? `${testId}-actions` : undefined, className: "absolute left-2 top-2 flex items-center gap-2", children: actions }))] }));
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-web",
3
- "version": "0.349.0",
3
+ "version": "0.350.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.349.0",
24
- "@cosmicdrift/kumiko-framework": "0.349.0",
25
- "@cosmicdrift/kumiko-headless": "0.349.0",
26
- "@cosmicdrift/kumiko-renderer": "0.349.0",
27
- "@cosmicdrift/kumiko-types": "0.349.0",
23
+ "@cosmicdrift/kumiko-dispatcher-live": "0.350.0",
24
+ "@cosmicdrift/kumiko-framework": "0.350.0",
25
+ "@cosmicdrift/kumiko-headless": "0.350.0",
26
+ "@cosmicdrift/kumiko-renderer": "0.350.0",
27
+ "@cosmicdrift/kumiko-types": "0.350.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.349.0"
75
+ "@cosmicdrift/kumiko-locale-de": "0.350.0"
76
76
  },
77
77
  "repository": {
78
78
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.350.0",
4
+ "type": "improvement",
5
+ "title": "Row actions with display \"responsive\", Lightbox actions and showPosition, navigate with searchParams, facet counts refresh after writes",
6
+ "detail": "Responsive row actions, Lightbox actions, navigate with search params, fresh facet counts\n`RowActionDisplay` gets `\"responsive\"`: a labelled button with icon in the table (768px and up), icon only in the narrow card layout with the label as accessible name. Without an icon it stays a button. Existing values keep their look. Below 768px, icon-only row actions and the row expand arrow have a 44px touch target.\n`Lightbox` takes `actions` (nodes in the top-left corner, e.g. a download button) and `showPosition` (default `true`; `false` hides the `{current} / {total}` counter).\n`NavApi.navigate`, `replace` and `hrefFor` take an optional `{ searchParams }` (`NavigateOptions`). Without it the query is dropped as before. `listFilterUrlKey(screenId, field)` builds the URL key a list reads a facet filter from.\nFacet chip counts refetch after a write from a row action, toolbar action, drawer or expanded row, and follow the entity's live events. Before, they kept the count from the first load."
7
+ },
2
8
  {
3
9
  "version": "0.349.0",
4
10
  "type": "improvement",