@cosmicdrift/kumiko-renderer 0.233.0 → 0.235.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.
@@ -0,0 +1,190 @@
1
+ import type { SecretsEditScreenDefinition } from "@cosmicdrift/kumiko-framework/ui-types";
2
+ import type { Translate } from "@cosmicdrift/kumiko-headless";
3
+ import { type ReactNode, useCallback, useMemo, useState } from "react";
4
+ import { useDispatcher } from "../context/dispatcher-context";
5
+ import { useQuery } from "../hooks/use-query";
6
+ import { useTranslation } from "../i18n";
7
+ import { usePrimitives } from "../primitives";
8
+ import { dispatcherErrorText } from "./write-failed-error";
9
+
10
+ type SecretListRow = {
11
+ readonly key: string;
12
+ readonly redactedPreview: string | null;
13
+ readonly hint: string | null;
14
+ };
15
+
16
+ export type SecretsEditBodyProps = {
17
+ readonly screen: SecretsEditScreenDefinition;
18
+ readonly translate?: Translate;
19
+ };
20
+
21
+ // secrets:query:list never returns plaintext (only a redacted preview), so
22
+ // unlike ConfigEditBody there is no server value to pre-fill a draft with —
23
+ // every input starts at "" and stays that way unless the user types into it.
24
+ export function SecretsEditBody({ screen, translate }: SecretsEditBodyProps): ReactNode {
25
+ const { Banner, Form, Section, Field, Input, Button, Text } = usePrimitives();
26
+ const t = useTranslation();
27
+ const effectiveTranslate = translate ?? t;
28
+ const dispatcher = useDispatcher();
29
+ const listQuery = useQuery<readonly SecretListRow[]>("secrets:query:list", {});
30
+
31
+ const [drafts, setDrafts] = useState<Readonly<Record<string, string>>>({});
32
+ const [submitting, setSubmitting] = useState(false);
33
+ const [submitError, setSubmitError] = useState<string | null>(null);
34
+
35
+ const rowsByQualifiedKey = useMemo(() => {
36
+ const out = new Map<string, SecretListRow>();
37
+ for (const row of listQuery.data ?? []) out.set(row.key, row);
38
+ return out;
39
+ }, [listQuery.data]);
40
+
41
+ const setDraft = useCallback((fieldId: string, value: string) => {
42
+ setDrafts((prev) => ({ ...prev, [fieldId]: value }));
43
+ }, []);
44
+
45
+ const handleSubmit = useCallback(async (): Promise<void> => {
46
+ const commands = Object.entries(screen.secretKeys).flatMap(([fieldId, qualified]) => {
47
+ const value = drafts[fieldId]?.trim();
48
+ return value ? [{ type: "secrets:write:set", payload: { key: qualified, value } }] : [];
49
+ });
50
+ if (commands.length === 0) return;
51
+ setSubmitting(true);
52
+ setSubmitError(null);
53
+ const result = await dispatcher.batch(commands);
54
+ setSubmitting(false);
55
+ if (!result.isSuccess) {
56
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
57
+ return;
58
+ }
59
+ setDrafts({});
60
+ await listQuery.refetch();
61
+ }, [dispatcher, drafts, screen.secretKeys, listQuery.refetch, effectiveTranslate]);
62
+
63
+ const handleDelete = useCallback(
64
+ async (qualified: string): Promise<void> => {
65
+ const result = await dispatcher.write("secrets:write:delete", { key: qualified });
66
+ if (!result.isSuccess) {
67
+ setSubmitError(dispatcherErrorText(result.error, effectiveTranslate));
68
+ return;
69
+ }
70
+ await listQuery.refetch();
71
+ },
72
+ [dispatcher, listQuery.refetch, effectiveTranslate],
73
+ );
74
+
75
+ if (listQuery.loading && listQuery.data === null) {
76
+ return (
77
+ <Banner padded variant="loading" testId="kumiko-screen-loading">
78
+ Loading…
79
+ </Banner>
80
+ );
81
+ }
82
+ if (listQuery.error) {
83
+ return (
84
+ <Banner padded variant="error" testId="kumiko-screen-error">
85
+ {dispatcherErrorText(listQuery.error, effectiveTranslate)}
86
+ </Banner>
87
+ );
88
+ }
89
+
90
+ return (
91
+ <Form
92
+ onSubmit={() => {
93
+ void handleSubmit();
94
+ }}
95
+ testId="secrets-edit-form"
96
+ actions={
97
+ <Button
98
+ type="submit"
99
+ variant="primary"
100
+ loading={submitting}
101
+ disabled={submitting}
102
+ testId="secrets-edit-submit"
103
+ >
104
+ {effectiveTranslate("kumiko.actions.save")}
105
+ </Button>
106
+ }
107
+ >
108
+ {submitError !== null && (
109
+ <Banner variant="error" testId="secrets-edit-error">
110
+ {submitError}
111
+ </Banner>
112
+ )}
113
+ {screen.sections.map((section, index) => (
114
+ <Section
115
+ key={section.title ?? `section-${index}`}
116
+ {...(section.title !== undefined && { title: effectiveTranslate(section.title) })}
117
+ >
118
+ {section.fields.map((fieldId) => {
119
+ const qualified = screen.secretKeys[fieldId];
120
+ if (qualified === undefined) return null;
121
+ const row = rowsByQualifiedKey.get(qualified);
122
+ const hintKey = screen.fieldHints?.[fieldId];
123
+ const isRequired = screen.requiredFields?.includes(fieldId) ?? false;
124
+ return (
125
+ <Field
126
+ key={fieldId}
127
+ id={fieldId}
128
+ label={effectiveTranslate(screen.fieldLabels[fieldId] ?? fieldId)}
129
+ required={isRequired}
130
+ testId={`field-${fieldId}`}
131
+ fieldAppendix={
132
+ <>
133
+ {hintKey !== undefined && (
134
+ <Text variant="small">{effectiveTranslate(hintKey)}</Text>
135
+ )}
136
+ {row !== undefined ? (
137
+ <>
138
+ <Text variant="small" testId={`secret-preview-${fieldId}`}>
139
+ {row.redactedPreview ?? effectiveTranslate("config.secrets.set")}
140
+ </Text>
141
+ <Button
142
+ type="button"
143
+ variant="danger-ghost"
144
+ size="sm"
145
+ onClick={() => handleDelete(qualified)}
146
+ testId={`secret-delete-${fieldId}`}
147
+ >
148
+ {effectiveTranslate("config.secrets.delete")}
149
+ </Button>
150
+ </>
151
+ ) : (
152
+ <>
153
+ <Text variant="small" testId={`secret-not-set-${fieldId}`}>
154
+ {effectiveTranslate("config.secrets.notSet")}
155
+ </Text>
156
+ {isRequired && (
157
+ <Text variant="small" testId={`required-marker-${fieldId}`}>
158
+ {effectiveTranslate("config.secrets.required")}
159
+ </Text>
160
+ )}
161
+ </>
162
+ )}
163
+ </>
164
+ }
165
+ >
166
+ <Text variant="small">
167
+ {effectiveTranslate(
168
+ row !== undefined
169
+ ? "config.secrets.replacePlaceholder"
170
+ : "config.secrets.placeholder",
171
+ )}
172
+ </Text>
173
+ <Input
174
+ kind="password"
175
+ id={fieldId}
176
+ name={fieldId}
177
+ value={drafts[fieldId] ?? ""}
178
+ onChange={(v) => setDraft(fieldId, v)}
179
+ autoComplete="new-password"
180
+ disabled={submitting}
181
+ testId={`secret-input-${fieldId}`}
182
+ />
183
+ </Field>
184
+ );
185
+ })}
186
+ </Section>
187
+ ))}
188
+ </Form>
189
+ );
190
+ }
@@ -0,0 +1,119 @@
1
+ // Fields without a declared icon derive one from the field name — mirrors
2
+ // ACTION_ICON_BY_ID/resolveActionIcon (kumiko-screen.tsx) for fields. Only
3
+ // kind:"text" (single-line) and kind:"number" structurally carry an icon
4
+ // prop, so that's what these tests assert against.
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import type { EditFieldViewModel } from "@cosmicdrift/kumiko-headless";
8
+ import { render } from "@testing-library/react";
9
+ import type { ComponentType, ReactNode } from "react";
10
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
11
+ import { type CorePrimitives, type InputProps, PrimitivesProvider } from "../../primitives";
12
+ import { RenderField } from "../render-field";
13
+
14
+ let captured: InputProps | undefined;
15
+ const captureInput: ComponentType<InputProps> = (props) => {
16
+ captured = props;
17
+ return null;
18
+ };
19
+ const noop = (): ReactNode => null;
20
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
21
+
22
+ const testPrimitives: CorePrimitives = {
23
+ Button: noop,
24
+ Banner: noop,
25
+ Field: passChildren,
26
+ Input: captureInput,
27
+ DataTable: noop,
28
+ Form: noop,
29
+ Section: noop,
30
+ Card: noop,
31
+ Grid: noop,
32
+ GridCell: noop,
33
+ Text: noop,
34
+ Heading: noop,
35
+ Dialog: noop,
36
+ Modal: noop,
37
+ Lightbox: noop,
38
+ ConfigSourceBadge: noop,
39
+ ConfigCascadeView: noop,
40
+ Link: noop,
41
+ };
42
+
43
+ function textField(overrides: Partial<EditFieldViewModel> = {}): EditFieldViewModel {
44
+ return {
45
+ field: "name",
46
+ label: "Name",
47
+ type: "text",
48
+ value: "",
49
+ visible: true,
50
+ readOnly: false,
51
+ required: false,
52
+ ...overrides,
53
+ };
54
+ }
55
+
56
+ function renderField(field: EditFieldViewModel): void {
57
+ captured = undefined;
58
+ render(
59
+ <LocaleProvider resolver={createStaticLocaleResolver({ locale: "en-US" })}>
60
+ <PrimitivesProvider value={testPrimitives}>
61
+ <RenderField field={field} onChange={() => {}} />
62
+ </PrimitivesProvider>
63
+ </LocaleProvider>,
64
+ );
65
+ }
66
+
67
+ describe("RenderField — icon derivation from field name/type", () => {
68
+ test("field named 'email' with no declared icon renders the mail icon", () => {
69
+ renderField(textField({ field: "email" }));
70
+ expect(captured?.kind).toBe("text");
71
+ if (captured?.kind === "text") expect(captured.icon).toBe("mail");
72
+ });
73
+
74
+ test("a declared field.icon overrides the derivation", () => {
75
+ renderField(textField({ field: "email", icon: "lock" }));
76
+ expect(captured?.kind).toBe("text");
77
+ if (captured?.kind === "text") expect(captured.icon).toBe("lock");
78
+ });
79
+
80
+ test("a generic text field with no recognizable name gets no icon", () => {
81
+ renderField(textField({ field: "foo" }));
82
+ expect(captured?.kind).toBe("text");
83
+ if (captured?.kind === "text") expect(captured.icon).toBeUndefined();
84
+ });
85
+
86
+ test("a boolean field gets no icon", () => {
87
+ renderField({
88
+ field: "isActive",
89
+ label: "Active",
90
+ type: "boolean",
91
+ value: false,
92
+ visible: true,
93
+ readOnly: false,
94
+ required: false,
95
+ });
96
+ expect(captured?.kind).toBe("boolean");
97
+ if (captured?.kind === "boolean") expect("icon" in captured).toBe(false);
98
+ });
99
+
100
+ test("a multiline field named 'email' gets no icon (textarea has no icon slot)", () => {
101
+ renderField(textField({ field: "email", multiline: true }));
102
+ expect(captured?.kind).toBe("textarea");
103
+ if (captured?.kind === "textarea") expect("icon" in captured).toBe(false);
104
+ });
105
+
106
+ test("a number field with no recognizable name gets no icon", () => {
107
+ renderField({
108
+ field: "quantity",
109
+ label: "Quantity",
110
+ type: "number",
111
+ value: 1,
112
+ visible: true,
113
+ readOnly: false,
114
+ required: false,
115
+ });
116
+ expect(captured?.kind).toBe("number");
117
+ if (captured?.kind === "number") expect(captured.icon).toBeUndefined();
118
+ });
119
+ });
@@ -0,0 +1,146 @@
1
+ // ToolbarActionView collapses a group of >2 icon-carrying toolbar actions to
2
+ // icon-only buttons (`shouldRenderActionsIconOnly`). The button then has no
3
+ // accessible name from its children, so the label has to move to `ariaLabel`
4
+ // and must NOT stay in the children — otherwise the text renders inside a
5
+ // button sized for an icon.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ EntityDefinition,
10
+ EntityListScreenDefinition,
11
+ } from "@cosmicdrift/kumiko-framework/ui-types";
12
+ import { render, screen as rtlScreen } from "@testing-library/react";
13
+ import type { ComponentType, ReactNode } from "react";
14
+ import { createStaticLocaleResolver, LocaleProvider } from "../../i18n";
15
+ import { kumikoDefaultTranslations } from "../../i18n-defaults";
16
+ import {
17
+ type ButtonProps,
18
+ type CorePrimitives,
19
+ type DataTableProps,
20
+ PrimitivesProvider,
21
+ } from "../../primitives";
22
+ import { RenderList, type ToolbarActionButton } from "../render-list";
23
+
24
+ const TestButton: ComponentType<ButtonProps> = ({ children, onClick, testId, ariaLabel, size }) => (
25
+ <button
26
+ type="button"
27
+ data-testid={testId}
28
+ data-size={size ?? "md"}
29
+ aria-label={ariaLabel}
30
+ onClick={() => {
31
+ void onClick?.();
32
+ }}
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+
38
+ const toolbarOnlyDataTable: ComponentType<DataTableProps> = ({ toolbarEnd }) => (
39
+ <div>{toolbarEnd}</div>
40
+ );
41
+
42
+ const noop = (): ReactNode => null;
43
+ const passChildren = ({ children }: { readonly children?: ReactNode }): ReactNode => children;
44
+
45
+ const testPrimitives: CorePrimitives = {
46
+ Button: TestButton,
47
+ Banner: passChildren,
48
+ Field: passChildren,
49
+ Input: noop,
50
+ DataTable: toolbarOnlyDataTable,
51
+ Form: passChildren,
52
+ Section: passChildren,
53
+ Card: passChildren,
54
+ Grid: passChildren,
55
+ GridCell: passChildren,
56
+ Text: passChildren,
57
+ Heading: noop,
58
+ Dialog: noop,
59
+ Modal: noop,
60
+ Lightbox: noop,
61
+ ConfigSourceBadge: noop,
62
+ ConfigCascadeView: noop,
63
+ Link: noop,
64
+ };
65
+
66
+ const entity: EntityDefinition = {
67
+ fields: {
68
+ name: { type: "text", maxLength: 50, required: false, searchable: false, sortable: false },
69
+ },
70
+ };
71
+
72
+ const listScreen: EntityListScreenDefinition = {
73
+ id: "widget-list",
74
+ type: "entityList",
75
+ entity: "widget",
76
+ columns: ["name"],
77
+ };
78
+
79
+ function toolbarAction(id: string, label: string, withIcon: boolean): ToolbarActionButton {
80
+ return {
81
+ id,
82
+ label,
83
+ onTrigger: () => {},
84
+ ...(withIcon && { icon: "archive" as const }),
85
+ };
86
+ }
87
+
88
+ function renderToolbar(actions: readonly ToolbarActionButton[]): void {
89
+ render(
90
+ <LocaleProvider
91
+ resolver={createStaticLocaleResolver({ locale: "en" })}
92
+ fallbackBundles={[kumikoDefaultTranslations]}
93
+ >
94
+ <PrimitivesProvider value={testPrimitives}>
95
+ <RenderList
96
+ screen={listScreen}
97
+ entity={entity}
98
+ rows={[]}
99
+ featureName="toolbar-fixture"
100
+ toolbarActions={actions}
101
+ />
102
+ </PrimitivesProvider>
103
+ </LocaleProvider>,
104
+ );
105
+ }
106
+
107
+ describe("RenderList toolbar actions collapse to icon-only", () => {
108
+ test("three icon actions render buttons with no visible text but an aria-label", () => {
109
+ renderToolbar([
110
+ toolbarAction("sync", "Sync", true),
111
+ toolbarAction("export", "Export", true),
112
+ toolbarAction("archive", "Archive", true),
113
+ ]);
114
+
115
+ for (const [id, label] of [
116
+ ["sync", "Sync"],
117
+ ["export", "Export"],
118
+ ["archive", "Archive"],
119
+ ] as const) {
120
+ const button = rtlScreen.getByTestId(`render-list-toolbar-action-${id}`);
121
+ expect(button.getAttribute("data-size")).toBe("icon");
122
+ expect(button.getAttribute("aria-label")).toBe(label);
123
+ expect(button.textContent).toBe("");
124
+ }
125
+ });
126
+
127
+ test("two actions stay on text buttons carrying the label as children", () => {
128
+ renderToolbar([toolbarAction("sync", "Sync", true), toolbarAction("export", "Export", true)]);
129
+
130
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
131
+ expect(button.getAttribute("data-size")).toBe("md");
132
+ expect(button.textContent).toBe("Sync");
133
+ });
134
+
135
+ test("an icon-less member keeps the whole group on text buttons", () => {
136
+ renderToolbar([
137
+ toolbarAction("sync", "Sync", true),
138
+ toolbarAction("export", "Export", true),
139
+ toolbarAction("archive", "Archive", false),
140
+ ]);
141
+
142
+ const button = rtlScreen.getByTestId("render-list-toolbar-action-sync");
143
+ expect(button.getAttribute("data-size")).toBe("md");
144
+ expect(button.textContent).toBe("Sync");
145
+ });
146
+ });
@@ -93,26 +93,36 @@ export function RelatedListSection({
93
93
  }
94
94
  : undefined;
95
95
 
96
+ const content =
97
+ rowsQuery.loading && rowsQuery.data === null ? (
98
+ <Banner padded variant="loading" testId="related-list-loading">
99
+ Loading…
100
+ </Banner>
101
+ ) : rowsQuery.error ? (
102
+ <Banner padded variant="error" testId="related-list-error">
103
+ {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
104
+ </Banner>
105
+ ) : (
106
+ <RenderList
107
+ screen={listScreen}
108
+ entity={entity}
109
+ rows={rowsQuery.data?.rows ?? []}
110
+ featureName={featureName}
111
+ translate={effectiveTranslate}
112
+ {...(onRowClick !== undefined && { onRowClick })}
113
+ />
114
+ );
115
+
116
+ // hideTitle (tabs mode) → this section carries no header content of its
117
+ // own, and RenderList's DataTable already draws its own card frame — a
118
+ // Section wrapper here would only add a second, nested card (fw record-
119
+ // screen-type polish). Stacked (non-tabs) sections keep their Section
120
+ // card since they render a visible title.
121
+ if (hideTitle) return content;
122
+
96
123
  return (
97
- <Section title={hideTitle ? undefined : section.title} testId={`related-list-${section.title}`}>
98
- {rowsQuery.loading && rowsQuery.data === null ? (
99
- <Banner padded variant="loading" testId="related-list-loading">
100
- Loading…
101
- </Banner>
102
- ) : rowsQuery.error ? (
103
- <Banner padded variant="error" testId="related-list-error">
104
- {dispatcherErrorText(rowsQuery.error, effectiveTranslate)}
105
- </Banner>
106
- ) : (
107
- <RenderList
108
- screen={listScreen}
109
- entity={entity}
110
- rows={rowsQuery.data?.rows ?? []}
111
- featureName={featureName}
112
- translate={effectiveTranslate}
113
- {...(onRowClick !== undefined && { onRowClick })}
114
- />
115
- )}
124
+ <Section title={section.title} testId={`related-list-${section.title}`}>
125
+ {content}
116
126
  </Section>
117
127
  );
118
128
  }
@@ -9,11 +9,15 @@ import type { RenderEditAction } from "./render-edit-types";
9
9
  // to hook into like the built-in onDelete/onSubmit paths have).
10
10
  export function RenderEditActionButton({
11
11
  action,
12
+ iconOnly = false,
12
13
  Button,
13
14
  Dialog,
14
15
  onError,
15
16
  }: {
16
17
  readonly action: RenderEditAction;
18
+ /** Group-level collapse (see `shouldRenderActionsIconOnly`) — only takes
19
+ * effect when this action actually resolved an icon. */
20
+ readonly iconOnly?: boolean;
17
21
  readonly Button: ReturnType<typeof usePrimitives>["Button"];
18
22
  readonly Dialog: ReturnType<typeof usePrimitives>["Dialog"];
19
23
  readonly onError: (text: string | null) => void;
@@ -37,6 +41,7 @@ export function RenderEditActionButton({
37
41
  // Same rule as RowActionWriteHandler: "danger" forces a confirm even
38
42
  // without an explicit confirm key.
39
43
  const needsConfirm = action.confirm !== undefined || action.style === "danger";
44
+ const showIconOnly = iconOnly && action.icon !== undefined;
40
45
 
41
46
  return (
42
47
  <>
@@ -44,6 +49,8 @@ export function RenderEditActionButton({
44
49
  type="button"
45
50
  variant={variant}
46
51
  loading={busy}
52
+ {...(action.icon !== undefined && { icon: action.icon })}
53
+ {...(showIconOnly && { size: "icon" as const, ariaLabel: action.label })}
47
54
  onClick={() => {
48
55
  if (needsConfirm) {
49
56
  setConfirmOpen(true);
@@ -53,7 +60,7 @@ export function RenderEditActionButton({
53
60
  }}
54
61
  testId={`render-edit-action-${action.id}`}
55
62
  >
56
- {action.label}
63
+ {showIconOnly ? null : action.label}
57
64
  </Button>
58
65
  <Dialog
59
66
  open={confirmOpen}
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  EntityDefinition,
3
3
  EntityEditScreenDefinition,
4
+ IconKey,
4
5
  } from "@cosmicdrift/kumiko-framework/ui-types";
5
6
  import type {
6
7
  FormSnapshot,
@@ -156,6 +157,10 @@ export type RenderEditAction = {
156
157
  readonly style?: "primary" | "secondary" | "danger";
157
158
  readonly confirm?: string;
158
159
  readonly confirmLabel?: string;
160
+ /** Resolved icon (author `RowAction.icon` or the id-derived default) —
161
+ * drives both the icon-left-of-text render and the icon-only collapse
162
+ * rule (see `shouldRenderActionsIconOnly`). */
163
+ readonly icon?: IconKey;
159
164
  };
160
165
 
161
166
  export type RenderEditChangeState<TValues extends FormValues> = {
@@ -36,7 +36,7 @@ import { useDraftStorage } from "../context/draft-storage-context";
36
36
  import { formatWhen } from "../format-when";
37
37
  import { useForm } from "../hooks/use-form";
38
38
  import { useTranslation } from "../i18n";
39
- import { usePrimitives } from "../primitives";
39
+ import { shouldRenderActionsIconOnly, usePrimitives } from "../primitives";
40
40
  import { RelatedListSection } from "./related-list-section";
41
41
  import {
42
42
  filterEditSections,
@@ -922,6 +922,10 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
922
922
  onCopyLink !== undefined ||
923
923
  (actions !== undefined && actions.length > 0) ||
924
924
  onCancel !== undefined;
925
+ // Teil-C collapse rule: driven by the custom `actions` group only — a
926
+ // resolved true also folds the Copy-Link button (same visual group in the
927
+ // footer), but never Delete/Cancel, which keep their text regardless.
928
+ const iconOnlyMidActions = actions !== undefined && shouldRenderActionsIconOnly(actions);
925
929
  const secondaryFormActions = (
926
930
  <>
927
931
  {onDelete !== undefined && (
@@ -939,21 +943,30 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
939
943
  {onCopyLink !== undefined && (
940
944
  <Button
941
945
  type="button"
942
- variant="link"
946
+ variant={iconOnlyMidActions ? "secondary" : "link"}
943
947
  icon={linkCopied ? "check" : "link"}
944
948
  testId="render-edit-copy-link"
949
+ {...(iconOnlyMidActions && {
950
+ size: "icon" as const,
951
+ ariaLabel: translate(
952
+ linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink",
953
+ ),
954
+ })}
945
955
  onClick={async () => {
946
956
  await onCopyLink();
947
957
  setLinkCopied(true);
948
958
  }}
949
959
  >
950
- {translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
960
+ {iconOnlyMidActions
961
+ ? null
962
+ : translate(linkCopied ? "kumiko.actions.copyLinkCopied" : "kumiko.actions.copyLink")}
951
963
  </Button>
952
964
  )}
953
965
  {actions?.map((action) => (
954
966
  <RenderEditActionButton
955
967
  key={action.id}
956
968
  action={action}
969
+ iconOnly={iconOnlyMidActions}
957
970
  Button={Button}
958
971
  Dialog={Dialog}
959
972
  onError={setActionError}
@@ -972,6 +985,14 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
972
985
  )}
973
986
  </>
974
987
  );
988
+ // Mirrors every branch inside formActions below — without this guard
989
+ // DefaultForm renders an empty footer strip (border + padding, no content)
990
+ // on read-only detail screens, since `actions` would otherwise always be
991
+ // a defined (if empty) fragment.
992
+ const hasFormActions =
993
+ (isWizard && currentStep > 0) ||
994
+ (isWizard && !isLastWizardStep) ||
995
+ ((isFormEditable || hasExtensionRegistrations) && (!isWizard || isLastWizardStep));
975
996
  const formActions = (
976
997
  <>
977
998
  {isWizard && currentStep > 0 && (
@@ -1039,7 +1060,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1039
1060
  {...(hideSectionTitles !== true && { title: formTitle })}
1040
1061
  {...(hideSectionTitles !== true &&
1041
1062
  formSubtitle !== undefined && { subtitle: formSubtitle })}
1042
- {...(hideActions !== true && { actions: formActions })}
1063
+ {...(hideActions !== true && hasFormActions && { actions: formActions })}
1043
1064
  {...(hideActions !== true &&
1044
1065
  hasSecondaryFormActions && { secondaryActions: secondaryFormActions })}
1045
1066
  testId="render-edit-form"
@@ -1206,6 +1227,7 @@ export function RenderEdit<TValues extends FormValues, TCtx = unknown>(
1206
1227
  key={sectionKey}
1207
1228
  {...(sectionTitle !== undefined && { title: sectionTitle })}
1208
1229
  {...(section.description !== undefined && { subtitle: section.description })}
1230
+ {...(section.icon !== undefined && { icon: section.icon })}
1209
1231
  testId={`section-${sectionKey}`}
1210
1232
  >
1211
1233
  <Grid columns={section.columns}>