Skip to content

feat: Add new Tabs component#4062

Open
RayRedGoose wants to merge 5 commits into
Workday:prerelease/majorfrom
RayRedGoose:issue3980-tabs
Open

feat: Add new Tabs component#4062
RayRedGoose wants to merge 5 commits into
Workday:prerelease/majorfrom
RayRedGoose:issue3980-tabs

Conversation

@RayRedGoose

@RayRedGoose RayRedGoose commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes: #3980

New Tabs component has been added to preview.

Release Category

Components

Release Note

New Tabs component has been added to preview.


Checklist

For the Reviewer

  • PR title is short and descriptive
  • PR summary describes the change (Fixes/Resolves linked correctly)
  • PR Release Notes describes additional information useful to call out in a release message or removed if not applicable
  • Breaking Changes provides useful information to upgrade to this code or removed if not applicable

Where Should the Reviewer Start?

Areas for Feedback? (optional)

  • Code
  • Documentation
  • Testing
  • Codemods

Testing Manually

Screenshots or GIFs (if applicable)

Thank You Gif (optional)

Summary by CodeRabbit

  • New Features
    • Added a React Tabs compound component (Tabs, list, items, panels) with keyboard/ARIA-friendly selection and support for named, disabled, icon-based, single-panel, right-to-left, and dynamic tab content.
    • Included overflow support with an overflow button and a menu popper for accessing hidden tabs.
  • Documentation
    • Added Storybook/MDX documentation and expanded examples, including licensing/README content for the Tabs feature.
  • Tests
    • Added automated specs and an SSR smoke test covering tab roles, panel rendering, selection callbacks, and basic interaction behavior.

@RayRedGoose
RayRedGoose requested a review from a team as a code owner July 15, 2026 03:17
Copilot AI review requested due to automatic review settings July 15, 2026 03:17
@RayRedGoose RayRedGoose added the needs-a11y Extra attention from accessibility is needed label Jul 15, 2026
@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f7d827c8-3299-4d39-af77-e5e20a39f7c7

📥 Commits

Reviewing files that changed from the base of the PR and between 0615cd8 and 39b7e28.

📒 Files selected for processing (1)
  • modules/preview-react/tabs/stories/examples/Basic.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • modules/preview-react/tabs/stories/examples/Basic.tsx

📝 Walkthrough

Walkthrough

Introduces a Preview React compound Tabs component with model-driven selection, panels, keyboard and overflow behavior, accessibility attributes, public exports, tests, Storybook stories, and usage documentation.

Changes

Tabs Feature

Layer / File(s) Summary
Tabs model and public entrypoints
modules/preview-react/index.ts, modules/preview-react/tabs/index.ts, modules/preview-react/tabs/lib/useTabsModel.tsx, modules/preview-react/tabs/lib/Tabs.tsx, modules/react/tabs/lib/TabsItem.tsx
Adds the Tabs model, compound container, menu integration, selection state, overflow item derivation, public barrel exports, and updated aria-selected documentation.
Tab primitives and overflow UI
modules/preview-react/tabs/lib/TabsItem.tsx, TabsList.tsx, TabsPanel.tsx, TabsPanels.tsx, TabsOverflowButton.tsx, TabsMenuPopper.tsx
Adds model-connected tab items, lists, panels, overflow controls, menu popper behavior, ARIA attributes, scrolling state, and variant styling.
Component tests and test configuration
modules/preview-react/tabs/spec/*
Adds tests for tab, tablist, panel, SSR rendering, selection callbacks, and child rendering.
Tabs examples and Storybook documentation
modules/preview-react/tabs/stories/Tabs.mdx, Tabs.stories.ts, stories/examples/*, README.md, LICENSE, modules/react/tabs/stories/Tabs.mdx
Documents the Tabs API and adds examples for variants, overflow, dynamic tabs, RTL, icons, disabled tabs, and panel configurations.
Storybook state coverage
modules/preview-react/tabs/stories/testing.stories.tsx, stories/tsconfig.json
Adds Storybook testing stories covering component states, bidirectionality, overflow, viewport constraints, and container widths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Consumer
  participant Tabs
  participant useTabsModel
  participant OverflowListModel
  participant MenuModel
  Consumer->>Tabs: render Tabs.List, Tabs.Item, and Tabs.Panel
  Tabs->>useTabsModel: create model state and events
  useTabsModel->>OverflowListModel: manage selection, focus, orientation, and overflow
  useTabsModel->>MenuModel: expose hidden items as menu entries
  MenuModel->>useTabsModel: report selected overflow item
  useTabsModel->>Tabs: update selection and panel visibility
Loading

Suggested reviewers: josh-bagwell, williamjstanton

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately summarizes the main change: adding a new Tabs component.
Linked Issues check ✅ Passed The PR adds the Tabs component, button-style variants, Storybook docs, and tests that match the linked Tabs feature.
Out of Scope Changes check ✅ Passed The changes stay within the Tabs feature, with related docs, tests, and minor API doc updates only.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a new preview Tabs compound component to Canvas Kit, including Storybook documentation/examples and initial unit/spec coverage, in support of issue #3980 (Sana Canvas Tabs updates + new item variants).

Changes:

  • Introduces @workday/canvas-kit-preview-react/tabs with Tabs, useTabsModel, and supporting subcomponents (List/Item/Panel/Overflow).
  • Adds Storybook MDX docs plus example stories (basic, overflow, RTL, icons, dynamic tabs, etc.) and Chromatic testing stories.
  • Adds initial Testing Library specs for Tabs, TabList, Tab, and TabPanel.

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
modules/preview-react/tabs/stories/tsconfig.json Storybook TS config for the Tabs stories folder.
modules/preview-react/tabs/stories/testing.stories.tsx Visual/static state coverage for Chromatic/testing (variants, overflow, RTL).
modules/preview-react/tabs/stories/Tabs.stories.ts Primary Storybook stories entry for the Tabs docs/examples.
modules/preview-react/tabs/stories/Tabs.mdx MDX documentation page for preview Tabs usage and API/spec sections.
modules/preview-react/tabs/stories/examples/Basic.tsx Basic usage examples for filled + outline variants.
modules/preview-react/tabs/stories/examples/DisabledTab.tsx Demonstrates disabled tab item behavior.
modules/preview-react/tabs/stories/examples/Icons.tsx Demonstrates Tabs.Item.Icon + Tabs.Item.Text usage.
modules/preview-react/tabs/stories/examples/NamedTabs.tsx Demonstrates data-id based tab/panel pairing.
modules/preview-react/tabs/stories/examples/RightToLeft.tsx RTL usage example via CanvasProvider dir="rtl".
modules/preview-react/tabs/stories/examples/OverflowTabs.tsx Overflow menu usage example with dynamic items/model.
modules/preview-react/tabs/stories/examples/HoistedModel.tsx Hoisted useTabsModel example with external event triggering.
modules/preview-react/tabs/stories/examples/SinglePanel.tsx Demonstrates advanced “single panel” composition pattern.
modules/preview-react/tabs/stories/examples/AlternativeTabStop.tsx Demonstrates disabling panel focusability via tabIndex={undefined}.
modules/preview-react/tabs/stories/examples/DynamicTabs.tsx Demonstrates dynamically adding/removing tabs and overflow behavior.
modules/preview-react/tabs/spec/tsconfig.json Spec TS config for the preview Tabs package.
modules/preview-react/tabs/spec/Tabs.spec.tsx Baseline SSR + onSelect behavior tests.
modules/preview-react/tabs/spec/TabPanel.spec.tsx Tabs.Panel role/content + verifyComponent coverage.
modules/preview-react/tabs/spec/TabList.spec.tsx Tabs.List role/content + verifyComponent coverage.
modules/preview-react/tabs/spec/Tab.spec.tsx Tabs.Item role/content + verifyComponent coverage.
modules/preview-react/tabs/README.md Package README and Storybook docs link.
modules/preview-react/tabs/LICENSE License file for the new preview package.
modules/preview-react/tabs/lib/useTabsModel.tsx Tabs model built on collection + overflow + menu models, incl. variant and panel registration.
modules/preview-react/tabs/lib/Tabs.tsx Container/compound component wiring subcomponents and overflow menu integration.
modules/preview-react/tabs/lib/TabsList.tsx Tab list implementation with overflow measurement + touch scroll masking.
modules/preview-react/tabs/lib/TabsItem.tsx Tab item implementation, styling, selection/roving focus/overflow measurement.
modules/preview-react/tabs/lib/TabsPanel.tsx Tab panel implementation + panel registration and ARIA wiring.
modules/preview-react/tabs/lib/TabsPanels.tsx Helper for rendering panels from dynamic items via render-prop.
modules/preview-react/tabs/lib/TabsOverflowButton.tsx Overflow trigger button that targets the overflow list + menu model.
modules/preview-react/tabs/lib/TabsMenuPopper.tsx Popper wrapper for the overflow menu.
modules/preview-react/tabs/index.ts Public exports for the preview Tabs package.
modules/preview-react/index.ts Re-exports tabs from the preview-react barrel.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +249 to +251
const modality = useModalityType();
console.log(model.state.variant);

@@ -0,0 +1,59 @@
import {Meta, StoryObj} from '@storybook/react';

import {Tabs} from '@workday/canvas-kit-react/tabs';
Comment on lines +130 to +134
<SymbolDoc name="Tabs" fileName="/react/" />

## Specifications

<Specifications file="./cypress/component/Tabs.spec.tsx" name="Tabs" />
Comment on lines +3 to +5
View the
[documentation for Tabs](https://workday.github.io/canvas-kit/?path=/docs/components-containers-tabs--docs)
on Storybook.
Comment on lines +31 to +33
* Optional id for the whole `Tabs` group. The `aria-controls` of the `Tab.Item` and `id` of the
* `Tab.Panel` will automatically derived from this id. If not provided, a unique id will be
* created.
Comment on lines +54 to +58
/**
* The identifier of the tab. This identifier will be used in change events and for `initialTab`.
* Must match the `data-id` of the associated tab panel. If this property is not provided, it will
* default to a string representation of the the zero-based index of the Tab when it was
* initialized.
Comment on lines +20 to +24
/**
* The identifier of the tab. This identifier will be used in change events and for `initialTab`.
* Must match the `data-id` of the associated tab item. If this property is not provided, it will
* default to a string representation of the the zero-based index of the Tab when it was
* initialized.
Comment on lines +27 to +32
/**
* Part of the ARIA specification for tabs. By default, all `tabpanel` elements have a `tabIndex`
* of `0` which makes the whole content area receive focus. If you have a focusable item near the
* top of the tab panel content area, you may set `tabIndex` to `undefined` to prevent the tab
* panel element from receiving focus. Only do this is a child of the tab panel can receive focus.
*/
Third Tab
</Tabs.Item>
</Tabs.List>
<Tabs.Panel cs={{marginBlockStart: system.gap.lg}} hidden={undefined} id="mytab-panel">
@cypress

cypress Bot commented Jul 15, 2026

Copy link
Copy Markdown

Workday/canvas-kit    Run #11280

Run Properties:  status check passed Passed #11280  •  git commit 4e55d656d4 ℹ️: Merge 39b7e2885085a883af7e702d28c14e7a36849e35 into f21d7cc4d8f56e8a7ffffbb5881a...
Project Workday/canvas-kit
Branch Review issue3980-tabs
Run status status check passed Passed #11280
Run duration 02m 26s
Commit git commit 4e55d656d4 ℹ️: Merge 39b7e2885085a883af7e702d28c14e7a36849e35 into f21d7cc4d8f56e8a7ffffbb5881a...
Committer Raisa Primerova
View all properties for this run ↗︎

Test results
Tests that failed  Failures 0
Tests that were flaky  Flaky 0
Tests that did not run due to a developer annotating a test with .skip  Pending 17
Tests that did not run due to a failure in a mocha hook  Skipped 0
Tests that passed  Passing 809
View all changes introduced in this branch ↗︎
UI Coverage  19.64%
  Untested elements 1528  
  Tested elements 371  
Accessibility  99.47%
  Failed rules  5 critical   5 serious   0 moderate   2 minor
  Failed elements 72  

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 7

🧹 Nitpick comments (4)
modules/preview-react/tabs/spec/Tabs.spec.tsx (2)

27-28: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused container variable.

The container destructured from render is never used. You can safely remove it.

♻️ Proposed refactor
-    const {container} = render(
-      <Tabs onSelect={cb} initialSelectedIds={['first']}>
+    render(
+      <Tabs onSelect={cb} initialTab="first">
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/spec/Tabs.spec.tsx` around lines 27 - 28, Update
the render call in the Tabs test to stop destructuring the unused container
value; invoke render without assigning its return value while preserving the
existing Tabs props and test behavior.

25-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the idiomatic initialTab prop instead of initialSelectedIds.

The Tabs component defines initialTab in its model config for initial selection, but this test uses initialSelectedIds. For accurate testing of the public API surface, you should use initialTab.

♻️ Proposed refactor
-    const {container} = render(
-      <Tabs onSelect={cb} initialSelectedIds={['first']}>
+    const {container} = render(
+      <Tabs onSelect={cb} initialTab="first">
         <Tabs.List>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/spec/Tabs.spec.tsx` around lines 25 - 35, Update
the Tabs test setup in “should call "onSelect" when tab is selected” to use the
public initialTab prop instead of initialSelectedIds, preserving the same
initial “first” tab selection.
modules/preview-react/tabs/stories/examples/SinglePanel.tsx (1)

35-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Fix implicit any type error for object indexing.

Indexing the contents object with model.state.selectedIds[0] (which is a string) may result in a TypeScript compiler error, as the object only has specific literal keys (first, second, third). Consider casting the index to keyof typeof contents to ensure type safety.

♻️ Proposed fix to ensure type safety
-        {contents[model.state.selectedIds[0]]}
+        {contents[model.state.selectedIds[0] as keyof typeof contents]}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/stories/examples/SinglePanel.tsx` at line 35,
Update the contents lookup in the SinglePanel render expression to type the
selected ID as keyof typeof contents before indexing, resolving the implicit-any
error while preserving the existing selected-content behavior.
modules/preview-react/tabs/lib/TabsItem.tsx (1)

250-250: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove leftover debug artifact.

♻️ Proposed fix
-  console.log(model.state.variant);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsItem.tsx` at line 250, Remove the leftover
console.log debug statement from the TabsItem component, including the
model.state.variant logging.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@modules/preview-react/tabs/lib/TabsItem.tsx`:
- Line 132: Update the disabled-state selector in TabsItem styling to target
only elements whose aria-disabled attribute equals "true", while preserving the
existing :disabled and .disabled selectors.
- Around line 227-232: Update the `aria-selected` value in the returned tab
attributes so unselected tabs provide `undefined` rather than `false`, while
selected tabs remain `true`; preserve the existing `role`, `aria-controls`, and
other attribute behavior.
- Around line 254-259: Update the StyledTabItem prop order in TabsItem so
model.state.variant is applied before spreading elemProps, allowing an
explicitly provided variant to override the model fallback while preserving the
default when no override is supplied.

In `@modules/preview-react/tabs/lib/TabsList.tsx`:
- Around line 36-64: Update getScrollPercentage to detect the tabs container’s
RTL direction and normalize horizontal scrollLeft before calculating the
percentage, while preserving the existing vertical behavior. In the horizontal
overflow rendering around the start/end masks, swap the gradient direction for
RTL so the fade aligns with the correct edge; keep LTR positioning unchanged.

In `@modules/preview-react/tabs/lib/TabsPanel.tsx`:
- Around line 42-64: Update the localId initialization in TabsPanel so panels
without data-id or item?.id derive their positional fallback from
state.panelIndexRef.current during render, before useMountLayout runs. Preserve
explicit data-id and item IDs, and keep registration cleanup aligned with the
resolved fallback ID so SSR and first-render visibility/ARIA attributes are
correct.

In `@modules/preview-react/tabs/lib/useTabsModel.tsx`:
- Around line 119-127: Update the nonInteractiveIds configuration in
useTabsModel’s useMenuModel setup to preserve disabled state for overflowed
tabs: derive it from the IDs represented by overflowItems rather than filtering
against hiddenIds in a way that excludes them. Ensure disabled overflowed tabs
remain non-selectable while preserving the menu’s existing selection behavior.

In `@modules/preview-react/tabs/stories/testing.stories.tsx`:
- Around line 52-64: Wrap the conditional Tabs.Item instances in a Tabs.List
within the Tabs component in the ComponentStatesTable render callback. Preserve
the existing hasIcon, variant, and item content behavior while ensuring both
branches are children of Tabs.List.

---

Nitpick comments:
In `@modules/preview-react/tabs/lib/TabsItem.tsx`:
- Line 250: Remove the leftover console.log debug statement from the TabsItem
component, including the model.state.variant logging.

In `@modules/preview-react/tabs/spec/Tabs.spec.tsx`:
- Around line 27-28: Update the render call in the Tabs test to stop
destructuring the unused container value; invoke render without assigning its
return value while preserving the existing Tabs props and test behavior.
- Around line 25-35: Update the Tabs test setup in “should call "onSelect" when
tab is selected” to use the public initialTab prop instead of
initialSelectedIds, preserving the same initial “first” tab selection.

In `@modules/preview-react/tabs/stories/examples/SinglePanel.tsx`:
- Line 35: Update the contents lookup in the SinglePanel render expression to
type the selected ID as keyof typeof contents before indexing, resolving the
implicit-any error while preserving the existing selected-content behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b7f2ce41-067a-4720-a076-3221ab0275e8

📥 Commits

Reviewing files that changed from the base of the PR and between 86cf338 and fe90ed7.

📒 Files selected for processing (31)
  • modules/preview-react/index.ts
  • modules/preview-react/tabs/LICENSE
  • modules/preview-react/tabs/README.md
  • modules/preview-react/tabs/index.ts
  • modules/preview-react/tabs/lib/Tabs.tsx
  • modules/preview-react/tabs/lib/TabsItem.tsx
  • modules/preview-react/tabs/lib/TabsList.tsx
  • modules/preview-react/tabs/lib/TabsMenuPopper.tsx
  • modules/preview-react/tabs/lib/TabsOverflowButton.tsx
  • modules/preview-react/tabs/lib/TabsPanel.tsx
  • modules/preview-react/tabs/lib/TabsPanels.tsx
  • modules/preview-react/tabs/lib/useTabsModel.tsx
  • modules/preview-react/tabs/spec/Tab.spec.tsx
  • modules/preview-react/tabs/spec/TabList.spec.tsx
  • modules/preview-react/tabs/spec/TabPanel.spec.tsx
  • modules/preview-react/tabs/spec/Tabs.spec.tsx
  • modules/preview-react/tabs/spec/tsconfig.json
  • modules/preview-react/tabs/stories/Tabs.mdx
  • modules/preview-react/tabs/stories/Tabs.stories.ts
  • modules/preview-react/tabs/stories/examples/AlternativeTabStop.tsx
  • modules/preview-react/tabs/stories/examples/Basic.tsx
  • modules/preview-react/tabs/stories/examples/DisabledTab.tsx
  • modules/preview-react/tabs/stories/examples/DynamicTabs.tsx
  • modules/preview-react/tabs/stories/examples/HoistedModel.tsx
  • modules/preview-react/tabs/stories/examples/Icons.tsx
  • modules/preview-react/tabs/stories/examples/NamedTabs.tsx
  • modules/preview-react/tabs/stories/examples/OverflowTabs.tsx
  • modules/preview-react/tabs/stories/examples/RightToLeft.tsx
  • modules/preview-react/tabs/stories/examples/SinglePanel.tsx
  • modules/preview-react/tabs/stories/testing.stories.tsx
  • modules/preview-react/tabs/stories/tsconfig.json


// Using opacity token applied to container to achieve disabled state instead of 'disabled" color tokens for
// icon and text colors
'&:disabled, &.disabled, &[aria-disabled]': {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the attribute selector to prevent false-positive disabled states.

The [aria-disabled] CSS selector matches any value, including "false". If a developer explicitly passes aria-disabled={false} to indicate the tab is active/enabled, it will incorrectly receive disabled styling. Target the "true" value specifically.

🐛 Proposed fix
-    '&:disabled, &.disabled, &[aria-disabled]': {
+    '&:disabled, &.disabled, &[aria-disabled="true"]': {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
'&:disabled, &.disabled, &[aria-disabled]': {
'&:disabled, &.disabled, &[aria-disabled="true"]': {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsItem.tsx` at line 132, Update the
disabled-state selector in TabsItem styling to target only elements whose
aria-disabled attribute equals "true", while preserving the existing :disabled
and .disabled selectors.

Comment on lines +227 to +232
return {
type: 'button' as const,
role: 'tab' as const,
'aria-selected': selected,
'aria-controls': slugify(`tabpanel-${state.id}-${name}`),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align aria-selected behavior with the documented API contract.

The documentation for aria-selected explicitly states it should be "true or undefined and never false". However, the current logic passes boolean false, which React will render as aria-selected="false" in the DOM.

🐛 Proposed fix
     return {
       type: 'button' as const,
       role: 'tab' as const,
-      'aria-selected': selected,
+      'aria-selected': selected || undefined,
       'aria-controls': slugify(`tabpanel-${state.id}-${name}`),
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
type: 'button' as const,
role: 'tab' as const,
'aria-selected': selected,
'aria-controls': slugify(`tabpanel-${state.id}-${name}`),
};
return {
type: 'button' as const,
role: 'tab' as const,
'aria-selected': selected || undefined,
'aria-controls': slugify(`tabpanel-${state.id}-${name}`),
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsItem.tsx` around lines 227 - 232, Update
the `aria-selected` value in the returned tab attributes so unselected tabs
provide `undefined` rather than `false`, while selected tabs remain `true`;
preserve the existing `role`, `aria-controls`, and other attribute behavior.

Comment on lines +254 to +259
<StyledTabItem
as={Element}
maxWidth={modality === 'touch' ? undefined : 280}
{...elemProps}
variant={model.state.variant}
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Allow user-provided props to override the model fallback.

Since TabsItemProps documents variant as an acceptable prop, users should be able to override the group's default variant for a specific tab. Currently, variant={model.state.variant} appears after {...elemProps}, unconditionally overriding any variant the user provides.

Swap the order so the model state acts as a default, while respecting explicit user overrides.

🐛 Proposed fix
       <StyledTabItem
         as={Element}
         maxWidth={modality === 'touch' ? undefined : 280}
-        {...elemProps}
         variant={model.state.variant}
+        {...elemProps}
       >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<StyledTabItem
as={Element}
maxWidth={modality === 'touch' ? undefined : 280}
{...elemProps}
variant={model.state.variant}
>
<StyledTabItem
as={Element}
maxWidth={modality === 'touch' ? undefined : 280}
variant={model.state.variant}
{...elemProps}
>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsItem.tsx` around lines 254 - 259, Update
the StyledTabItem prop order in TabsItem so model.state.variant is applied
before spreading elemProps, allowing an explicitly provided variant to override
the model fallback while preserving the default when no override is supplied.

Comment on lines +36 to +64
function getScrollPercentage(elem: HTMLDivElement, direction: 'vertical' | 'horizontal'): number {
if (direction === 'vertical') {
if (elem.scrollHeight > elem.clientHeight) {
const percentage = elem.scrollTop / (elem.scrollHeight - elem.clientHeight);
return percentage;
} else {
return -1;
}
}
if (elem.scrollWidth > elem.clientWidth) {
const percentage = elem.scrollLeft / (elem.scrollWidth - elem.clientWidth);
return percentage;
} else {
return -1;
}
}

function getScrollPosition(
elem: HTMLDivElement,
direction: 'vertical' | 'horizontal'
): 'start' | 'middle' | 'end' | undefined {
const percentage = getScrollPercentage(elem, direction);
return percentage <= -1
? undefined
: percentage > 0.99
? 'end'
: percentage > 0.01
? 'middle'
: 'start';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect relevant files and nearby context
git ls-files 'modules/preview-react/tabs/lib/*' 'modules/preview-react/tabs/**/*' | sed -n '1,200p'

printf '\n--- TabsList.tsx (outline) ---\n'
ast-grep outline modules/preview-react/tabs/lib/TabsList.tsx --view expanded || true

printf '\n--- TabsList.tsx (relevant lines) ---\n'
cat -n modules/preview-react/tabs/lib/TabsList.tsx | sed -n '1,220p'

printf '\n--- Search for RTL / scroll handling / gradients in tabs module ---\n'
rg -n --hidden --no-ignore-vcs -S "rtl|direction|scrollLeft|scrollRight|gradient|mask-image|maskImage|overflow" modules/preview-react/tabs -g '!**/*.map' -g '!**/dist/**' || true

Repository: Workday/canvas-kit

Length of output: 13332


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect any companion styles/components that may affect RTL overflow masks
fd -a -t f 'TabsList' modules/preview-react/tabs || true
fd -a -t f 'Tab' modules/preview-react/tabs/lib || true

Repository: Workday/canvas-kit

Length of output: 742


Account for RTL in tab overflow positioning
modules/preview-react/tabs/lib/TabsList.tsx:36-64, 133-143 — the horizontal scrollLeft calculation and the start/end masks are LTR-only. In dir="rtl", overflow state can land on the wrong side and the fade can hide the wrong edge. Normalize the horizontal offset and swap the gradient direction for RTL.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsList.tsx` around lines 36 - 64, Update
getScrollPercentage to detect the tabs container’s RTL direction and normalize
horizontal scrollLeft before calculating the percentage, while preserving the
existing vertical behavior. In the horizontal overflow rendering around the
start/end masks, swap the gradient direction for RTL so the fade aligns with the
correct edge; keep LTR positioning unchanged.

Comment on lines +42 to +64
const [localId, setLocalId] = React.useState(elemProps['data-id'] || item?.id || '');

useMountLayout(() => {
if (item) {
return;
}
const defaultId = state.panelIndexRef.current;
const itemId = localId || String(defaultId);
events.registerPanel({id: itemId, textValue: ''});
setLocalId(itemId);

return () => {
events.unregisterPanel({id: itemId});
};
});

return {
role: 'tabpanel' as const,
'aria-labelledby': slugify(`${state.id}-${localId}`),
hidden: !!localId && localId !== state.selectedIds[0],
id: slugify(`tabpanel-${state.id}-${localId}`),
tabIndex: 0 as const,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the target file and nearby tab-related files.
git ls-files 'modules/preview-react/tabs/lib/*' 'modules/preview-react/tabs/*' | sed -n '1,200p'

echo '--- outline TabsPanel.tsx ---'
ast-grep outline modules/preview-react/tabs/lib/TabsPanel.tsx --view expanded || true

echo '--- TabsPanel.tsx lines ---'
wc -l modules/preview-react/tabs/lib/TabsPanel.tsx
cat -n modules/preview-react/tabs/lib/TabsPanel.tsx | sed -n '1,220p'

Repository: Workday/canvas-kit

Length of output: 5042


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect supporting tab state and hooks referenced by TabsPanel.
rg -n "useMountLayout|panelIndexRef|selectedIds|registerPanel|unregisterPanel|slugify" modules/preview-react/tabs -S

echo '--- file list around tabs lib ---'
git ls-files 'modules/preview-react/tabs/lib/*' | sed -n '1,200p'

Repository: Workday/canvas-kit

Length of output: 2320


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- TabsPanel.tsx ---'
sed -n '1,220p' modules/preview-react/tabs/lib/TabsPanel.tsx | nl -ba

echo '--- search for TabsPanel usage / data-id / panelIndexRef ---'
rg -n "TabsPanel|data-id|panelIndexRef|selectedIds|registerPanel|unregisterPanel|useMountLayout" modules/preview-react -S

Repository: Workday/canvas-kit

Length of output: 218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- useTabsModel.tsx ---'
sed -n '1,220p' modules/preview-react/tabs/lib/useTabsModel.tsx

echo '--- TabsPanels.tsx ---'
sed -n '1,220p' modules/preview-react/tabs/lib/TabsPanels.tsx

echo '--- TabsItem.tsx (id/aria-controls refs) ---'
sed -n '200,280p' modules/preview-react/tabs/lib/TabsItem.tsx

Repository: Workday/canvas-kit

Length of output: 6830


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "ListRenderItemContext|createElemPropsHook|useMountLayout|panelIndexRef|selectedIds" modules/preview-react packages -S

Repository: Workday/canvas-kit

Length of output: 4064


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- useTabsModel.tsx ---'
cat -n modules/preview-react/tabs/lib/useTabsModel.tsx | sed -n '1,220p'

echo '--- TabsPanels.tsx ---'
cat -n modules/preview-react/tabs/lib/TabsPanels.tsx | sed -n '1,220p'

echo '--- TabsItem.tsx excerpt ---'
cat -n modules/preview-react/tabs/lib/TabsItem.tsx | sed -n '210,270p'

Repository: Workday/canvas-kit

Length of output: 8132


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "export .*ListRenderItemContext|ListRenderItemContext" . -S

Repository: Workday/canvas-kit

Length of output: 1612


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect collection wrapper and any tests around panel ids / SSR-like behavior.
sed -n '1,220p' modules/preview-react/tabs/lib/TabsPanels.tsx | cat -n

echo '--- specs mentioning panel id / aria / hidden ---'
rg -n "tabpanel-|aria-labelledby|hidden:|selectedIds|data-id|Panel" modules/preview-react/tabs/spec modules/preview-react/tabs/stories -S

Repository: Workday/canvas-kit

Length of output: 11589


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "ListRenderItemContext.Provider|Collection|renderItem|indexRef" modules/preview-react/tabs modules/preview-react/collection -S

Repository: Workday/canvas-kit

Length of output: 414


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- TabsPanels.tsx ---'
cat -n modules/preview-react/tabs/lib/TabsPanels.tsx | sed -n '1,220p'

echo '--- Tabs.tsx ---'
cat -n modules/preview-react/tabs/lib/Tabs.tsx | sed -n '1,240p'

Repository: Workday/canvas-kit

Length of output: 6465


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "<Tabs\\.Panel|TabsPanel|TabsPanels" modules/preview-react/tabs/stories modules/preview-react/tabs/spec -S

Repository: Workday/canvas-kit

Length of output: 6106


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

for p in [
    Path('modules/preview-react/tabs/lib/TabsPanels.tsx'),
    Path('modules/preview-react/tabs/lib/useTabsModel.tsx'),
]:
    print(f'--- {p} ---')
    text = p.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if i <= 220:
            print(f'{i:4d}: {line}')
    print()
PY

Repository: Workday/canvas-kit

Length of output: 6138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- useListRenderItem.tsx ---'
cat -n modules/react/collection/lib/useListRenderItem.tsx | sed -n '1,220p'

echo '--- useListModel related ---'
rg -n "useListModel|indexRef|registerItem|unregisterItem|ListRenderItemContext" modules/react/collection/lib -S

Repository: Workday/canvas-kit

Length of output: 7658


Generate the fallback panel id before the first render. This only affects plain Tabs.Panel children without data-id: they stay at localId === '' until useMountLayout, so server-rendered output gets empty/duplicate id/aria-labelledby values and all panels remain visible. Derive the positional id during render instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/TabsPanel.tsx` around lines 42 - 64, Update
the localId initialization in TabsPanel so panels without data-id or item?.id
derive their positional fallback from state.panelIndexRef.current during render,
before useMountLayout runs. Preserve explicit data-id and item IDs, and keep
registration cleanup aligned with the resolved fallback ID so SSR and
first-render visibility/ARIA attributes are correct.

Comment on lines +119 to +127
const menu = useMenuModel(
useMenuModel.mergeConfig(config.menuConfig, {
id: `menu-${model.state.id}`,
items: overflowItems,
nonInteractiveIds: state.nonInteractiveIds.filter(key => !state.hiddenIds.includes(key)),
onSelect(data) {
menu.events.hide();
events.select(data);
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve disabled state for overflowed tabs.

Line 123 filters out the hidden IDs that actually populate this menu. Consequently, disabled overflowed tabs become selectable, while the retained visible IDs are absent from overflowItems.

Proposed fix
-      nonInteractiveIds: state.nonInteractiveIds.filter(key => !state.hiddenIds.includes(key)),
+      nonInteractiveIds: state.nonInteractiveIds.filter(key => state.hiddenIds.includes(key)),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const menu = useMenuModel(
useMenuModel.mergeConfig(config.menuConfig, {
id: `menu-${model.state.id}`,
items: overflowItems,
nonInteractiveIds: state.nonInteractiveIds.filter(key => !state.hiddenIds.includes(key)),
onSelect(data) {
menu.events.hide();
events.select(data);
},
const menu = useMenuModel(
useMenuModel.mergeConfig(config.menuConfig, {
id: `menu-${model.state.id}`,
items: overflowItems,
nonInteractiveIds: state.nonInteractiveIds.filter(key => state.hiddenIds.includes(key)),
onSelect(data) {
menu.events.hide();
events.select(data);
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/lib/useTabsModel.tsx` around lines 119 - 127,
Update the nonInteractiveIds configuration in useTabsModel’s useMenuModel setup
to preserve disabled state for overflowed tabs: derive it from the IDs
represented by overflowItems rather than filtering against hiddenIds in a way
that excludes them. Ensure disabled overflowed tabs remain non-selectable while
preserving the menu’s existing selection behavior.

Comment on lines +52 to +64
{({hasIcon, variant, ...props}) => (
<Tabs variant={variant}>
{hasIcon ? (
<Tabs.Item {...props}>
<Tabs.Item.Icon icon={configureIcon} />
<Tabs.Item.Text>Icon</Tabs.Item.Text>
</Tabs.Item>
) : (
<Tabs.Item {...props}>Tab</Tabs.Item>
)}
</Tabs>
)}
</ComponentStatesTable>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Wrap Tabs.Item with Tabs.List.

Tabs.Item is meant to be composed inside a Tabs.List component, which usually provides the necessary layout and ARIA attributes (such as role="tablist"). Rendering Tabs.Item directly inside Tabs may result in incorrect visual layout or accessibility structure in these test states.

♻️ Proposed fix to ensure correct structure
         {({hasIcon, variant, ...props}) => (
           <Tabs variant={variant}>
-            {hasIcon ? (
-              <Tabs.Item {...props}>
-                <Tabs.Item.Icon icon={configureIcon} />
-                <Tabs.Item.Text>Icon</Tabs.Item.Text>
-              </Tabs.Item>
-            ) : (
-              <Tabs.Item {...props}>Tab</Tabs.Item>
-            )}
+            <Tabs.List>
+              {hasIcon ? (
+                <Tabs.Item {...props}>
+                  <Tabs.Item.Icon icon={configureIcon} />
+                  <Tabs.Item.Text>Icon</Tabs.Item.Text>
+                </Tabs.Item>
+              ) : (
+                <Tabs.Item {...props}>Tab</Tabs.Item>
+              )}
+            </Tabs.List>
           </Tabs>
         )}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{({hasIcon, variant, ...props}) => (
<Tabs variant={variant}>
{hasIcon ? (
<Tabs.Item {...props}>
<Tabs.Item.Icon icon={configureIcon} />
<Tabs.Item.Text>Icon</Tabs.Item.Text>
</Tabs.Item>
) : (
<Tabs.Item {...props}>Tab</Tabs.Item>
)}
</Tabs>
)}
</ComponentStatesTable>
{({hasIcon, variant, ...props}) => (
<Tabs variant={variant}>
<Tabs.List>
{hasIcon ? (
<Tabs.Item {...props}>
<Tabs.Item.Icon icon={configureIcon} />
<Tabs.Item.Text>Icon</Tabs.Item.Text>
</Tabs.Item>
) : (
<Tabs.Item {...props}>Tab</Tabs.Item>
)}
</Tabs.List>
</Tabs>
)}
</ComponentStatesTable>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/preview-react/tabs/stories/testing.stories.tsx` around lines 52 - 64,
Wrap the conditional Tabs.Item instances in a Tabs.List within the Tabs
component in the ComponentStatesTable render callback. Preserve the existing
hasIcon, variant, and item content behavior while ensuring both branches are
children of Tabs.List.

},
},

'&[aria-selected=true]': {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we add a non-color selected indicator for the filled variant that survives Windows High Contrast / forced-colors themes? Right now the selected state appears to rely on background color only.

(Regression from our Tabs in main because they use a bottom-border on selected state.)

This is a red flag for me. The color difference between selected and unselected states does not meet contrast guidelines and much too difficult to see. CC: @purvas12

@sheelah sheelah Jul 17, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for the callout @williamjstanton - left a question for design on this. I assume that the border that shows on selected tabs is enough of a differentiation for the outlined variant? If so, then would adding a border that's visible on selected tabs for the default (filled) variant when we detect high contrast mode (via '@media (forced-colors: active)' which we use elsewhere) be enough of a visual indication?

)}
>
{useListRenderItems(model, children)}
{overflowButton}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Since this renders a non-tab menu button directly inside role="tablist", can we confirm the ARIA pattern here or document the exception? Existing tests suppress aria-required-children for this structure.”

The changes that I'd really like to see are:

  1. Include this overflow button into the collection system so the roving tabindex right / left arrow key interaction can work on this "More" tab,
  2. Set required role="tab"
  3. Set aria-haspopup="menu"

I worked on these changes here in my branch:
williamjstanton/canvas-kit/tree/william-3558-overflow-tabs-fixes

Issue: #3558


### Named Tabs

`Tabs.Item` and `Tabs.Panel` both take an optional `data-id` attribute that is used for the

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this say onSelect instead of onActivate to match the model API?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good catch! Fixed 👍🏻

*/
'aria-controls'?: string;
/**
* Part of the ARIA specification for tabs. Lets screen readers know which tab is active. This

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This says aria-selected should never be false, but useTabsItem returns false for inactive tabs. I think the implementation is correct according to W3C spec. We just need to update this comment and any related docs.

Spec: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks William! That sounds right to me -- Removed that part of the comment.

@sheelah sheelah added the ready for review Code is ready for review label Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-a11y Extra attention from accessibility is needed ready for review Code is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants