Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
abfe9c4
feat(ActionBar): add data-component attributes for better accessibility
francinelucca Apr 2, 2026
6381280
fix test
francinelucca Apr 2, 2026
091de76
Merge branch 'main' of github.com:primer/react into chore/implement-a…
francinelucca Apr 7, 2026
32d4062
fix(ActionBar): update data-component attributes for IconButton and Menu
francinelucca Apr 7, 2026
c7c0da7
Add data-component for ActionList
francinelucca Apr 8, 2026
4cf2494
fix(ActionList): add data-component attributes for Item and LinkItem
francinelucca Apr 8, 2026
2962363
test(Tooltip): add tests for data-component attributes in Tooltip and…
francinelucca Apr 8, 2026
3f92c21
Merge branch 'main' of github.com:primer/react into chore/implement-a…
francinelucca Apr 9, 2026
273f681
fix: update data-component attribute for GroupHeading to GroupHeading…
francinelucca Apr 9, 2026
8c38035
fix comment
francinelucca Apr 9, 2026
8c3d244
Merge branch 'main' into chore/implement-adr-023
francinelucca Apr 13, 2026
8ca6eac
add data-component attribute to Pagination
francinelucca Apr 13, 2026
ddca738
Merge branch 'chore/implement-adr-023' of github.com:primer/react int…
francinelucca Apr 13, 2026
6f96ebd
Add data-component attributes to TextInput components for improved ac…
francinelucca Apr 13, 2026
17f2bf0
test: add nested ActionList data-component attribute tests for Filter…
francinelucca Apr 13, 2026
ff94d66
Merge branch 'main' into chore/implement-adr-023
francinelucca Apr 14, 2026
79e42d4
Merge branch 'chore/implement-adr-023' of github.com:primer/react int…
francinelucca Apr 14, 2026
b05df83
test: enhance data-component attribute tests for FilteredActionList a…
francinelucca Apr 14, 2026
db67cc2
test: add data-component attribute tests for Table and Pagination com…
francinelucca Apr 14, 2026
e71ce27
test: add data-component attribute tests for Table.Header and Table.S…
francinelucca Apr 14, 2026
f2b0a1f
test: add data-component attribute to Button and ButtonReset components
francinelucca Apr 14, 2026
e94fe52
test: add data-component attribute for LinkButton and its tests
francinelucca Apr 14, 2026
8c886da
test: add data-component attribute to Link component and its tests
francinelucca Apr 14, 2026
76bb50d
test: add data-component attributes for SelectPanel.CloseButton and S…
francinelucca Apr 14, 2026
bba65c1
test: add stable data-component selectors to DataTable, Button, Link,…
francinelucca Apr 14, 2026
9aaead8
rearrange stuff
francinelucca Apr 14, 2026
8429e82
chore: auto-fix lint and formatting issues
francinelucca Apr 14, 2026
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
20 changes: 20 additions & 0 deletions .changeset/datatable-stable-selectors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@primer/react': minor
---

Add stable `data-component` selectors to multiple components following ADR-023:

- **ActionBar**
- **ActionList** and friends
- **Button**
- **FilteredActionList** and friends
- **Link**
- **LinkButton**
- **Pagination**
- **SelectPanel** and friends
- **Table** and friends
- **TextInput**
- **TextInputwithTokens**
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Component name casing: TextInputwithTokens should be TextInputWithTokens to match the exported component name.

Suggested change
- **TextInputwithTokens**
- **TextInputWithTokens**

Copilot uses AI. Check for mistakes.
- **TooltipV2**

This enables consumers to query and test components using stable selectors like `[data-component="Table.Row"]`.
62 changes: 62 additions & 0 deletions packages/react/src/ActionBar/ActionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,65 @@ describe('ActionBar.Menu returnFocusRef', () => {
expect(document.activeElement).toEqual(menuButton)
})
})

describe('ActionBar data-component attributes', () => {
it('renders ActionBar with data-component attribute', () => {
const {container} = render(
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
</ActionBar>,
)

const actionBar = container.querySelector('[data-component="ActionBar"]')
expect(actionBar).toBeInTheDocument()
})

it('renders ActionBar.IconButton with data-component attribute', () => {
const {container} = render(
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
</ActionBar>,
)

const iconButton = container.querySelector('[data-component="ActionBar"] [data-component="IconButton"]')
expect(iconButton).toBeInTheDocument()
})

it('renders ActionBar.VerticalDivider with data-component attribute', () => {
const {container} = render(
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.Divider />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)

const divider = container.querySelector('[data-component="ActionBar.VerticalDivider"]')
expect(divider).toBeInTheDocument()
})

it('renders ActionBar.Group with data-component attribute', () => {
const {container} = render(
<ActionBar aria-label="Toolbar">
<ActionBar.Group>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar.Group>
</ActionBar>,
)

const group = container.querySelector('[data-component="ActionBar.Group"]')
expect(group).toBeInTheDocument()
})

it('renders ActionBar.Menu.IconButton with data-component attribute', () => {
render(
<ActionBar aria-label="Toolbar">
<ActionBar.Menu aria-label="More options" icon={BoldIcon} items={[{label: 'Option 1', onClick: vi.fn()}]} />
</ActionBar>,
)

const menuButton = screen.getByRole('button', {name: 'More options'})
expect(menuButton).toHaveAttribute('data-component', 'ActionBar.Menu.IconButton')
})
})
13 changes: 10 additions & 3 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop

return (
<ActionBarContext.Provider value={{size, isVisibleChild}}>
<div ref={navRef} className={clsx(className, styles.Nav)} data-flush={flush}>
<div ref={navRef} className={clsx(className, styles.Nav)} data-component="ActionBar" data-flush={flush}>
<div
ref={listRef}
role="toolbar"
Expand Down Expand Up @@ -532,7 +532,7 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f

return (
<ActionBarGroupContext.Provider value={{groupId: id}}>
<div className={styles.Group} ref={ref}>
<div className={styles.Group} data-component="ActionBar.Group" ref={ref}>
{children}
</div>
</ActionBarGroupContext.Provider>
Expand Down Expand Up @@ -571,7 +571,14 @@ export const ActionBarMenu = forwardRef(
return (
<ActionMenu anchorRef={ref} open={menuOpen} onOpenChange={setMenuOpen}>
<ActionMenu.Anchor>
<IconButton variant="invisible" aria-label={ariaLabel} icon={icon} {...props} />
<IconButton
variant="invisible"
aria-label={ariaLabel}
icon={icon}
{...props}
// overriding IconButton's data-component so that the ActionBar's "More Menu" Icon can be targetted specifically
Copy link

Copilot AI Apr 14, 2026

Choose a reason for hiding this comment

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

Spelling: “targetted” should be “targeted”.

Suggested change
// overriding IconButton's data-component so that the ActionBar's "More Menu" Icon can be targetted specifically
// overriding IconButton's data-component so that the ActionBar's "More Menu" Icon can be targeted specifically

Copilot uses AI. Check for mistakes.
data-component="ActionBar.Menu.IconButton"
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

should this be:

  • ActionBar.MenuIconButton
  • ActionBar.Menu
  • ActionBar.Menu.IconButton
    ?

I feel like this is the IconButton that belongs to the Menu that is a subcomponent of ActionBar, which is why ActionBar.Menu.IconButton makes sense to me 🤔. ActionBar.Menu seems disingenuous because really this is not the menu itself, just the Icon trigger. But I could see the case for ActionBar.MenuIconButton

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.

I vote for ActionBar.Menu.IconButton too!

Copy link
Copy Markdown
Member

@siddharthkp siddharthkp Apr 8, 2026

Choose a reason for hiding this comment

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

tl;dr: both ActionBar.MenuIconButton and ActionBar.Menu.IconButton are fine, i like one more than the other

  • ActionBar.Menu 👎 that's for the menu, not the button
  • ActionBar.Menu.IconButton it's fine, but the double dots feels weird, idk why. Maybe because ActionBar.Menu is actually a shorthand for ActionMenu... but ActionBar.ActionMenu.IconButton feels worse for some reason.
  • ActionBar.MenuIconButton: 👍 I like this the most. consistent pattern everywhere

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

what about here
image

you vote for ActionList.ItemLabel?

Copy link
Copy Markdown
Member

@siddharthkp siddharthkp Apr 9, 2026

Choose a reason for hiding this comment

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

Hmmmmmm, I know i'm being very inconsistent with my feeling or taste™, but i like ActionList.Item.Label there because ActionList.Item is where you would put your className 🤔

❌ bad:

<ActionList>
  <ActionList.Item><span className={styles.myLabel}>label</span></ActionList.Item>
</ActionList>

<style>
  .myLabel {
    font-weight: bold
  }
</style>

✅ good:

<ActionList>
  <ActionList.Item className={styles.myActionListItem}>label</ActionList.Item>
</ActionList>

<style>
  // with direct data-component, good!:
  .myActionListItem [data-component="ActionList.Item.Label"] {
    font-weight: bold
  }
</style>

I think I was wrong about ActionBar.Menu.IconButton. If ActionBar.Menu is part of the public API, then ActionBar.Menu.IconButton should be the better choice.

Do you mind also including Pagination and SelectPanel in this PR? I think those are the most opaque components, once we go through them, all the rest will be straightforward.

/>
</ActionMenu.Anchor>
<ActionMenu.Overlay {...(returnFocusRef && {returnFocusRef})}>
<ActionList>{items.map((item, index) => renderMenuItem(item, index))}</ActionList>
Expand Down
177 changes: 177 additions & 0 deletions packages/react/src/ActionList/ActionList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,3 +258,180 @@ describe('ActionList', () => {
expect(linkElements[2]).toHaveAttribute('data-size', 'medium') // default should be medium
})
})

describe('ActionList data-component attributes', () => {
it('renders ActionList with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const actionList = container.querySelector('[data-component="ActionList"]')
expect(actionList).toBeInTheDocument()
})

it('renders ActionList.Item with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const item = container.querySelector('[data-component="ActionList.Item"]')
expect(item).toBeInTheDocument()
})

it('renders ActionList.Item.Wrapper with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const wrapper = container.querySelector('[data-component="ActionList.Item.Wrapper"]')
expect(wrapper).toBeInTheDocument()
})

it('renders ActionList.Item.Label with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const label = container.querySelector('[data-component="ActionList.Item.Label"]')
expect(label).toBeInTheDocument()
})

it('renders ActionList.Item--DividerContainer with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const dividerContainer = container.querySelector('[data-component="ActionList.Item--DividerContainer"]')
expect(dividerContainer).toBeInTheDocument()
})

it('renders ActionList.Group with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Group>
<ActionList.GroupHeading as="h3">Group</ActionList.GroupHeading>
<ActionList.Item>Item</ActionList.Item>
</ActionList.Group>
</ActionList>,
)

const group = container.querySelector('[data-component="ActionList.Group"]')
expect(group).toBeInTheDocument()
})

it('renders ActionList.GroupHeading with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Group>
<ActionList.GroupHeading as="h3">Group Heading</ActionList.GroupHeading>
<ActionList.Item>Item</ActionList.Item>
</ActionList.Group>
</ActionList>,
)

const groupHeading = container.querySelector('[data-component="GroupHeadingWrap"]')
expect(groupHeading).toBeInTheDocument()
})

it('renders ActionList.Divider with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>Item 1</ActionList.Item>
<ActionList.Divider />
<ActionList.Item>Item 2</ActionList.Item>
</ActionList>,
)

const divider = container.querySelector('[data-component="ActionList.Divider"]')
expect(divider).toBeInTheDocument()
})

it('renders ActionList.Description with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>
Item
<ActionList.Description>Description</ActionList.Description>
</ActionList.Item>
</ActionList>,
)

const description = container.querySelector('[data-component="ActionList.Description"]')
expect(description).toBeInTheDocument()
})

it('renders ActionList.LeadingVisual with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>
<ActionList.LeadingVisual>Icon</ActionList.LeadingVisual>
Item
</ActionList.Item>
</ActionList>,
)

const leadingVisual = container.querySelector('[data-component="ActionList.LeadingVisual"]')
expect(leadingVisual).toBeInTheDocument()
})

it('renders ActionList.TrailingVisual with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>
Item
<ActionList.TrailingVisual>Icon</ActionList.TrailingVisual>
</ActionList.Item>
</ActionList>,
)

const trailingVisual = container.querySelector('[data-component="ActionList.TrailingVisual"]')
expect(trailingVisual).toBeInTheDocument()
})

it('renders ActionList.Selection with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList selectionVariant="single" aria-label="List">
<ActionList.Item selected>Item</ActionList.Item>
</ActionList>,
)

const selection = container.querySelector('[data-component="ActionList.Selection"]')
expect(selection).toBeInTheDocument()
})

it('renders ActionList.Heading with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Heading as="h2">Heading</ActionList.Heading>
<ActionList.Item>Item</ActionList.Item>
</ActionList>,
)

const heading = container.querySelector('[data-component="ActionList.Heading"]')
expect(heading).toBeInTheDocument()
})

it('renders ActionList.TrailingAction with data-component attribute', () => {
const {container} = HTMLRender(
<ActionList aria-label="List">
<ActionList.Item>
Item
<ActionList.TrailingAction label="Action" />
</ActionList.Item>
</ActionList>,
)

const trailingAction = container.querySelector('[data-component="ActionList.TrailingAction"]')
expect(trailingAction).toBeInTheDocument()
})
})
9 changes: 8 additions & 1 deletion packages/react/src/ActionList/Group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,12 @@ export const Group: FCWithSlotMarker<React.PropsWithChildren<ActionListGroupProp
}

return (
<li className={clsx(className, groupClasses.Group)} role={listRole ? 'none' : undefined} {...props}>
<li
className={clsx(className, groupClasses.Group)}
data-component="ActionList.Group"
role={listRole ? 'none' : undefined}
{...props}
>
<GroupContext.Provider value={{selectionVariant, groupHeadingId}}>
{title && !slots.groupHeading ? (
// Escape hatch: supports old API <ActionList.Group title="group title"> in a non breaking way
Expand Down Expand Up @@ -177,6 +182,7 @@ export const GroupHeading: FCWithSlotMarker<React.PropsWithChildren<ActionListGr
className={groupClasses.GroupHeadingWrap}
aria-hidden="true"
data-variant={variant}
// TODO: next-major: switch for data-component="ActionList.GroupHeading" next major
data-component="GroupHeadingWrap"
as={headingWrapElement}
{...props}
Expand All @@ -192,6 +198,7 @@ export const GroupHeading: FCWithSlotMarker<React.PropsWithChildren<ActionListGr
className={groupClasses.GroupHeadingWrap}
data-variant={variant}
as={headingWrapElement}
// TODO: next-major: switch for data-component="ActionList.GroupHeading" next major
data-component="GroupHeadingWrap"
>
<Heading
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/ActionList/Heading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const Heading = forwardRef(({as, size, children, visuallyHidden = false,
// use custom id if it is provided. Otherwise, use the id from the context
id={props.id ?? headingId}
className={clsx(className, classes.ActionListHeader)}
data-component="ActionList.Heading"
data-list-variant={listVariant}
{...props}
>
Expand Down
Loading
Loading