Skip to content

feat(option-tile): align OptionTile and OptionTileGroup - #8127

Open
tamas-sage wants to merge 12 commits into
masterfrom
feat/FE-7746-option-tile-component
Open

feat(option-tile): align OptionTile and OptionTileGroup#8127
tamas-sage wants to merge 12 commits into
masterfrom
feat/FE-7746-option-tile-component

Conversation

@tamas-sage

@tamas-sage tamas-sage commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Proposed behaviour

Standardize both single-select and multi-select groups to 4 options for consistency.
Keep custom option only for single-select groups and prevent it in multi-select groups.
Support numbered-key selection for single-select options.
Keep tiles content-sized (not forced full width) to better match the intended layout.

Current behaviour

Option tiles are displayed in single and multi-select groups with basic click/keyboard selection.
Group examples were inconsistent with the design guidance (option count/usage patterns varied).
Custom input tile behavior exists, but group-level rules were not clearly enforced in all examples.
image
image
image
image
image

Checklist

  • Commits follow our style guide
  • Related issues linked in commit messages if required
  • Screenshots are included in the PR if useful
  • All themes are supported if required
  • Unit tests added or updated if required
  • Playwright automation tests added or updated if required
  • Storybook added or updated if required
  • Translations added or updated (including creating or amending translation keys table in storybook) if required
  • Typescript d.ts file added or updated if required
  • Related docs have been updated if required

QA

  • Tested in provided StackBlitz sandbox/Storybook
  • Add new Playwright test coverage if required
  • Carbon implementation matches Design System/designs
  • UI Tests GitHub check reviewed if required

Additional context

Testing instructions

@tamas-sage
tamas-sage requested review from a team as code owners August 12, 2026 08:31
@tamas-sage
tamas-sage marked this pull request as draft August 12, 2026 08:32
@tamas-sage tamas-sage self-assigned this Aug 12, 2026
@edleeks87
edleeks87 requested review from edleeks87 and a lite review from Copilot August 13, 2026 09:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

  • Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.

Pull request overview

This PR introduces a new OptionTile / OptionTileGroup component pair to the Carbon React component library and exports them from the package entrypoint, with updated Storybook, Jest, and Playwright coverage to align single- vs multi-select usage patterns and keyboard interactions.

Changes:

  • Added OptionTile variants (single, multiple, custom) and OptionTileGroup with single/multiple selection context.
  • Implemented keyboard behaviors (Enter/Space toggles, number-key hotkeys for single groups) and enforced custom usage constraints.
  • Added Storybook stories plus Jest + Playwright tests for accessibility and interactions.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/index.ts Exposes OptionTile / OptionTileGroup and related types from the package entrypoint.
src/components/option-tile/index.ts Barrel export for the component and public prop/type surface.
src/components/option-tile/option-tile.component.tsx Implements the component variants, group context, and keyboard behaviors.
src/components/option-tile/option-tile.style.ts Adds styled-components styling for tiles, custom input, and group layout.
src/components/option-tile/option-tile.stories.tsx Adds Storybook examples for single/custom/multiple and grouped usage.
src/components/option-tile/option-tile.test.tsx Adds Jest tests covering rendering, interaction, constraints, and a11y attributes.
src/components/option-tile/option-tile.pw.tsx Adds Playwright CT coverage for accessibility and key interactions.
src/components/option-tile/components.test-pw.tsx Adds Playwright component fixtures used by CT tests.
Files excluded by content exclusion policy (3)
  • skills/carbon-react/components/option-tile-group.md
  • skills/carbon-react/components/option-tile.md
  • skills/carbon-react/index.md

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/components/option-tile/option-tile.component.tsx
Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.test.tsx Outdated
Comment thread src/components/option-tile/option-tile.style.ts Outdated
@tamas-sage
tamas-sage requested a lite review from Copilot and removed request for edleeks87 August 13, 2026 09:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (8)

src/components/option-tile/option-tile.component.tsx:283

  • Number-key hotkeys are handled at the group level and currently fire for any bubbled keydown. This will also trigger when focus is inside the custom tile text input, so typing digits can unexpectedly change the selected tile. Consider ignoring hotkey handling when the event originated from an input/textarea or a contenteditable element.
  const onGroupKeyDown = (ev: React.KeyboardEvent<HTMLDivElement>) => {
    if (selectionType !== "single") return;

    if (!/^[1-9]$/.test(ev.key)) return;

src/components/option-tile/option-tile.stories.tsx:100

  • This Storybook example currently renders 3 tiles, but the PR description says multi-select groups should be standardized to 4 options. Adding a fourth option here keeps the documented examples consistent.
      <OptionTileGroup selectionType="multiple" aria-label="Delivery options">
        <OptionTile
          variant="multiple"
          label="Email receipt"
          checked={selected.includes("email")}

src/components/option-tile/option-tile.test.tsx:345

  • The console.error spy is not restored at the end of this test. Using mockReset keeps the spy installed and can leak into subsequent tests, potentially masking real console errors. Restoring the spy avoids cross-test side effects.
  const consoleSpy = jest
    .spyOn(global.console, "error")
    .mockImplementation(() => undefined);

  expect(() =>
    render(
      <OptionTileGroup selectionType="multiple" aria-label="Options">
        <OptionTile
          variant="custom"
          title="Something else"
          inputAriaLabel="Custom option value"
        />
      </OptionTileGroup>,
    ),
  ).toThrow(
    "OptionTile with variant='custom' can only be used within a single selection OptionTileGroup.",
  );

  consoleSpy.mockReset();
});

skills/carbon-react/components/option-tile.md:90

  • The SingleSelectGroup example shows 3 options, but the PR description states single-select groups should be standardized to 4 options. Updating the docs example (3 standard + custom) will keep the documented guidance consistent.
      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"
          onClick={() => setSelected("pay-now")}
          data-element={selected === "pay-now" ? "selected" : undefined}

skills/carbon-react/components/option-tile.md:133

  • The MultiSelectGroup example shows 3 options, but the PR description states multi-select groups should be standardized to 4 options. Updating the docs example keeps it aligned with the intended usage.
      <OptionTileGroup selectionType="multiple" aria-label="Delivery options">
        <OptionTile
          variant="multiple"
          label="Email receipt"
          checked={selected.includes("email")}
          onChange={toggle("email")}
        />

src/components/option-tile/option-tile.style.ts:101

  • The tile has a fixed width of 424px, which can cause horizontal overflow on narrow viewports/containers (e.g. 320px). Clamping with max-width keeps the intended size without breaking responsive layouts.
  box-sizing: border-box;
  width: 424px;
  min-height: var(--global-size-m);
  display: flex;

src/components/option-tile/option-tile.stories.tsx:79

  • The PR description states both single-select and multi-select groups should be standardized to 4 options, but these Storybook examples currently render 3 tiles in each group. Updating the stories to match the documented behavior will avoid confusion for consumers.

This issue also appears on line 96 of the same file.

      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"

skills/carbon-react/components/option-tile.md:18

  • The props table lists variant as only "single", but the component supports "single" | "multiple" | "custom". Updating this row prevents consumers from missing the other supported variants.

This issue also appears in the following locations of the same file:

  • line 84
  • line 127
| Name | Type | Required | Literals | Description | Default |
| --- | --- | --- | --- | --- | --- |
| variant | "single" | Yes |  | The single-select visual variant. |  |
| disabled | boolean \| undefined | No |  | Applies a disabled state to the tile. |  |
| data-element | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |

Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.component.tsx Outdated
Comment thread src/components/option-tile/option-tile.test.tsx Outdated
Comment thread src/components/option-tile/option-tile.pw.tsx

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (5)

src/components/option-tile/option-tile.style.ts:100

  • tileBaseStyles hard-codes a width: 424px;, which contradicts the PR goal of keeping tiles content-sized (it forces a fixed width regardless of content). Consider switching to an intrinsic width with a max-width cap so tiles size to content but don’t grow unbounded.
  box-sizing: border-box;
  width: 424px;
  min-height: var(--global-size-m);
  display: flex;

src/components/option-tile/option-tile.component.tsx:166

  • The invariant message is misleading: the code only disallows variant="custom" inside a multiple selection OptionTileGroup, but the message says it can only be used within a single-selection group (even though standalone usage is allowed). Update the error text to reflect the actual constraint.
  invariant(
    selectionType !== "multiple",
    "OptionTile with variant='custom' can only be used within a single selection OptionTileGroup.",
  );

src/components/option-tile/option-tile.stories.tsx:79

  • PR description says both single- and multi-select group examples should be standardized to 4 options, but the Storybook group examples currently render 3 tiles each. Update these examples so consumers see the intended 4-option layout.
      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"
          onClick={() => setSelected("pay-now")}
          data-element={selected === "pay-now" ? "selected" : undefined}
        />
        <OptionTile
          variant="single"
          number={2}
          title="Pay later"
          onClick={() => setSelected("pay-later")}
          data-element={selected === "pay-later" ? "selected" : undefined}
        />
        <OptionTile
          variant="custom"
          title="Something else"
          inputAriaLabel="Custom amount"
          inputPlaceholder="Type amount"
        />
      </OptionTileGroup>

src/components/option-tile/components.test-pw.tsx:73

  • Similarly to the Storybook examples, the Playwright helper components meant to showcase grouped tiles don’t match the described standard of 4 options per group (single group has 2, multiple group has 2). Aligning these fixtures to 4 options will keep visual/a11y coverage representative of the intended usage.
export const SingleSelectGroupOptionTileComponent = () => {
  const [selected, setSelected] = useState("none");

  return (
    <>
      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"
          onClick={() => setSelected("Pay now")}
        />
        <OptionTile
          variant="single"
          number={2}
          title="Pay later"
          onClick={() => setSelected("Pay later")}
        />
      </OptionTileGroup>
      <p>{`Selected: ${selected}`}</p>
    </>
  );
};

export const GroupedOptionTileComponent = () => {
  const [emailChecked, setEmailChecked] = useState(false);
  const [smsChecked, setSmsChecked] = useState(false);

  return (
    <OptionTileGroup selectionType="multiple" aria-label="Delivery options">
      <OptionTile
        variant="multiple"
        label="Email receipt"
        checked={emailChecked}
        onChange={setEmailChecked}
      />
      <OptionTile
        variant="multiple"
        label="SMS updates"
        checked={smsChecked}
        onChange={setSmsChecked}
      />
    </OptionTileGroup>
  );

skills/carbon-react/components/option-tile.md:21

  • The generated props table for OptionTile is incomplete: it only documents variant: "single" plus a few common props, but omits key props for all variants (e.g. number, title, label, checked, onChange, inputAriaLabel, etc.). This can mislead consumers of the skills docs; the props section should reflect the full union type (OptionTileProps) and its variant-specific fields.
## Props
| Name | Type | Required | Literals | Description | Default |
| --- | --- | --- | --- | --- | --- |
| variant | "single" | Yes |  | The single-select visual variant. |  |
| disabled | boolean \| undefined | No |  | Applies a disabled state to the tile. |  |
| data-element | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |
| data-role | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |

@tamas-sage
tamas-sage force-pushed the feat/FE-7746-option-tile-component branch from 2c27677 to 7f12fc8 Compare August 14, 2026 12:48
@DipperTheDan
DipperTheDan self-requested a review August 14, 2026 14:34
@tamas-sage
tamas-sage requested review from edleeks87 and a lite review from Copilot August 17, 2026 08:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.

Suppressed comments (8)

src/components/option-tile/option-tile.test.tsx:182

  • getByTestId("option-tile-icon") will fail because the icon uses data-role="option-tile-icon" (no data-testid). Query the icon via its data-role from within the tile button.
  const icon = screen.getByTestId("option-tile-icon");

  expect(icon).toHaveAttribute("type", "edit");
  expect(icon).toHaveAttribute("aria-hidden", "true");

src/components/option-tile/option-tile.component.tsx:235

  • When the custom tile is active, the input should receive disabled={disabled} so it can't be edited if the tile is disabled (including when disabled changes after activation).
      <StyledCustomInput
        ref={inputRef}
        aria-label={inputAriaLabel}
        placeholder={inputPlaceholder}
        value={customValue}
        onChange={(ev) => onCustomValueChange(ev.target.value)}
        onKeyDown={onActiveKeyDown}
      />

src/components/option-tile/option-tile.stories.tsx:124

  • The PR description says multi-select groups should be standardized to 4 options; this Story currently renders only 3. Add a 4th option so the example matches the stated behavior.
      <OptionTileGroup selectionType="multiple" aria-label="Delivery options">
        <OptionTile
          variant="multiple"
          label="Email receipt"
          checked={selected.includes("email")}
          onChange={toggle("email")}
        />
        <OptionTile
          variant="multiple"
          label="SMS updates"
          checked={selected.includes("sms")}
          onChange={toggle("sms")}
        />
        <OptionTile
          variant="multiple"
          label="Printed statement"
          checked={selected.includes("print")}
          onChange={toggle("print")}
        />
      </OptionTileGroup>

src/components/option-tile/components.test-pw.tsx:79

  • The PR description states grouped examples should be standardized to 4 options; this Playwright multi-select group example currently renders only 2 tiles. Expand it to 4 options to reflect the intended guidance.
export const GroupedOptionTileComponent = () => {
  const [emailChecked, setEmailChecked] = useState(false);
  const [smsChecked, setSmsChecked] = useState(false);

  return (
    <OptionTileGroup selectionType="multiple" aria-label="Delivery options">
      <OptionTile
        variant="multiple"
        label="Email receipt"
        checked={emailChecked}
        onChange={setEmailChecked}
      />
      <OptionTile
        variant="multiple"
        label="SMS updates"
        checked={smsChecked}
        onChange={setSmsChecked}
      />
    </OptionTileGroup>
  );

src/components/option-tile/option-tile.component.tsx:175

  • If disabled changes to true while the custom tile is active, the input remains editable and the tile stays active. The component should deactivate and notify onCustomActiveChange(false) when it becomes disabled.

This issue also appears on line 228 of the same file.

  const setActive = (next: boolean) => {
    setIsActive(next);
    onCustomActiveChange?.(next);
  };

src/components/option-tile/option-tile.stories.tsx:88

  • The PR description says single-select groups should be standardized to 4 options; this Story currently renders only 3 (2 single + 1 custom). Add another single option so the example matches the stated behavior.

This issue also appears on line 105 of the same file.

      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"
          onClick={() => setSelected("pay-now")}
          data-element={selected === "pay-now" ? "selected" : undefined}
        />
        <OptionTile
          variant="single"
          number={2}
          title="Pay later"
          onClick={() => setSelected("pay-later")}
          data-element={selected === "pay-later" ? "selected" : undefined}
        />
        <OptionTile
          variant="custom"
          title="Something else"
          inputAriaLabel="Custom amount"
          inputPlaceholder="Type amount"
          customValue={customValue}
          onCustomValueChange={setCustomValue}
        />
      </OptionTileGroup>

src/components/option-tile/components.test-pw.tsx:54

  • The PR description states grouped examples should be standardized to 4 options; this Playwright single-select group example currently renders only 2 tiles. Expand it to 4 options to reflect the intended guidance.

This issue also appears on line 60 of the same file.

      <OptionTileGroup selectionType="single" aria-label="Payment options">
        <OptionTile
          variant="single"
          number={1}
          title="Pay now"
          onClick={() => setSelected("Pay now")}
        />
        <OptionTile
          variant="single"
          number={2}
          title="Pay later"
          onClick={() => setSelected("Pay later")}
        />
      </OptionTileGroup>

skills/carbon-react/components/option-tile.md:22

  • The OptionTile props table is currently incorrect/incomplete (e.g. it lists variant as only "single" and omits the variant-specific props like checked, onChange, customValue, etc.). This will mislead consumers of this generated component reference.
## Props
| Name | Type | Required | Literals | Description | Default |
| --- | --- | --- | --- | --- | --- |
| variant | "single" | Yes |  | The single-select visual variant. |  |
| disabled | boolean \| undefined | No |  | Applies a disabled state to the tile. |  |
| data-element | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |
| data-role | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |

Comment thread src/components/option-tile/option-tile.test.tsx
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (4)

src/components/option-tile/option-tile.component.tsx:232

  • When the custom tile is active, the input is not marked as disabled, so if disabled becomes true while active the custom value can still be edited. The active input should reflect the disabled state.
      <StyledCustomInput
        ref={inputRef}
        aria-label={inputAriaLabel}
        placeholder={inputPlaceholder}
        value={customValue}

src/components/option-tile/option-tile.style.ts:99

  • tileBaseStyles sets a fixed width: 424px;, which makes tiles a fixed size rather than content-sized. This conflicts with the PR description goal of keeping tiles content-sized (not forced to a fixed width).
  width: 424px;

skills/carbon-react/components/option-tile.md:20

  • The OptionTile docs props table is inaccurate/incomplete: it lists variant as only "single", but the component supports "single" | "multiple" | "custom" and has many variant-specific required props (e.g. title, label, checked). This should be regenerated/updated so consumers get correct guidance.
| Name | Type | Required | Literals | Description | Default |
| --- | --- | --- | --- | --- | --- |
| variant | "single" | Yes |  | The single-select visual variant. |  |
| disabled | boolean \| undefined | No |  | Applies a disabled state to the tile. |  |
| data-element | string \| undefined | No |  | Identifier used for testing purposes, applied to the root element of the component. |  |

src/components/option-tile/option-tile.component.tsx:161

  • The invariant message is misleading: the custom variant is allowed outside a group (it’s used standalone in stories), but the error says it can only be used within a single selection OptionTileGroup. Consider rewording to specifically prohibit use in a multiple-selection group.
  invariant(
    selectionType !== "multiple",
    "OptionTile with variant='custom' can only be used within a single selection OptionTileGroup.",
  );

DipperTheDan
DipperTheDan previously approved these changes Aug 20, 2026
edleeks87
edleeks87 previously approved these changes Aug 21, 2026
@tamas-sage
tamas-sage marked this pull request as ready for review August 21, 2026 12:09
@tamas-sage
tamas-sage dismissed stale reviews from edleeks87 and DipperTheDan via 665579a August 25, 2026 07:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

4 participants