diff --git a/contributor-docs/adrs/adr-023-stable-selectors-api.md b/contributor-docs/adrs/adr-023-stable-selectors-api.md new file mode 100644 index 00000000000..bca1a928831 --- /dev/null +++ b/contributor-docs/adrs/adr-023-stable-selectors-api.md @@ -0,0 +1,353 @@ +# Stable identifiers for Primer components + +📆 Date: 2026-02-20 + +## Status + +| Stage | State | +| -------------- | ----------- | +| Status | Accepted ✅ | +| Implementation | Pending ⚠️ | + +## Context + +Primer React components do not expose stable identifiers for their rendered DOM +elements. Consumers who need to reference a specific component or slot in the +DOM — for testing, tracking, monitoring, or querying — have no reliable way to +do so. + +This creates problems across several areas: + +- **Testing.** Unit and end-to-end tests resort to brittle selectors like CSS + Module hashes (`prc-ActionList-Item-cBBI`), DOM structure assumptions, or + text content matching — all of which break silently when Primer is updated. +- **Tracking and monitoring.** Consumers that want to measure how components are + used in production (e.g., counting how many ActionList items appear on a page) + have no stable hook to query against. +- **JavaScript queries.** Finding all instances of a component or its parts in + the DOM (e.g., all selected items in an action list) requires knowledge of + internal implementation details. +- **Style overrides.** When consumers do need to customize appearance, CSS + Module hashes are not stable across versions, leaving no reliable selector to + target. + +The `data-component` attribute is already used internally across multiple +components (`Button`, `ActionList`, `PageHeader`, `UnderlineNav`, `Avatar`, and +others) for internal CSS targeting and DOM queries. However, the naming +convention is inconsistent: + +| Pattern | Examples | +| -------------------- | ------------------------------------------------ | +| `ComponentName.Part` | `ActionList.Description`, `ActionList.Selection` | +| `PREFIX_Part` | `PH_LeadingAction`, `PH_Title`, `PH_Navigation` | +| `camelCase` | `buttonContent`, `leadingVisual`, `text` | +| `PascalCase` | `Avatar`, `IconButton`, `SkeletonText` | + +Because `data-component` is not documented as a public API, values have changed +without notice and coverage is incomplete — many component parts have no +`data-component` attribute at all. + +## Decision + +Establish a **public, stable data attribute** that acts as a reliable +identifier for Primer components in the DOM. Think of this as +a built-in test ID — a stable, predictable anchor that consumers can use for +testing, tracking, monitoring, and querying without coupling to internal DOM +structure. + +- **`data-component`** — identifies the root element of a component, + sub-component, or internal part. + +This attribute is primarily intended for: + +1. **Testing** — use them as locators in unit tests, integration tests, and + end-to-end tests instead of brittle class names or DOM paths. +2. **Tracking and monitoring** — query the DOM for component usage metrics, + analytics, or observability. +3. **JavaScript queries** — find specific components or parts + programmatically (e.g., + `document.querySelectorAll('[data-component="ActionList.Item"]')`). +4. **Simple CSS overrides** — as an added benefit, these selectors also + provide a stable target for style customizations when needed. + +### Implementation details + +#### Naming convention + +All values use PascalCase: + +``` +data-component="ComponentName" → root element of a component, sub-component, or internal part +``` + +##### Rules + +1. **Root components** get `data-component` with their React component name. + + ```html + + ``` + +2. **Public sub-components** get `data-component` matching the React API. If + consumers write ``, the DOM element gets + `data-component="ActionList.Item"`. + + ```html +
  • + ``` + +3. **Inner structural parts** (DOM elements that are not exposed as a + sub-component but represent a meaningful part of the structure) get + `data-component` with the parent component name and a PascalCase part name. + + ```html + monalisa + ... + + ``` + + Part names are scoped to their parent component via the naming convention — + `ActionList.Item.Label` is distinct from `Button.Label`. + +4. **State and modifier attributes remain separate.** `data-component` + identifies _what_ an element is. Existing attributes like + `data-variant`, `data-size`, and `data-loading` describe the _state_ of that + element. These concerns must not be mixed. + + ```html +
  • + Delete file +
  • + ``` + +#### `data-component` on all components and appropriate internals + +All Primer components, slots and meaningful parts will receive a `data-component` attribute on their root. This +attribute serves as the stable identifier that consumers can use for targeting, +ensuring full coverage across the library. + +Elements that are purely for layout and have no semantic meaning (spacers, +wrappers that exist only for CSS grid/flex layout) do not require this +attribute. + +#### ESLint rule in `eslint-plugin-primer-react` + +A new ESLint rule will be added to `eslint-plugin-primer-react` that **warns** +whenever a `data-component` selector is used in consumer code. The warning will +inform consumers of the implications of relying on these selectors — namely that +the surrounding DOM structure, attributes, parents, and children of the targeted +element are not guaranteed to be stable. + +Consumers must **explicitly override** the +rule on a per-usage basis to use a `data-component` selector. Overriding the +rule acts as an acknowledgment that the consumer understands the risks and +accepts responsibility for any breakage caused by structural changes to the +component's DOM. + +```js +/* eslint-disable primer-react/no-data-component-selector -- + Intentionally targeting ActionList.Item for custom border radius. + We accept that the surrounding DOM may change. */ +``` + +#### Internal CSS usage + +Components may use `data-component` selectors in their own CSS Modules for targeting +child parts. This replaces ad-hoc patterns like bare `[data-component='text']` +with the standardized naming: + +```css +/* ButtonBase.module.css */ +& :where([data-component='Button.LeadingVisual']) { + color: var(--button-leadingVisual-fgColor); +} +``` + +#### Testing requirements + +The presence and values of `data-component` attributes must be +covered by tests. This can be achieved through: + +- Unit tests that assert the attributes are present on rendered elements +- Snapshot tests that capture the attribute values + +#### Documentation on Primer Docs + +A dedicated section will be added to Primer Docs explaining what the stable +selectors are intended for and what they are not intended for. + +**Intended use cases:** + +- **Unit tests** — targeting components and internals in test assertions + (e.g., `getByAttribute('data-component', 'ActionList.Item')`) +- **End-to-end testing** — using stable selectors in Playwright or Cypress + locators instead of brittle class names or DOM structure +- **Simple CSS selectors** — directly targeting a component or part for style + overrides (e.g., `[data-component="Button"] { border-radius: 8px; }`) + +**Not intended for:** + +- **Complex CSS selector chaining** — sibling selectors (`~`, `+`), child + combinators (`>`), or parent selectors (`:has()`) that depend on DOM structure +- **CSS hacks** — workarounds that rely on the internal layout, nesting, or + ordering of elements within a component +- **Any code that assumes a specific DOM structure** — the DOM tree around a + `data-component` element (its parent, children, siblings, and attributes) is + not part of the public API and may change without notice + +The best solution is always to work alongside Primer for your use case. If you +find yourself needing overly complex selectors or targeting `data-component` +attributes for something bigger than their intended use, chances are there is a +better approach. The Primer team is happy to help, guide, and assist with +whatever your use case may be. The design system is always growing — if we are +not covering your use case, we would love to hear from you, or even better, +accept a contribution! + +### Relationship to CSS Modules and CSS Layers + +While the primary purpose of `data-component` is identification +(testing, tracking, querying), it also serves as a stable selector for CSS +overrides when consumers need to customize appearance. + +In that context, it complements the existing styling architecture: + +- **CSS Modules** provide scoped class names for internal styling. Components + continue to use CSS Module classes for their own styles. +- **CSS Layers** ([ADR-021](./adr-021-css-layers.md)) ensure that consumer + overrides take precedence over component styles regardless of specificity. +- **`data-component`** provides the stable selectors that + consumers can use to target components and their parts within those overrides. + +Example of a simple, supported override: + +> **Note:** This is an example for demo purposes only. In practice, +> `ActionList.Item` accepts a `className` prop, which is better suited for this +> type of override. You should always prefer the `className` prop over a stable +> selector when possible. + +```css +[data-component='ActionList.Item'] { + border-radius: 8px; +} +``` + +### Versioning and breaking changes + +The **only** guarantee provided for `data-component` attributes is +that they **exist** and are **tied to the most relevant DOM element** for each +component and its internals. + +The following aspects are **not** part of the public API and may change at any +time without notice or a major semver bump: + +- The specific DOM element a `data-component` attribute is + applied to +- The attributes, parent, or children of that element + +Because of this, consumers should only rely on the **direct targeting** of +these selectors (e.g., `[data-component="ActionList.Item"]`). Chaining +selectors that depend on DOM structure — such as parent, child, or sibling +selectors — is **not supported** and may break without warning. + +| Change | semver bump | +| ------------------------------------------------------ | ------------- | +| A `data-component` attribute is added to an element | `minor` | +| A `data-component` value is renamed | `major` | +| A `data-component` attribute is removed | `major` | +| The DOM element an attribute is applied to changes | `minor` | +| Attributes, parents, or children of the element change | `patch/minor` | + +The [Migration](#migration) table below captures the full set of renames +planned for the next major release. + +### Migration + +Existing `data-component` values must be migrated to the new convention. This +migration is a breaking change and should be coordinated as part of a major +release. + +| Current value | New value | +| --------------------------------------- | ---------------------------- | +| `buttonContent` | `Button.Content` | +| `text` (in Button) | `Button.Label` | +| `leadingVisual` (in Button) | `Button.LeadingVisual` | +| `trailingVisual` (in Button) | `Button.TrailingVisual` | +| `trailingAction` (in Button) | `Button.TrailingAction` | +| `ButtonCounter` | `Button.Counter` | +| `PH_LeadingAction` | `PageHeader.LeadingAction` | +| `PH_Breadcrumbs` | `PageHeader.Breadcrumbs` | +| `PH_LeadingVisual` | `PageHeader.LeadingVisual` | +| `PH_Title` | `PageHeader.Title` | +| `PH_TrailingVisual` | `PageHeader.TrailingVisual` | +| `PH_TrailingAction` | `PageHeader.TrailingAction` | +| `PH_Actions` | `PageHeader.Actions` | +| `PH_Navigation` | `PageHeader.Navigation` | +| `TitleArea` | `PageHeader.TitleArea` | +| `GroupHeadingWrap` | `ActionList.GroupHeading` | +| `ActionList.Item--DividerContainer` | `ActionList.Item.SubContent` | +| `icon` (in UnderlineTabbedInterface) | `UnderlineNav.Item.Icon` | +| `text` (in UnderlineTabbedInterface) | `UnderlineNav.Item.Label` | +| `counter` (in UnderlineTabbedInterface) | `UnderlineNav.Item.Counter` | +| `multilineContainer` | `TextInput.Container` | +| `input` (in TextInput) | `TextInput.Input` | +| `AnchoredOverlay` | `AnchoredOverlay` | +| `ActionBar.VerticalDivider` | `ActionBar.VerticalDivider` | + +Components that currently have no attributes on key parts must also be updated. + +## Consequences + +### Positive + +- **Stable identifiers for testing.** Consumers can use `data-component` + as reliable locators in unit tests, integration tests, and + end-to-end tests — no more coupling to CSS Module hashes or DOM structure. +- **Enables tracking and monitoring.** Consumers can query the DOM for component + usage metrics and observability without relying on implementation details. +- **Enables JavaScript queries.** Consumers and tests can use + `querySelectorAll('[data-component="ActionList.Item"]')` reliably. +- **Consistent naming.** A single convention replaces four inconsistent patterns, + making the codebase easier to learn and maintain. +- **Unified approach.** All elements — whether root components, sub-components, + or internal parts — use `data-component`, providing a simple and consistent + mental model. +- **Supports CSS overrides.** Consumers who need to + customize styles have stable selectors to target, complementing CSS Layers + (ADR-021) for a complete override path. + +### Negative + +- **Breaking change for existing consumers.** Anyone currently relying on the + undocumented `data-component` values (e.g., in tests, tracking code, CSS + overrides, or `querySelector` calls) will need to update when values are + renamed. This must be coordinated in a major release. + +## Alternatives + +### 1. Stable class names alongside CSS Module classes + +Add a non-hashed class name to every part (e.g., +`className={clsx(classes.Item, 'ActionList-item')}`). + +**Why not chosen:** Pollutes the global CSS namespace. Risk of collisions with +consumer or third-party styles. Requires consumers to understand which class +names are "stable" vs. which are CSS Module hashes. Data attributes are a +cleaner separation of concerns — class names for styling, data attributes for +identification. + +### 2. CSS `::part()` pseudo-element + +The `::part()` CSS pseudo-element allows styling of elements inside a shadow +DOM. + +**Why not chosen:** Only works with Shadow DOM, which React does not use. Not +applicable to our architecture. + +### 3. Do nothing — keep `data-component` as an internal implementation detail + +Continue using `data-component` informally without guaranteeing stability. + +**Why not chosen:** Consumers are already depending on these attributes in +tests, tracking code, and CSS overrides. Without a stability guarantee, any +refactor can silently break consumer code. Formalizing the API acknowledges the +reality and provides a proper contract. diff --git a/contributor-docs/versioning.md b/contributor-docs/versioning.md index 733e8538be9..ae61443768a 100644 --- a/contributor-docs/versioning.md +++ b/contributor-docs/versioning.md @@ -7,17 +7,19 @@ ## Table of Contents -- [Overview](#overview) -- [Changes](#changes) -- [Reference](#reference) - - [The type of a prop is broadened](#the-type-of-a-prop-is-broadened) - - [The type of a prop is narrowed](#the-type-of-a-prop-is-narrowed) - - [The `display` property used for the container of `children` is changed](#the-display-property-used-for-the-container-of-children-is-changed) - - [A component changes its usage of a CSS Custom Property](#a-component-changes-its-usage-of-a-css-custom-property) - - [A component includes a landmark role](#a-component-includes-a-landmark-role) - - [A component no longer includes a landmark role](#a-component-no-longer-includes-a-landmark-role) - - [The element onto which props are spread is changed](#the-element-onto-which-props-are-spread-is-changed) - - [The element type in an event handler becomes broader](#the-element-type-in-an-event-handler-becomes-broader) +- [Versioning](#versioning) + - [Table of Contents](#table-of-contents) + - [Overview](#overview) + - [Changes](#changes) + - [Reference](#reference) + - [The type of a prop is broadened](#the-type-of-a-prop-is-broadened) + - [The type of a prop is narrowed](#the-type-of-a-prop-is-narrowed) + - [The `display` property used for the container of `children` is changed](#the-display-property-used-for-the-container-of-children-is-changed) + - [A component changes its usage of a CSS Custom Property](#a-component-changes-its-usage-of-a-css-custom-property) + - [A component includes a landmark role](#a-component-includes-a-landmark-role) + - [A component no longer includes a landmark role](#a-component-no-longer-includes-a-landmark-role) + - [The element onto which props are spread is changed](#the-element-onto-which-props-are-spread-is-changed) + - [The element type in an event handler becomes broader](#the-element-type-in-an-event-handler-becomes-broader) @@ -68,6 +70,11 @@ For a full list of releases, visit our [releases](https://github.com/primer/reac | | [A component changes its usage of a CSS Custom Property](#a-component-changes-its-usage-of-a-css-custom-property) | potentially `major` | | Accessibility | [A component includes a landmark role](#a-component-includes-a-landmark-role) | potentially `major` | | | [A component no longer includes a landmark role](#a-component-no-longer-includes-a-landmark-role) | potentially `major` | +| Data attrs | A `data-component` attribute is added to an element ([ADR-023](./adrs/adr-023-stable-selectors-api.md)) | `minor` | +| | A `data-component` value is renamed | `major` | +| | A `data-component` attribute is removed | `major` | +| | The DOM element an attribute is applied to changes | `minor` | +| | Attributes, parents, or children of the element change | `patch/minor` | ## Reference