Skip to content

Commit 2413432

Browse files
IzumiSyCopilot
andauthored
feat(action-panel): support custom empty content (#632)
* feat(action-panel): support custom empty content * fix(action-panel): restore action row hover padding * fix(action-panel): style and localize empty messages --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: IzumiSy <982850+IzumiSy@users.noreply.github.com>
1 parent 1411958 commit 2413432

9 files changed

Lines changed: 279 additions & 61 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
Add an optional `emptyContent` prop to `ActionPanel` for localized strings or custom React nodes.
6+
Align the title, action icons, and empty content while preserving horizontal padding around action hover backgrounds and focus rings.
7+
Omitting `emptyContent` shows the default message in the AppShell locale (English or Japanese); custom strings use the same muted body styling. Passing `null` hides the content and its additional spacing while keeping the card and title.
8+
9+
```tsx
10+
<ActionPanel title="操作" actions={[]} emptyContent={<p>利用可能な操作はありません</p>} />
11+
```

‎docs-manifest.json‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
"kind": "code-backed",
77
"outline": "docs-src/components/action-panel.docs.outline.md",
88
"output": "docs/components/action-panel.md",
9-
"examples": null,
9+
"examples": "docs-src/components/action-panel.docs.examples.tsx",
1010
"sources": ["packages/core/src/components/action-panel/**"],
1111
"claims": [],
1212
"symbols": ["ActionPanel", "ActionPanelProps"],
1313
"hashes": {
1414
"typeSurface": "f497e4e3021d029a",
15-
"outline": "a2d7daced73b465e",
15+
"outline": "2e5fe6fdb0636b7c",
1616
"snapshot": null,
17-
"outputMd": "71794d4b561e4d4b",
18-
"examples": null
17+
"outputMd": "dc861082aa9610af",
18+
"examples": "79d8f5c1ae3099a3"
1919
}
2020
},
2121
"activity-card": {
@@ -1770,7 +1770,7 @@
17701770
"packages/core/skills/app-shell-patterns/references/concepts/routing-navigation.md": "8034c7f885994b29",
17711771
"packages/core/skills/app-shell-patterns/references/concepts/sidebar-navigation.md": "5a3c8bac248061f9",
17721772
"packages/core/skills/app-shell-patterns/references/concepts/styling-theming.md": "755e362908623d2c",
1773-
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "71794d4b561e4d4b",
1773+
"packages/core/skills/app-shell-patterns/references/components/action-panel.md": "dc861082aa9610af",
17741774
"packages/core/skills/app-shell-patterns/references/components/activity-card.md": "4f386e49380fb0b0",
17751775
"packages/core/skills/app-shell-patterns/references/components/ai-chat.md": "824d1aa36bb7f194",
17761776
"packages/core/skills/app-shell-patterns/references/components/alert.md": "db2c4388ca783a75",
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { ActionPanel } from "@tailor-platform/app-shell";
2+
import { ExternalLink, Receipt } from "lucide-react";
3+
4+
export function BasicUsage() {
5+
return (
6+
<ActionPanel
7+
title="Actions"
8+
actions={[
9+
{
10+
key: "create-invoice",
11+
label: "Create new sales invoice",
12+
icon: <Receipt />,
13+
onClick: () => {},
14+
},
15+
{
16+
key: "view-docs",
17+
label: "View documentation",
18+
icon: <ExternalLink />,
19+
onClick: () => {},
20+
},
21+
]}
22+
/>
23+
);
24+
}
25+
26+
export function CustomEmptyContent() {
27+
return (
28+
<ActionPanel
29+
title="操作"
30+
actions={[]}
31+
emptyContent={
32+
<div>
33+
<p>利用可能な操作はありません</p>
34+
<p>ステータスが変わると、操作が表示されます。</p>
35+
</div>
36+
}
37+
/>
38+
);
39+
}

‎docs-src/components/action-panel.docs.outline.md‎

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,7 @@ import { ActionPanel } from "@tailor-platform/app-shell";
1919

2020
## Basic Usage
2121

22-
```tsx
23-
<ActionPanel
24-
title="Actions"
25-
actions={[
26-
{
27-
key: "create-invoice",
28-
label: "Create new sales invoice",
29-
icon: <ReceiptIcon />,
30-
onClick: () => openCreateInvoiceModal(),
31-
},
32-
{
33-
key: "view-docs",
34-
label: "View documentation",
35-
icon: <ExternalLinkIcon />,
36-
onClick: () => window.open("/docs", "_blank", "noopener,noreferrer"),
37-
},
38-
]}
39-
/>
40-
```
22+
<!-- example: basic-usage -->
4123

4224
<!-- api -->
4325

@@ -58,7 +40,15 @@ import { ActionPanel } from "@tailor-platform/app-shell";
5840
- Rows are rendered as `<button type="button">` elements.
5941
- `disabled` or `loading` makes the row non-interactive.
6042
- When `loading` is `true`, the icon slot renders a spinner without shifting layout.
61-
- Empty actions show a built-in fallback message: `No actions available`.
43+
- Empty actions show `emptyContent`, which defaults to a styled message localized using the AppShell locale: `No actions available` in English or `利用可能なアクションはありません` in Japanese.
44+
45+
## Custom Empty Content
46+
47+
Pass a string or any `ReactNode` to `emptyContent` to replace the default content when `actions` is empty. Strings use the same muted `Text` body styling as the default message. Other React nodes render directly inside `Card.Content` without a paragraph wrapper and control their own typography. `Card.Content` owns the spacing, and the title, empty content, and action icons share the same 32px horizontal inset. Action rows retain horizontal padding around their hover backgrounds and focus rings. Custom empty content is ignored when actions are present.
48+
49+
<!-- example: custom-empty-content -->
50+
51+
Pass `null` to hide the empty content and its additional vertical spacing while keeping the card and its title. To hide the entire panel when no actions are available, render it conditionally with `actions.length > 0`.
6252

6353
## With Loading State
6454

‎docs/components/action-panel.md‎

Lines changed: 55 additions & 23 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/core/src/components/action-panel/ActionPanel.test.tsx‎

Lines changed: 129 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react";
33
import userEvent from "@testing-library/user-event";
44
import { MemoryRouter } from "react-router";
55
import { ActionPanel } from "./ActionPanel";
6+
import { AppShellConfigContext, buildConfigurations } from "../../contexts/appshell-context";
67
import {
78
CommandPaletteProvider,
89
useCommandPaletteActions,
@@ -20,8 +21,15 @@ afterEach(() => {
2021
cleanup();
2122
});
2223

23-
const renderActionPanel = (props: React.ComponentProps<typeof ActionPanel>) =>
24-
render(<ActionPanel {...props} />, { wrapper });
24+
const renderActionPanel = (props: React.ComponentProps<typeof ActionPanel>, locale = "en") =>
25+
render(
26+
<AppShellConfigContext.Provider
27+
value={{ configurations: buildConfigurations({ modules: [], locale }) }}
28+
>
29+
<ActionPanel {...props} />
30+
</AppShellConfigContext.Provider>,
31+
{ wrapper },
32+
);
2533

2634
describe("ActionPanel", () => {
2735
it("renders title", () => {
@@ -33,7 +41,110 @@ describe("ActionPanel", () => {
3341

3442
it("renders empty state when no actions", () => {
3543
renderActionPanel({ title: "Actions", actions: [] });
36-
expect(screen.getByText("No actions available")).toBeDefined();
44+
const message = screen.getByText("No actions available");
45+
expect(message.className).toContain("astw:text-body-md");
46+
expect(message.parentElement?.getAttribute("data-slot")).toBe("card-content");
47+
expect(message.parentElement?.className).toContain("astw:px-8");
48+
expect(message.parentElement?.className).toContain("astw:pt-2");
49+
expect(message.parentElement?.className).toContain("astw:pb-6");
50+
});
51+
52+
it.each([
53+
["en", "No actions available"],
54+
["ja", "利用可能なアクションはありません"],
55+
["fr", "No actions available"],
56+
])("localizes the default empty content for %s", (locale, message) => {
57+
renderActionPanel({ title: "Actions", actions: [], emptyContent: undefined }, locale);
58+
59+
const content = screen.getByText(message);
60+
expect(content.className).toContain("astw:text-body-md");
61+
expect(content.className).toContain("astw:text-muted-foreground");
62+
});
63+
64+
it.each(["en", "ja"])("styles a custom string instead of the %s default", (locale) => {
65+
renderActionPanel(
66+
{
67+
title: "操作",
68+
actions: [],
69+
emptyContent: "利用可能な操作はありません",
70+
},
71+
locale,
72+
);
73+
74+
const content = screen.getByText("利用可能な操作はありません");
75+
expect(content.tagName).toBe("P");
76+
expect(content.className).toContain("astw:text-body-md");
77+
expect(content.className).toContain("astw:text-muted-foreground");
78+
expect(content.parentElement?.getAttribute("data-slot")).toBe("card-content");
79+
expect(content.parentElement?.className).toContain("astw:px-8");
80+
expect(content.parentElement?.className).toContain("astw:pt-2");
81+
expect(content.parentElement?.className).toContain("astw:pb-6");
82+
expect(screen.queryByText("No actions available")).toBeNull();
83+
expect(screen.queryByText("利用可能なアクションはありません")).toBeNull();
84+
});
85+
86+
it("renders custom JSX without a paragraph wrapper", () => {
87+
renderActionPanel({
88+
title: "操作",
89+
actions: [],
90+
emptyContent: (
91+
<div data-testid="custom-empty-state">
92+
<p>利用可能な操作はありません</p>
93+
<a href="/help/actions">操作について</a>
94+
</div>
95+
),
96+
});
97+
98+
expect(screen.getByText("利用可能な操作はありません")).toBeDefined();
99+
expect(screen.getByRole("link", { name: "操作について" }).getAttribute("href")).toBe(
100+
"/help/actions",
101+
);
102+
const customContent = screen.getByTestId("custom-empty-state");
103+
expect(customContent.closest("p")).toBeNull();
104+
expect(customContent.parentElement?.getAttribute("data-slot")).toBe("card-content");
105+
expect(customContent.parentElement?.className).toContain("astw:px-8");
106+
expect(customContent.parentElement?.className).toContain("astw:pt-2");
107+
expect(customContent.parentElement?.className).toContain("astw:pb-6");
108+
expect(screen.queryByText("No actions available")).toBeNull();
109+
});
110+
111+
it.each([null, false, true])("renders no empty-state padding for %j", (emptyContent) => {
112+
const { container } = renderActionPanel({
113+
title: "Actions",
114+
actions: [],
115+
emptyContent,
116+
});
117+
118+
expect(screen.queryByText("No actions available")).toBeNull();
119+
const content = container.querySelector('[data-slot="card-content"]');
120+
expect(content?.textContent).toBe("");
121+
expect(content?.childElementCount).toBe(0);
122+
expect(content?.className).toContain("astw:px-8");
123+
expect(content?.className).toContain("astw:pb-4");
124+
expect(content?.className).not.toContain("astw:pt-2");
125+
expect(screen.getByRole("heading", { name: "Actions" })).toBeDefined();
126+
});
127+
128+
it("does not fall back to the default for an empty string", () => {
129+
renderActionPanel({ title: "Actions", actions: [], emptyContent: "" });
130+
expect(screen.queryByText("No actions available")).toBeNull();
131+
});
132+
133+
it("renders zero as custom content", () => {
134+
renderActionPanel({ title: "Actions", actions: [], emptyContent: 0 });
135+
expect(screen.getByText("0").getAttribute("data-slot")).toBe("card-content");
136+
});
137+
138+
it("does not render empty content when actions are present", () => {
139+
renderActionPanel({
140+
title: "Actions",
141+
actions: [{ key: "1", label: "Create invoice", icon: <MockIcon /> }],
142+
emptyContent: <div>利用可能な操作はありません</div>,
143+
});
144+
145+
expect(screen.getByRole("button", { name: "Create invoice" })).toBeDefined();
146+
expect(screen.queryByText("利用可能な操作はありません")).toBeNull();
147+
expect(screen.queryByText("No actions available")).toBeNull();
37148
});
38149

39150
it("renders action list with buttons when onClick is provided", () => {
@@ -46,9 +157,21 @@ describe("ActionPanel", () => {
46157
},
47158
{ key: "2", label: "Edit order", icon: <MockIcon />, onClick: () => {} },
48159
];
49-
renderActionPanel({ title: "Actions", actions });
50-
51-
expect(screen.getByRole("button", { name: "Create invoice" })).toBeDefined();
160+
const { container } = renderActionPanel({ title: "Actions", actions });
161+
162+
const button = screen.getByRole("button", { name: "Create invoice" });
163+
expect(button.className).toContain("astw:px-3");
164+
expect(screen.getByRole("list").className).toContain("astw:-mx-3");
165+
expect(screen.getByRole("list").className).toContain("astw:my-0");
166+
expect(screen.getByRole("list").className).not.toContain("astw:m-0");
167+
expect(button.className).toContain("astw:py-2");
168+
expect(button.className).toContain("astw:border-0");
169+
expect(button.className).toContain("astw:text-label-md");
170+
expect(container.querySelector('[data-slot="card-header"]')?.className).toContain("astw:px-8");
171+
const content = container.querySelector('[data-slot="card-content"]');
172+
expect(content?.className).toContain("astw:px-8");
173+
expect(content?.className).toContain("astw:pb-4");
174+
expect(content?.className).not.toContain("astw:pt-2");
52175
expect(screen.getByRole("button", { name: "Edit order" })).toBeDefined();
53176
expect(screen.getAllByTestId("mock-icon").length).toBe(2);
54177
});

0 commit comments

Comments
 (0)