Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/fifty-carrots-beam.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"chat": minor
"@chat-adapter/slack": minor
"@chat-adapter/teams": minor
"@chat-adapter/gchat": minor
---

Add optional stack, columns, and carousel layout hints to sections, including JSX and React conversion. Slack, Teams, and Google Chat render eligible groups natively; unsupported groups retain the existing vertical rendering. Preserve nested controls and callback data in native layouts and fallbacks.
4 changes: 4 additions & 0 deletions apps/docs/content/docs/api/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ Section([
])
```

`Section(children, options?)` accepts `options.layout` as `"stack"`, `"columns"`, or `"carousel"`. The JSX equivalent is `<Section layout="columns">`. Each child section is a column or carousel item. `SectionElement.layout`, `SectionOptions`, and `SectionLayout` carry this hint through JSON and JSX/React conversion.

Adapters use native layouts only when the group's content and platform limits permit them. Other groups use the existing vertical section rendering. See [layout hints](/docs/cards#layout-hints) for adapter behavior. Controls retain their existing platform-specific submission behavior and limits.

## Fields

Renders key-value pairs in a compact, multi-column layout.
Expand Down
31 changes: 31 additions & 0 deletions apps/docs/content/docs/cards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,37 @@ Groups related content together.
</Section>
```

### Layout hints

Pass an optional layout hint to `Section`, with one child `Section` per column or carousel item:

```tsx
<Section layout="columns">
<Section><CardText>First column</CardText></Section>
<Section><CardText>Second column</CardText></Section>
</Section>
```

The function API accepts the same hint as its second argument:

```ts
Section([
Section([CardText("First column")]),
Section([CardText("Second column")]),
], { layout: "columns" });
```

Use `layout: "carousel"` for a sequence of items, or `"stack"` for the default vertical grouping. Layout hints do not change action IDs, values, or handlers.

| Adapter | Columns | Carousel |
| --- | --- | --- |
| Slack | Native layout when each item renders as one eligible block, up to 15 items | Native carousel for up to 10 simple items that fit Slack's card fields and limits |
| Teams | Native `ColumnSet`, with actions kept inside their column | Vertical sections |
| Google Chat | Native columns for up to 2 items containing supported widgets | Native carousel for items containing text, images, and buttons |
| Other adapters | Existing vertical rendering | Existing vertical rendering |

If the children do not fit a platform's native layout, the adapter uses its existing vertical rendering for the complete group. This includes complex Slack carousel items and Google Chat carousel items containing selectors. Existing platform message and control limits still apply. On narrow screens, a platform can stack otherwise eligible columns.

### Fields

Renders key-value pairs in a compact layout.
Expand Down
43 changes: 43 additions & 0 deletions packages/adapter-discord/src/layout-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { layoutFallbackCard } from "../../tests/src/layout-fallback.fixture";
import { cardToDiscordPayload } from "./cards";
import { DiscordContentFormat } from "./types";

describe("portable section layout fallbacks", () => {
for (const contentFormat of [
DiscordContentFormat.Embeds,
DiscordContentFormat.ComponentsV2,
]) {
it.each([
"columns",
"carousel",
] as const)(`preserves %s text and controls in ${contentFormat}`, (layout) => {
const output = cardToDiscordPayload(layoutFallbackCard(layout, true), {
contentFormat,
});
expect(output).toEqual(
cardToDiscordPayload(layoutFallbackCard(undefined, true), {
contentFormat,
})
);
const payload = JSON.stringify(output);
for (const content of [
"First column",
"First details",
"Second column",
"approve",
"v1",
"https://example.com/docs",
]) {
expect(payload).toContain(content);
}
if (contentFormat === DiscordContentFormat.ComponentsV2) {
expect(payload).toContain("priority");
expect(payload).toContain("region");
}
expect(payload.indexOf("First column")).toBeLessThan(
payload.indexOf("Second column")
);
});
}
});
193 changes: 193 additions & 0 deletions packages/adapter-gchat/src/cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,196 @@ describe("cardToGoogleCard with CardLink", () => {
});
});
});

describe("portable section layouts", () => {
it("renders two native columns and preserves select and action callbacks", () => {
const card = Card({
children: [
Section(
[
Section([
CardText("First"),
Actions([
Select({
id: "environment",
label: "Environment",
options: [
SelectOption({ label: "Production", value: "production" }),
],
}),
Button({ id: "deploy", label: "Deploy", value: "web" }),
]),
]),
Section([
Image({ url: "https://example.com/image.png", alt: "Preview" }),
Actions([
LinkButton({ label: "Docs", url: "https://example.com/docs" }),
]),
]),
],
{ layout: "columns" }
),
],
});
const widgets = cardToGoogleCard(card, {
endpointUrl: "https://example.com/events",
}).card.sections[0].widgets;
expect(widgets).toHaveLength(1);
expect(widgets[0].columns?.columnItems).toMatchObject([
{
horizontalSizeStyle: "FILL_AVAILABLE_SPACE",
widgets: [
{ textParagraph: { text: "First" } },
{
selectionInput: {
name: "environment",
onChangeAction: { function: "https://example.com/events" },
},
},
{
buttonList: {
buttons: [
{
onClick: {
action: {
function: "https://example.com/events",
parameters: [
{ key: "actionId", value: "deploy" },
{ key: "value", value: "web" },
],
},
},
},
],
},
},
],
},
{
widgets: [
{ image: { altText: "Preview" } },
{
buttonList: {
buttons: [
{ onClick: { openLink: { url: "https://example.com/docs" } } },
],
},
},
],
},
]);
});

it("stacks all three columns rather than truncating to the platform limit", () => {
const sections = ["one", "two", "three"].map((id) =>
Section([CardText(id), Actions([Button({ id, label: id })])])
);
const widgets = cardToGoogleCard(
Card({ children: [Section(sections, { layout: "columns" })] })
).card.sections[0].widgets;
expect(
widgets.map((widget) => widget.textParagraph?.text).filter(Boolean)
).toEqual(["one", "two", "three"]);
expect(widgets.filter((widget) => widget.buttonList)).toHaveLength(3);
expect(widgets.some((widget) => widget.columns)).toBe(false);
});

it("renders a native carousel with text, images, and buttons", () => {
const card = Card({
children: [
Section(
[
Section([
CardText("First"),
Actions([Button({ id: "first", label: "Choose first" })]),
]),
Section([
CardText("Second"),
Image({
url: "https://example.com/image.png",
alt: "Second item",
}),
]),
],
{ layout: "carousel" }
),
],
});
const widgets = cardToGoogleCard(card).card.sections[0].widgets;
expect(widgets).toMatchObject([
{
carousel: {
carouselCards: [
{
widgets: [
{ textParagraph: { text: "First" } },
{
buttonList: {
buttons: [{ onClick: { action: { function: "first" } } }],
},
},
],
},
{
widgets: [
{ textParagraph: { text: "Second" } },
{ image: { altText: "Second item" } },
],
},
],
},
},
]);
});

it("stacks a carousel with selections that native carousel items cannot hold", () => {
const card = Card({
children: [
Section(
[
Section([
CardText("First"),
Actions([
Select({
id: "choice",
label: "Choose",
options: [SelectOption({ label: "Yes", value: "yes" })],
}),
]),
]),
Section([CardText("Second")]),
],
{ layout: "carousel" }
),
],
});
const widgets = cardToGoogleCard(card).card.sections[0].widgets;
expect(widgets).toMatchObject([
{ textParagraph: { text: "First" } },
{ selectionInput: { name: "choice" } },
{ textParagraph: { text: "Second" } },
]);
expect(widgets.some((widget) => widget.carousel)).toBe(false);
});

it("does not nest unsupported widgets in native columns", () => {
const card = Card({
children: [
Section(
[
Section([CardText("Before"), Divider(), CardText("After")]),
Section([CardText("Second")]),
],
{ layout: "columns" }
),
],
});
const widgets = cardToGoogleCard(card).card.sections[0].widgets;
expect(widgets).toMatchObject([
{ textParagraph: { text: "Before" } },
{ divider: {} },
{ textParagraph: { text: "After" } },
{ textParagraph: { text: "Second" } },
]);
});
});
Loading