diff --git a/.changeset/teams-select-support.md b/.changeset/teams-select-support.md
new file mode 100644
index 00000000..965af6fd
--- /dev/null
+++ b/.changeset/teams-select-support.md
@@ -0,0 +1,5 @@
+---
+"@chat-adapter/teams": minor
+---
+
+Add Select and RadioSelect support for Teams Adaptive Cards with auto-submit fan-out
diff --git a/examples/nextjs-chat/src/lib/bot.tsx b/examples/nextjs-chat/src/lib/bot.tsx
index be401533..17bfda99 100644
--- a/examples/nextjs-chat/src/lib/bot.tsx
+++ b/examples/nextjs-chat/src/lib/bot.tsx
@@ -117,6 +117,7 @@ bot.onNewMention(async (thread, message) => {
+
@@ -310,6 +311,43 @@ bot.onAction("plan_selected", (event) => {
);
});
+bot.onAction("preferences", (event) => {
+ if (!event.thread) {
+ return;
+ }
+ event.thread.post(
+
+ Choose your theme and notification settings:
+
+
+
+
+
+
+
+
+
+ );
+});
+
+bot.onAction("theme_selected", (event) => {
+ if (!event.thread) {
+ return;
+ }
+ event.thread.post(`${emoji.sparkles} Theme set to **${event.value}**`);
+});
+
+bot.onAction("notifications_selected", (event) => {
+ if (!event.thread) {
+ return;
+ }
+ event.thread.post(`${emoji.bell} Notifications set to **${event.value}**`);
+});
+
// Handle card button actions
bot.onAction("hello", async (event) => {
if (!event.thread) {
diff --git a/packages/adapter-teams/src/cards.test.ts b/packages/adapter-teams/src/cards.test.ts
index d1cbe8db..a270333c 100644
--- a/packages/adapter-teams/src/cards.test.ts
+++ b/packages/adapter-teams/src/cards.test.ts
@@ -9,7 +9,10 @@ import {
Fields,
Image,
LinkButton,
+ RadioSelect,
Section,
+ Select,
+ SelectOption,
} from "chat";
import { describe, expect, it } from "vitest";
import { cardToAdaptiveCard, cardToFallbackText } from "./cards";
@@ -323,6 +326,115 @@ describe("cardToAdaptiveCard with modal buttons", () => {
});
});
+describe("cardToAdaptiveCard with select and radio_select in Actions", () => {
+ it("converts Select to compact ChoiceSetInput in body", () => {
+ const card = Card({
+ children: [
+ Actions([
+ Select({
+ id: "color",
+ label: "Pick a color",
+ options: [
+ SelectOption({ label: "Red", value: "red" }),
+ SelectOption({ label: "Blue", value: "blue" }),
+ ],
+ placeholder: "Choose...",
+ }),
+ ]),
+ ],
+ });
+ const adaptive = cardToAdaptiveCard(card);
+
+ expect(adaptive.body).toHaveLength(1);
+ expect(adaptive.body[0]).toMatchObject({
+ type: "Input.ChoiceSet",
+ id: "color",
+ label: "Pick a color",
+ style: "compact",
+ isRequired: true,
+ placeholder: "Choose...",
+ });
+ const choiceSet = adaptive.body[0] as {
+ choices: { title: string; value: string }[];
+ };
+ expect(choiceSet.choices).toHaveLength(2);
+ expect(choiceSet.choices[0]).toMatchObject({
+ title: "Red",
+ value: "red",
+ });
+
+ // Auto-injects submit button since there are no explicit buttons
+ expect(adaptive.actions).toHaveLength(1);
+ expect(adaptive.actions?.[0]).toMatchObject({
+ type: "Action.Submit",
+ title: "Submit",
+ data: { actionId: "__auto_submit" },
+ });
+ });
+
+ it("converts RadioSelect to expanded ChoiceSetInput in body", () => {
+ const card = Card({
+ children: [
+ Actions([
+ RadioSelect({
+ id: "plan",
+ label: "Choose Plan",
+ options: [
+ SelectOption({ label: "Free", value: "free" }),
+ SelectOption({ label: "Pro", value: "pro" }),
+ ],
+ }),
+ ]),
+ ],
+ });
+ const adaptive = cardToAdaptiveCard(card);
+
+ expect(adaptive.body).toHaveLength(1);
+ expect(adaptive.body[0]).toMatchObject({
+ type: "Input.ChoiceSet",
+ id: "plan",
+ label: "Choose Plan",
+ style: "expanded",
+ isRequired: true,
+ });
+
+ // Auto-injects submit button
+ expect(adaptive.actions).toHaveLength(1);
+ expect(adaptive.actions?.[0]).toMatchObject({
+ type: "Action.Submit",
+ data: { actionId: "__auto_submit" },
+ });
+ });
+
+ it("does NOT auto-inject submit when buttons are present", () => {
+ const card = Card({
+ children: [
+ Actions([
+ Select({
+ id: "color",
+ label: "Color",
+ options: [SelectOption({ label: "Red", value: "red" })],
+ }),
+ Button({ id: "submit", label: "Submit", style: "primary" }),
+ ]),
+ ],
+ });
+ const adaptive = cardToAdaptiveCard(card);
+
+ // Select goes to body, button goes to actions
+ expect(adaptive.body).toHaveLength(1);
+ expect(adaptive.body[0]).toMatchObject({
+ type: "Input.ChoiceSet",
+ id: "color",
+ });
+ expect(adaptive.actions).toHaveLength(1);
+ expect(adaptive.actions?.[0]).toMatchObject({
+ type: "Action.Submit",
+ title: "Submit",
+ });
+ });
+});
+
describe("cardToAdaptiveCard with CardLink", () => {
it("converts CardLink to a TextBlock with markdown link", () => {
const card = Card({
diff --git a/packages/adapter-teams/src/cards.ts b/packages/adapter-teams/src/cards.ts
index 6712ecc2..46781751 100644
--- a/packages/adapter-teams/src/cards.ts
+++ b/packages/adapter-teams/src/cards.ts
@@ -14,10 +14,13 @@ import type {
ActionArray,
ActionStyle,
CardElementArray,
+ ChoiceSetInputOptions,
} from "@microsoft/teams.cards";
import {
AdaptiveCard,
Image as AdaptiveImage,
+ Choice,
+ ChoiceSetInput,
Column,
ColumnSet,
Container,
@@ -36,7 +39,9 @@ import type {
FieldsElement,
ImageElement,
LinkButtonElement,
+ RadioSelectElement,
SectionElement,
+ SelectElement,
TableElement,
TextElement,
} from "chat";
@@ -51,6 +56,12 @@ const ADAPTIVE_CARD_SCHEMA =
"http://adaptivecards.io/schemas/adaptive-card.json";
const ADAPTIVE_CARD_VERSION = "1.4" as const;
+/**
+ * Sentinel action ID for auto-injected submit buttons.
+ * Used when a card has select/radio_select inputs but no submit button.
+ */
+export const AUTO_SUBMIT_ACTION_ID = "__auto_submit";
+
/**
* Convert a CardElement to a Teams Adaptive Card.
*/
@@ -176,17 +187,80 @@ function convertDividerToElement(_element: DividerElement): Container {
}
function convertActionsToElements(element: ActionsElement): ConvertResult {
- // In Adaptive Cards, actions go at the card level, not inline
- const actions: ActionArray = element.children
- .filter((child) => child.type === "button" || child.type === "link-button")
- .map((button) => {
- if (button.type === "link-button") {
- return convertLinkButtonToAction(button);
- }
- return convertButtonToAction(button);
- });
+ const actions: ActionArray = [];
+ const elements: CardElementArray = [];
+ let hasButtons = false;
+ let hasInputs = false;
+
+ for (const child of element.children) {
+ switch (child.type) {
+ case "button":
+ hasButtons = true;
+ actions.push(convertButtonToAction(child));
+ break;
+ case "link-button":
+ actions.push(convertLinkButtonToAction(child));
+ break;
+ case "select":
+ hasInputs = true;
+ elements.push(convertSelectToElement(child));
+ break;
+ case "radio_select":
+ hasInputs = true;
+ elements.push(convertRadioSelectToElement(child));
+ break;
+ default:
+ break;
+ }
+ }
+
+ // Auto-inject a submit button when there are inputs but no buttons.
+ // Teams inputs don't auto-submit like Slack — they need an Action.Submit.
+ if (hasInputs && !hasButtons) {
+ actions.push(
+ new SubmitAction({
+ title: "Submit",
+ data: { actionId: AUTO_SUBMIT_ACTION_ID },
+ })
+ );
+ }
+
+ return { elements, actions };
+}
+
+function convertSelectToElement(select: SelectElement): ChoiceSetInput {
+ const choices = select.options.map(
+ (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value })
+ );
+
+ const options: ChoiceSetInputOptions = {
+ id: select.id,
+ label: convertEmoji(select.label),
+ style: "compact",
+ isRequired: !(select.optional ?? false),
+ placeholder: select.placeholder,
+ value: select.initialOption,
+ };
+
+ return new ChoiceSetInput(...choices).withOptions(options);
+}
+
+function convertRadioSelectToElement(
+ radioSelect: RadioSelectElement
+): ChoiceSetInput {
+ const choices = radioSelect.options.map(
+ (opt) => new Choice({ title: convertEmoji(opt.label), value: opt.value })
+ );
+
+ const options: ChoiceSetInputOptions = {
+ id: radioSelect.id,
+ label: convertEmoji(radioSelect.label),
+ style: "expanded",
+ isRequired: !(radioSelect.optional ?? false),
+ value: radioSelect.initialOption,
+ };
- return { elements: [], actions };
+ return new ChoiceSetInput(...choices).withOptions(options);
}
function convertButtonToAction(button: ButtonElement): SubmitAction {
diff --git a/packages/adapter-teams/src/index.ts b/packages/adapter-teams/src/index.ts
index f847af90..89925d69 100644
--- a/packages/adapter-teams/src/index.ts
+++ b/packages/adapter-teams/src/index.ts
@@ -49,7 +49,7 @@ import {
NotImplementedError,
} from "chat";
import { BridgeHttpAdapter } from "./bridge-adapter";
-import { cardToAdaptiveCard } from "./cards";
+import { AUTO_SUBMIT_ACTION_ID, cardToAdaptiveCard } from "./cards";
import { toAppOptions } from "./config";
import { handleTeamsError } from "./errors";
import { TeamsGraphReader } from "./graph-api";
@@ -337,6 +337,16 @@ export class TeamsAdapter implements Adapter {
serviceUrl: activity.serviceUrl || "",
});
+ // Auto-submit fan-out: fire onAction for each input value
+ if (actionValue.actionId === AUTO_SUBMIT_ACTION_ID) {
+ this.fanOutAutoSubmit(
+ actionValue as unknown as Record,
+ activity,
+ threadId
+ );
+ return;
+ }
+
const actionEvent: Omit & {
adapter: TeamsAdapter;
} = {
@@ -393,6 +403,13 @@ export class TeamsAdapter implements Adapter {
serviceUrl: activity.serviceUrl || "",
});
+ // Auto-submit fan-out: fire onAction for each input value
+ if (actionData.actionId === AUTO_SUBMIT_ACTION_ID) {
+ const rawPayload = activity.value.action.data as Record;
+ this.fanOutAutoSubmit(rawPayload, activity, threadId);
+ return;
+ }
+
const actionEvent: Omit & {
adapter: TeamsAdapter;
} = {
@@ -424,6 +441,56 @@ export class TeamsAdapter implements Adapter {
);
}
+ /**
+ * Fan out an auto-submit payload into individual onAction calls.
+ * Called when the sentinel __auto_submit action ID is detected.
+ * Each input key/value pair is dispatched as a separate action in parallel.
+ */
+ private fanOutAutoSubmit(
+ payload: Record,
+ activity: Activity,
+ threadId: string
+ ): void {
+ if (!this.chat) {
+ return;
+ }
+
+ const webhookOptions = this.bridgeAdapter.getWebhookOptions(activity.id);
+ const entries = Object.entries(payload).filter(
+ ([key]) => key !== "actionId" && key !== "msteams"
+ );
+
+ this.logger.debug("Auto-submit fan-out", {
+ inputCount: entries.length,
+ keys: entries.map(([k]) => k),
+ });
+
+ const baseEvent = {
+ user: {
+ userId: activity.from?.id || "unknown",
+ userName: activity.from?.name || "unknown",
+ fullName: activity.from?.name || "unknown",
+ isBot: false,
+ isMe: false,
+ },
+ messageId: activity.replyToId || activity.id || "",
+ threadId,
+ adapter: this as TeamsAdapter,
+ raw: activity,
+ };
+
+ for (const [key, val] of entries) {
+ this.chat.processAction(
+ {
+ ...baseEvent,
+ actionId: key,
+ value: typeof val === "string" ? val : undefined,
+ },
+ webhookOptions
+ );
+ }
+ }
+
/**
* Handle dialog.open (task/fetch) invoke.
* Uses Promise.race to resolve as soon as onOpenModal fires.