From 40785348ccbe0c4edf0122ab0c2260f58c838e28 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Wed, 28 Oct 2026 14:54:08 -0700 Subject: [PATCH 1/5] fix(Item, Token): a control inside rings itself, not the row or token too architecture:interaction-modality INV3: one focus move has one owning indicator. Item and Token drew their ring with focusWithin (:has(:focus-visible)), which matches any focused descendant, so a control in an Item's start or end slot, or a Token's remove button, drew its own ring and the row or token drew a second around it; a row with no action of its own rang too. A new shared style, focusWithinOwner, rings a wrapper only for the direct child marked data-astryx-focus-owner (FOCUS_OWNER_ATTRIBUTE). Item marks its invisible button and link (delegation mode keeps focusWithin: the nested control is the row's sole tab stop by contract); Token marks its button and link. Other controls ring themselves. New stories (Item With End Actions, Token Clickable With Remove) and FocusRingOwner.a11y.chromium.spec.ts: one ring at every tab stop at 1280 and 390 px; it fails on main (two rings while Share or Remove is focused). focusRings.ts is a shared helper for such specs. --- .changeset/focus-ring-one-owner-item-token.md | 7 ++ apps/storybook/stories/Item.stories.tsx | 40 ++++++++++ apps/storybook/stories/Token.stories.tsx | 14 ++++ packages/core/src/Item/Item.tsx | 15 +++- packages/core/src/Token/Token.tsx | 19 ++++- packages/core/src/Token/TokenLink.tsx | 6 +- .../FocusRingOwner.a11y.chromium.spec.ts | 80 +++++++++++++++++++ packages/core/src/__tests__/focusRings.ts | 51 ++++++++++++ .../core/src/utils/focusOutline.stylex.ts | 35 ++++++++ 9 files changed, 260 insertions(+), 7 deletions(-) create mode 100644 .changeset/focus-ring-one-owner-item-token.md create mode 100644 packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts create mode 100644 packages/core/src/__tests__/focusRings.ts diff --git a/.changeset/focus-ring-one-owner-item-token.md b/.changeset/focus-ring-one-owner-item-token.md new file mode 100644 index 0000000000000..fc72461f05355 --- /dev/null +++ b/.changeset/focus-ring-one-owner-item-token.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Focusing a control inside an `Item` (an action in `startContent` or `endContent`) or a `Token`'s remove button no longer also rings the whole row or token: each tab stop shows one focus ring. A row or token rings only for its own action, and a row without one draws no ring. + +@cixzhang diff --git a/apps/storybook/stories/Item.stories.tsx b/apps/storybook/stories/Item.stories.tsx index eb8c2d3655b28..3e702e7474059 100644 --- a/apps/storybook/stories/Item.stories.tsx +++ b/apps/storybook/stories/Item.stories.tsx @@ -3,6 +3,7 @@ import {useState, type ReactNode} from 'react'; import * as stylex from '@stylexjs/stylex'; import type {Meta, StoryObj} from '@storybook/react'; +import {IconButton} from '@astryxdesign/core/IconButton'; import {expect} from 'storybook/test'; import {Item} from '@astryxdesign/core/Item'; import {Avatar} from '@astryxdesign/core/Avatar'; @@ -36,6 +37,8 @@ import { ArchiveBoxIcon, EnvelopeIcon, TrashIcon, + ShareIcon, + ArrowUturnLeftIcon, } from '@heroicons/react/24/outline'; const meta: Meta = { @@ -249,6 +252,43 @@ export const SearchResults: Story = { }; /** Disabled items. */ +/** + * Rows holding a control of their own in `endContent`: a row with an action + * rings for that action, a row without one draws no ring, and the end + * control rings itself, so each tab stop shows one focus ring. + */ +export const WithEndActions: Story = { + render: () => ( +
+ {}} + endContent={ + } + variant="ghost" + size="sm" + /> + } + /> + } + variant="ghost" + size="sm" + /> + } + /> +
+ ), +}; + export const Disabled: Story = { render: () => ( diff --git a/apps/storybook/stories/Token.stories.tsx b/apps/storybook/stories/Token.stories.tsx index c9e76d2796734..63e14350eaa3b 100644 --- a/apps/storybook/stories/Token.stories.tsx +++ b/apps/storybook/stories/Token.stories.tsx @@ -148,6 +148,20 @@ export const Clickable: Story = { ), }; +/** + * A token with its own action and a remove button: each of the two tab stops + * shows one focus ring, the token's around the token and the remove button's + * around the X. + */ +export const ClickableWithRemove: Story = { + render: () => ( +
+ {}} onRemove={() => {}} /> + {}} /> +
+ ), +}; + export const AsLink: Story = { render: () => (
diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 34b264d00124d..0ecafd6c88ad3 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -51,7 +51,10 @@ import {useLinkComponent} from '../Link/useLinkComponent'; import {useClickableContainer} from '../hooks/useClickableContainer'; import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; -import {focusOutlineProps} from '../utils/focusOutline.stylex'; +import { + FOCUS_OWNER_ATTRIBUTE, + focusOutlineProps, +} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; @@ -770,6 +773,7 @@ export function Item({ rel={rel} aria-disabled={isDisabled || undefined} tabIndex={isDisabled ? -1 : undefined} + {...{[FOCUS_OWNER_ATTRIBUTE]: ''}} {...stylex.props( styles.invisibleAnchor, isInline && styles.inlineContent, @@ -782,6 +786,7 @@ export function Item({ type="button" onClick={onClick} disabled={isDisabled} + {...{[FOCUS_OWNER_ATTRIBUTE]: ''}} {...stylex.props( styles.invisibleButton, isInline && styles.inlineContent, @@ -879,7 +884,13 @@ export function Item({ {...(isInteractive ? pressable : undefined)} {...mergeProps( themeProps('item', {density, align}), - focusOutlineProps.focusWithin( + // The row rings for its own primary only: its invisible button or + // link, or in delegation mode the nested control that is its sole tab + // stop. Any other control in it (a start or end slot) rings itself + // (`architecture:interaction-modality` INV3). + (isDelegate + ? focusOutlineProps.focusWithin + : focusOutlineProps.focusWithinOwner)( styles.root, densityStyles[density], align === 'start' && styles.alignStart, diff --git a/packages/core/src/Token/Token.tsx b/packages/core/src/Token/Token.tsx index 57c5b3d6f351c..26eb16c6e60f9 100644 --- a/packages/core/src/Token/Token.tsx +++ b/packages/core/src/Token/Token.tsx @@ -36,7 +36,10 @@ import {useInteractiveRole} from '../hooks/useInteractiveRole'; import {TokenLink} from './TokenLink'; import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; -import {focusOutlineProps} from '../utils/focusOutline.stylex'; +import { + FOCUS_OWNER_ATTRIBUTE, + focusOutlineProps, +} from '../utils/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {useTranslator} from '../i18n'; @@ -393,7 +396,10 @@ export function Token({ icon={icon} endContent={endContent} removeButton={removeButton} - linkStyleProps={stylex.props(styles.invisibleButton)} + linkStyleProps={{ + ...stylex.props(styles.invisibleButton), + [FOCUS_OWNER_ATTRIBUTE]: '', + }} {...pressable} labelContent={ { /** Resolved link component (native `` or a router link). */ LinkComponent: LinkComponentType; /** `stylex.props()` output applied to the invisible inner link. */ - linkStyleProps: {className?: string; style?: CSSProperties}; + linkStyleProps: { + className?: string; + style?: CSSProperties; + 'data-astryx-focus-owner'?: string; + }; /** Visible (or visually hidden) label node rendered inside the link. */ labelContent: ReactNode; /** Optional leading icon. */ diff --git a/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts b/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..effd3100fb2a9 --- /dev/null +++ b/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts @@ -0,0 +1,80 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. +// @pr-a11y scope ^packages/core/src/Item/ ^packages/core/src/Token/ ^packages/core/src/utils/focusOutline ^apps/storybook/stories/(Item|Token) + +/** + * @file FocusRingOwner.a11y.chromium.spec.ts + * @input The built Item "With End Actions" and Token "Clickable With Remove" + * stories in real Chromium, focus moved with Tab, at desktop and phone + * widths + * @output Browser evidence for `architecture:interaction-modality` INV3: each + * tab stop in a row or token shows exactly one focus ring. A row or token + * rings for its own action only; a control inside it (an end action, a + * remove button) rings itself, and the wrapper stays unringed + * @position Regression proof for focusWithinOwner in Item and Token + */ + +import {expect, test, type Page} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; +import {ringedElements} from './focusRings'; + +let storybook: StaticServer; +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +async function open(page: Page, story: string, width: number) { + await page.setViewportSize({width, height: 600}); + await page.goto(`${storybook.origin}/iframe.html?id=${story}&viewMode=story`); + await page + .locator('#storybook-root button, #storybook-root a') + .first() + .waitFor(); +} + +/** Tabs through every stop, returning the rings painted at each. */ +async function ringsAtEachStop(page: Page, stops: number) { + const out: {focused: string; rings: string[]}[] = []; + for (let i = 0; i < stops; i += 1) { + await page.keyboard.press('Tab'); + const focused = await page.evaluate( + () => + document.activeElement?.getAttribute('aria-label') ?? + (document.activeElement?.textContent ?? '').trim().slice(0, 30), + ); + const rings = (await ringedElements(page)).map(ring => ring.description); + out.push({focused, rings}); + } + return out; +} + +for (const width of [1280, 390]) { + test(`at ${width} px, each tab stop in an Item shows one ring`, async ({ + page, + }) => { + await open(page, 'core-item--with-end-actions', width); + // The first row's action, its Share button, then the second row's + // Restore button (that row has no action). + for (const {focused, rings} of await ringsAtEachStop(page, 3)) { + expect(rings, `rings while "${focused}" is focused`).toHaveLength(1); + } + }); + + test(`at ${width} px, each tab stop in a Token shows one ring`, async ({ + page, + }) => { + await open(page, 'core-token--clickable-with-remove', width); + // Design, Remove Design, Docs, Remove Docs. + for (const {focused, rings} of await ringsAtEachStop(page, 4)) { + expect(rings, `rings while "${focused}" is focused`).toHaveLength(1); + } + }); +} diff --git a/packages/core/src/__tests__/focusRings.ts b/packages/core/src/__tests__/focusRings.ts new file mode 100644 index 0000000000000..6dd0528e11715 --- /dev/null +++ b/packages/core/src/__tests__/focusRings.ts @@ -0,0 +1,51 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file focusRings.ts + * @input A Playwright page showing a built story + * @output `ringedElements`: every element under a root that paints an outline + * right now, for browser specs proving `architecture:interaction-modality` + * INV3 (one focus move has one owning indicator) + * @position Shared helper for focus-ring browser specs + */ + +import type {Page} from '@playwright/test'; + +export interface RingedElement { + /** The element's tag, its accessible label or text, and its role. */ + readonly description: string; + /** Whether it is the focused element itself. */ + readonly isFocused: boolean; +} + +/** + * Every element under `root` whose computed outline paints (a style other + * than `none` and a width above zero). Read after moving focus with the + * keyboard, so `:focus-visible` applies. + */ +export async function ringedElements( + page: Page, + root = '#storybook-root', +): Promise { + return page.evaluate(rootSelector => { + const scope = document.querySelector(rootSelector) ?? document.body; + const out: {description: string; isFocused: boolean}[] = []; + for (const element of [scope, ...scope.querySelectorAll('*')]) { + const style = getComputedStyle(element); + if ( + style.outlineStyle === 'none' || + Number.parseFloat(style.outlineWidth) <= 0 + ) { + continue; + } + const label = + element.getAttribute('aria-label') ?? + (element.textContent ?? '').trim().slice(0, 30); + out.push({ + description: `${element.tagName.toLowerCase()} "${label}"`, + isFocused: element === document.activeElement, + }); + } + return out; + }, root); +} diff --git a/packages/core/src/utils/focusOutline.stylex.ts b/packages/core/src/utils/focusOutline.stylex.ts index 62fb233e36e29..754a4e5ae0bdd 100644 --- a/packages/core/src/utils/focusOutline.stylex.ts +++ b/packages/core/src/utils/focusOutline.stylex.ts @@ -82,6 +82,9 @@ const focusOutlineLonghands = { * Object.assign(el.style, FOCUS_OUTLINE_PARTS_NONE); // clear * ``` */ +/** Marks the direct child a `focusWithinOwner` wrapper rings for. */ +export const FOCUS_OWNER_ATTRIBUTE = 'data-astryx-focus-owner'; + export const FOCUS_OUTLINE_PARTS = { outlineWidth: FOCUS_OUTLINE_WIDTH, outlineStyle: FOCUS_OUTLINE_STYLE, @@ -160,6 +163,37 @@ export const focusOutlineStyles = stylex.create({ ':has(> :first-child:focus-visible)': FOCUS_OUTLINE_OFFSET, }, }, + /** + * Like {@link focusOutlineStyles.focusWithinFirstChild}, but for the direct + * child that carries `data-astryx-focus-owner` (`FOCUS_OWNER_ATTRIBUTE`), + * wherever it sits among its siblings: a wrapper whose primary control is + * not its first child (an icon or a start slot comes first) rings only + * for that control, and every other control inside rings itself + * (`architecture:interaction-modality` INV3). Only a direct child counts, + * so a nested component's own owner never rings this wrapper. Pair it + * with {@link focusOutlineStyles.suppressed} on the owner. + */ + focusWithinOwner: { + outlineWidth: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusOutlineLonghands.outlineWidth, + }, + outlineStyle: { + default: 'none', + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusOutlineLonghands.outlineStyle, + }, + outlineColor: { + default: null, + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusOutlineLonghands.outlineColor, + }, + outlineOffset: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible)': FOCUS_OUTLINE_OFFSET, + }, + }, /** * Draws no ring, and keeps the UA's own off — for an element whose ring an * ancestor paints. @@ -221,6 +255,7 @@ export const focusOutlineProps = { focusWithinFirstChild: makeFocusOutlineProps( focusOutlineStyles.focusWithinFirstChild, ), + focusWithinOwner: makeFocusOutlineProps(focusOutlineStyles.focusWithinOwner), suppressed: makeFocusOutlineProps(focusOutlineStyles.suppressed), publishFocusVisibleVars: makeFocusOutlineProps( focusOutlineStyles.publishFocusVisibleVars, From 00183224d481198a156304f1ed7bce3ec3d3ff6c Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 14:07:30 -0700 Subject: [PATCH 2/5] fix(Item, Token): keep the focus-owner ring internal; restore the Disabled story's doc comment CodeBunny: focusWithinOwner and FOCUS_OWNER_ATTRIBUTE were reachable from the published @astryxdesign/core/utils and utils/focusOutline.stylex entry points, which would make them public API (public-component-api INV12). They move to src/internal/focusOwner.ts, which no entry point exports, so nothing new is published and focusOutline.stylex.ts is unchanged from main. Item.stories: '/** Disabled items. */' goes back above Disabled. --- apps/storybook/stories/Item.stories.tsx | 2 +- packages/core/src/Item/Item.tsx | 9 ++-- packages/core/src/Token/Token.tsx | 9 ++-- packages/core/src/internal/focusOwner.ts | 53 +++++++++++++++++++ .../core/src/utils/focusOutline.stylex.ts | 35 ------------ 5 files changed, 63 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/internal/focusOwner.ts diff --git a/apps/storybook/stories/Item.stories.tsx b/apps/storybook/stories/Item.stories.tsx index 3e702e7474059..cb30d8de97c27 100644 --- a/apps/storybook/stories/Item.stories.tsx +++ b/apps/storybook/stories/Item.stories.tsx @@ -251,7 +251,6 @@ export const SearchResults: Story = { ), }; -/** Disabled items. */ /** * Rows holding a control of their own in `endContent`: a row with an action * rings for that action, a row without one draws no ring, and the end @@ -289,6 +288,7 @@ export const WithEndActions: Story = { ), }; +/** Disabled items. */ export const Disabled: Story = { render: () => ( diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 0ecafd6c88ad3..56d3cadbdf8d1 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -51,10 +51,11 @@ import {useLinkComponent} from '../Link/useLinkComponent'; import {useClickableContainer} from '../hooks/useClickableContainer'; import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { FOCUS_OWNER_ATTRIBUTE, - focusOutlineProps, -} from '../utils/focusOutline.stylex'; + focusWithinOwnerProps, +} from '../internal/focusOwner'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; @@ -888,9 +889,7 @@ export function Item({ // link, or in delegation mode the nested control that is its sole tab // stop. Any other control in it (a start or end slot) rings itself // (`architecture:interaction-modality` INV3). - (isDelegate - ? focusOutlineProps.focusWithin - : focusOutlineProps.focusWithinOwner)( + (isDelegate ? focusOutlineProps.focusWithin : focusWithinOwnerProps)( styles.root, densityStyles[density], align === 'start' && styles.alignStart, diff --git a/packages/core/src/Token/Token.tsx b/packages/core/src/Token/Token.tsx index 26eb16c6e60f9..9ead2cda5eb6f 100644 --- a/packages/core/src/Token/Token.tsx +++ b/packages/core/src/Token/Token.tsx @@ -36,10 +36,11 @@ import {useInteractiveRole} from '../hooks/useInteractiveRole'; import {TokenLink} from './TokenLink'; import type {BaseProps} from '../BaseProps'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { FOCUS_OWNER_ATTRIBUTE, - focusOutlineProps, -} from '../utils/focusOutline.stylex'; + focusWithinOwnerProps, +} from '../internal/focusOwner'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {useTranslator} from '../i18n'; @@ -414,7 +415,7 @@ export function Token({ themeProps('token', {color, size}), // Rings for the token's own button or link only; the remove button // rings itself (`architecture:interaction-modality` INV3). - focusOutlineProps.focusWithinOwner( + focusWithinOwnerProps( styles.base, sizeStyles[size], colorStyles[color], @@ -450,7 +451,7 @@ export function Token({ themeProps('token', {color, size}), // Rings for the token's own button or link only; the remove button // rings itself (`architecture:interaction-modality` INV3). - focusOutlineProps.focusWithinOwner( + focusWithinOwnerProps( styles.base, sizeStyles[size], colorStyles[color], diff --git a/packages/core/src/internal/focusOwner.ts b/packages/core/src/internal/focusOwner.ts new file mode 100644 index 0000000000000..0e46cc8c2d4b1 --- /dev/null +++ b/packages/core/src/internal/focusOwner.ts @@ -0,0 +1,53 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file focusOwner.ts + * @input focus ring tokens + * @output FOCUS_OWNER_ATTRIBUTE and focusOwnerProps, the ring a wrapper paints + * only for its marked primary control + * @position Internal to Core: not part of any published entry point, so the + * owner marker and its ring stay a component-local detail of Item and Token + * (`architecture:public-component-api` INV12) + */ + +import * as stylex from '@stylexjs/stylex'; +import type {StyleXPropsArg} from '@stylexjs/stylex'; +import {focusVars} from '../theme/tokens.stylex'; + +/** Marks the direct child a focus-owner wrapper rings for. */ +export const FOCUS_OWNER_ATTRIBUTE = 'data-astryx-focus-owner'; + +// Rings only for the direct child marked with FOCUS_OWNER_ATTRIBUTE, wherever +// it sits among its siblings, so every other control inside rings itself +// (`architecture:interaction-modality` INV3). Only a direct child counts, so a +// nested component's own owner never rings this wrapper. Written as +// longhands, like focusOutlineStyles. +const styles = stylex.create({ + focusWithinOwner: { + outlineWidth: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-width'], + }, + outlineStyle: { + default: 'none', + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-style'], + }, + outlineColor: { + default: null, + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-color'], + }, + outlineOffset: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-offset'], + }, + }, +}); + +/** The owner ring, with caller styles last (as focusOutlineProps does). */ +export function focusWithinOwnerProps(...extra: StyleXPropsArg[]) { + return stylex.props(styles.focusWithinOwner as never, ...(extra as never[])); +} diff --git a/packages/core/src/utils/focusOutline.stylex.ts b/packages/core/src/utils/focusOutline.stylex.ts index 754a4e5ae0bdd..62fb233e36e29 100644 --- a/packages/core/src/utils/focusOutline.stylex.ts +++ b/packages/core/src/utils/focusOutline.stylex.ts @@ -82,9 +82,6 @@ const focusOutlineLonghands = { * Object.assign(el.style, FOCUS_OUTLINE_PARTS_NONE); // clear * ``` */ -/** Marks the direct child a `focusWithinOwner` wrapper rings for. */ -export const FOCUS_OWNER_ATTRIBUTE = 'data-astryx-focus-owner'; - export const FOCUS_OUTLINE_PARTS = { outlineWidth: FOCUS_OUTLINE_WIDTH, outlineStyle: FOCUS_OUTLINE_STYLE, @@ -163,37 +160,6 @@ export const focusOutlineStyles = stylex.create({ ':has(> :first-child:focus-visible)': FOCUS_OUTLINE_OFFSET, }, }, - /** - * Like {@link focusOutlineStyles.focusWithinFirstChild}, but for the direct - * child that carries `data-astryx-focus-owner` (`FOCUS_OWNER_ATTRIBUTE`), - * wherever it sits among its siblings: a wrapper whose primary control is - * not its first child (an icon or a start slot comes first) rings only - * for that control, and every other control inside rings itself - * (`architecture:interaction-modality` INV3). Only a direct child counts, - * so a nested component's own owner never rings this wrapper. Pair it - * with {@link focusOutlineStyles.suppressed} on the owner. - */ - focusWithinOwner: { - outlineWidth: { - default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible)': - focusOutlineLonghands.outlineWidth, - }, - outlineStyle: { - default: 'none', - ':has(> [data-astryx-focus-owner]:focus-visible)': - focusOutlineLonghands.outlineStyle, - }, - outlineColor: { - default: null, - ':has(> [data-astryx-focus-owner]:focus-visible)': - focusOutlineLonghands.outlineColor, - }, - outlineOffset: { - default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible)': FOCUS_OUTLINE_OFFSET, - }, - }, /** * Draws no ring, and keeps the UA's own off — for an element whose ring an * ancestor paints. @@ -255,7 +221,6 @@ export const focusOutlineProps = { focusWithinFirstChild: makeFocusOutlineProps( focusOutlineStyles.focusWithinFirstChild, ), - focusWithinOwner: makeFocusOutlineProps(focusOutlineStyles.focusWithinOwner), suppressed: makeFocusOutlineProps(focusOutlineStyles.suppressed), publishFocusVisibleVars: makeFocusOutlineProps( focusOutlineStyles.publishFocusVisibleVars, From 0b8105d2466cb10f1b036a5526a9c81f488d40be Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 14:11:30 -0700 Subject: [PATCH 3/5] fix(Item, Token): the focus-owner ring lives in internal/focusOutline.stylex.ts The repo's focus-outline-shared lint rule allows rings only in a focusOutline.stylex file; the internal file keeps that rule and the shared tokens, and no package entry point exports it. --- packages/core/src/Item/Item.tsx | 2 +- packages/core/src/Token/Token.tsx | 2 +- .../internal/{focusOwner.ts => focusOutline.stylex.ts} | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) rename packages/core/src/internal/{focusOwner.ts => focusOutline.stylex.ts} (83%) diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 2d60cea4e7d88..2f1d2c2a51ad1 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -61,7 +61,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { FOCUS_OWNER_ATTRIBUTE, focusWithinOwnerProps, -} from '../internal/focusOwner'; +} from '../internal/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; diff --git a/packages/core/src/Token/Token.tsx b/packages/core/src/Token/Token.tsx index 9ead2cda5eb6f..28a6c5aebd70d 100644 --- a/packages/core/src/Token/Token.tsx +++ b/packages/core/src/Token/Token.tsx @@ -40,7 +40,7 @@ import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { FOCUS_OWNER_ATTRIBUTE, focusWithinOwnerProps, -} from '../internal/focusOwner'; +} from '../internal/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import {useTranslator} from '../i18n'; diff --git a/packages/core/src/internal/focusOwner.ts b/packages/core/src/internal/focusOutline.stylex.ts similarity index 83% rename from packages/core/src/internal/focusOwner.ts rename to packages/core/src/internal/focusOutline.stylex.ts index 0e46cc8c2d4b1..28e76792d000b 100644 --- a/packages/core/src/internal/focusOwner.ts +++ b/packages/core/src/internal/focusOutline.stylex.ts @@ -1,17 +1,17 @@ // Copyright (c) Meta Platforms, Inc. and affiliates. /** - * @file focusOwner.ts + * @file internal/focusOutline.stylex.ts * @input focus ring tokens * @output FOCUS_OWNER_ATTRIBUTE and focusOwnerProps, the ring a wrapper paints * only for its marked primary control - * @position Internal to Core: not part of any published entry point, so the - * owner marker and its ring stay a component-local detail of Item and Token + * @position The internal half of the focus outline utility: same tokens as + * utils/focusOutline.stylex, but no published entry point exports it, so the + * owner marker and its ring stay a detail of Item and Token * (`architecture:public-component-api` INV12) */ import * as stylex from '@stylexjs/stylex'; -import type {StyleXPropsArg} from '@stylexjs/stylex'; import {focusVars} from '../theme/tokens.stylex'; /** Marks the direct child a focus-owner wrapper rings for. */ @@ -48,6 +48,6 @@ const styles = stylex.create({ }); /** The owner ring, with caller styles last (as focusOutlineProps does). */ -export function focusWithinOwnerProps(...extra: StyleXPropsArg[]) { +export function focusWithinOwnerProps(...extra: unknown[]) { return stylex.props(styles.focusWithinOwner as never, ...(extra as never[])); } From 978b0579529c9b3db0840dde0dc82db096b17ae8 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Fri, 30 Oct 2026 21:24:50 -0700 Subject: [PATCH 4/5] fix(Item): a numbered row's own action keeps its ring; swizzled Item and Token resolve the owner module CodeBunny on #8869: - In a numbered List, Item wraps a row's button or link in the content cell beside the marker, so the owner condition's direct-child :has() missed it and the row showed no ring. The cell now carries data-astryx-focus-owner-group, and the condition also matches an owner one level down inside such a group. A numbered story and a Chromium case check one ring for a button row and a link row. - Swizzled Item and Token import internal/focusOutline.stylex, which Core did not export. It is now a ./internal/* entry like internal/touch: reachable for Core, Lab and swizzled copies, outside the compatibility promise (architecture:public-component-api INV12). --- apps/storybook/stories/Item.stories.tsx | 12 ++++++++++++ packages/core/package.json | 5 +++++ packages/core/src/Item/Item.tsx | 4 ++++ .../FocusRingOwner.a11y.chromium.spec.ts | 10 ++++++++++ .../core/src/internal/focusOutline.stylex.ts | 17 ++++++++++++----- scripts/sync-exports.js | 1 + 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/storybook/stories/Item.stories.tsx b/apps/storybook/stories/Item.stories.tsx index 5d326469d9714..3d2018ede3055 100644 --- a/apps/storybook/stories/Item.stories.tsx +++ b/apps/storybook/stories/Item.stories.tsx @@ -361,6 +361,18 @@ export const WithEndActions: Story = { ), }; +/** Numbered rows with their own action: the marker sits beside the row. */ +export const NumberedWithActions: Story = { + render: () => ( +
+ + {}} /> + + +
+ ), +}; + /** Disabled items. */ export const Disabled: Story = { render: () => ( diff --git a/packages/core/package.json b/packages/core/package.json index 3375b3a134f2f..f42c37bda1d9a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -828,6 +828,11 @@ "source": "./src/internal/tokenizer-presentation.ts", "types": "./dist/internal/tokenizer-presentation.d.ts", "default": "./dist/internal/tokenizer-presentation.js" + }, + "./internal/focusOutline.stylex": { + "source": "./src/internal/focusOutline.stylex.ts", + "types": "./dist/internal/focusOutline.stylex.d.ts", + "default": "./dist/internal/focusOutline.stylex.js" } }, "files": [ diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 2f1d2c2a51ad1..54abff0ffab64 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -60,6 +60,7 @@ import {themeProps} from '../utils/themeProps'; import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { FOCUS_OWNER_ATTRIBUTE, + FOCUS_OWNER_GROUP_ATTRIBUTE, focusWithinOwnerProps, } from '../internal/focusOutline.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; @@ -935,6 +936,9 @@ export function Item({ {isRenderable(marker) && marker} {isRenderable(marker) && columnMarker === marker ? ( { + await open(page, 'core-item--numbered-with-actions', width); + // The button row, then the link row: each rings once, beside its marker. + for (const {focused, rings} of await ringsAtEachStop(page, 2)) { + expect(rings, `rings while "${focused}" is focused`).toHaveLength(1); + } + }); + test(`at ${width} px, each tab stop in a Token shows one ring`, async ({ page, }) => { diff --git a/packages/core/src/internal/focusOutline.stylex.ts b/packages/core/src/internal/focusOutline.stylex.ts index 28e76792d000b..3b35c051a4b0a 100644 --- a/packages/core/src/internal/focusOutline.stylex.ts +++ b/packages/core/src/internal/focusOutline.stylex.ts @@ -17,8 +17,15 @@ import {focusVars} from '../theme/tokens.stylex'; /** Marks the direct child a focus-owner wrapper rings for. */ export const FOCUS_OWNER_ATTRIBUTE = 'data-astryx-focus-owner'; +/** + * Marks a layout wrapper (Item's content cell beside a list marker) whose + * direct child may be the owner, so the owner can sit one level down. + */ +export const FOCUS_OWNER_GROUP_ATTRIBUTE = 'data-astryx-focus-owner-group'; + // Rings only for the direct child marked with FOCUS_OWNER_ATTRIBUTE, wherever -// it sits among its siblings, so every other control inside rings itself +// it sits among its siblings (or one level down, inside a marked group), so +// every other control inside rings itself // (`architecture:interaction-modality` INV3). Only a direct child counts, so a // nested component's own owner never rings this wrapper. Written as // longhands, like focusOutlineStyles. @@ -26,22 +33,22 @@ const styles = stylex.create({ focusWithinOwner: { outlineWidth: { default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible)': + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': focusVars['--focus-outline-width'], }, outlineStyle: { default: 'none', - ':has(> [data-astryx-focus-owner]:focus-visible)': + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': focusVars['--focus-outline-style'], }, outlineColor: { default: null, - ':has(> [data-astryx-focus-owner]:focus-visible)': + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': focusVars['--focus-outline-color'], }, outlineOffset: { default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible)': + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': focusVars['--focus-outline-offset'], }, }, diff --git a/scripts/sync-exports.js b/scripts/sync-exports.js index ee5316c7916a1..0caf6da25028e 100644 --- a/scripts/sync-exports.js +++ b/scripts/sync-exports.js @@ -239,6 +239,7 @@ const FILE_MODULE_SUBPATH_EXPORTS = [ // (architecture:react-component-runtime INV13). 'internal/touch', 'internal/tokenizer-presentation', + 'internal/focusOutline.stylex', ]; /** From 51d461d0cecf07dc044fd8bc9d2b6e83352f5cc3 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 31 Oct 2026 11:24:40 -0700 Subject: [PATCH 5/5] refactor(Item, Token): the focus-owner attribute has one owner module, privateAttributes/focusOwner.stylex.ts Per architecture:interaction-modality INV3 and public-component-api INV23: the private data-astryx-focus-owner attribute, its group attribute and their selector are written once, in privateAttributes/focusOwner.stylex.ts, which exports only the shared focusWithinOwner wrapper ring and the props that set the attributes. Item and Token spread those exports and never write the attribute; TokenLink takes them through its link props without typing the name. No package entry exports the module: the ./internal/focusOutline.stylex entry and its export-sync line are gone. focus-outline-shared allows the owner module to write its ring. A source-build test checks the compiled ring sits on the wrapper, keyed to the marked control's :focus-visible, never :has(...) * on every descendant; a Chromium case checks a Token inside an Item shows one ring at each stop. --- apps/storybook/stories/Item.stories.tsx | 15 +++++ .../focus-outline-shared.js | 15 ++++- .../focus-outline-shared.test.mjs | 21 +++++-- packages/core/package.json | 5 -- packages/core/src/Item/Item.tsx | 19 +++--- packages/core/src/Token/Token.tsx | 16 ++--- packages/core/src/Token/TokenLink.tsx | 9 ++- .../FocusRingOwner.a11y.chromium.spec.ts | 10 +++ .../focusOwner.source-build.test.mjs | 52 +++++++++++++++ .../core/src/internal/focusOutline.stylex.ts | 60 ------------------ .../privateAttributes/focusOwner.stylex.ts | 63 +++++++++++++++++++ scripts/sync-exports.js | 1 - 12 files changed, 192 insertions(+), 94 deletions(-) create mode 100644 packages/core/src/__tests__/focusOwner.source-build.test.mjs delete mode 100644 packages/core/src/internal/focusOutline.stylex.ts create mode 100644 packages/core/src/privateAttributes/focusOwner.stylex.ts diff --git a/apps/storybook/stories/Item.stories.tsx b/apps/storybook/stories/Item.stories.tsx index 3d2018ede3055..30b5a8beae9d2 100644 --- a/apps/storybook/stories/Item.stories.tsx +++ b/apps/storybook/stories/Item.stories.tsx @@ -22,6 +22,7 @@ import {Stack} from '@astryxdesign/core/Layout'; import {List, ListItem} from '@astryxdesign/core/List'; import {RadioList, RadioListItem} from '@astryxdesign/core/RadioList'; import {StatusDot} from '@astryxdesign/core/StatusDot'; +import {Token} from '@astryxdesign/core/Token'; const storyStyles = stylex.create({ markerAnatomy: { @@ -361,6 +362,20 @@ export const WithEndActions: Story = { ), }; +/** A row with its own action and a clickable Token in its end slot. */ +export const WithTokenAction: Story = { + render: () => ( +
+ {}} + endContent={ {}} />} + /> +
+ ), +}; + /** Numbered rows with their own action: the marker sits beside the row. */ export const NumberedWithActions: Story = { render: () => ( diff --git a/internal/eslint-plugin-astryx/focus-outline-shared.js b/internal/eslint-plugin-astryx/focus-outline-shared.js index 847e8a24891f2..81520fd85bee4 100644 --- a/internal/eslint-plugin-astryx/focus-outline-shared.js +++ b/internal/eslint-plugin-astryx/focus-outline-shared.js @@ -37,8 +37,16 @@ const RING_APPEARANCE = new Set(['outline', 'outlineWidth', 'outlineStyle']); -/** The file that owns the ring is the one place allowed to write it. */ -const UTILITY_FILE = 'focusOutline.stylex'; +/** + * The files that own a ring are the only places allowed to write it: the + * shared utility, and the focus-owner attribute's owner module, which holds + * the wrapper ring keyed to that private attribute + * (`architecture:public-component-api` INV23). + */ +const RING_OWNER_FILES = [ + 'focusOutline.stylex', + 'privateAttributes/focusOwner.stylex', +]; function isFocusVisibleKey(key) { return typeof key === 'string' && key.includes(':focus-visible'); @@ -100,7 +108,8 @@ const rule = { schema: [], }, create(context) { - if (context.filename?.includes(UTILITY_FILE)) return {}; + const filename = context.filename?.replaceAll('\\', '/') ?? ''; + if (RING_OWNER_FILES.some(owner => filename.includes(owner))) return {}; return { Property(node) { diff --git a/internal/eslint-plugin-astryx/focus-outline-shared.test.mjs b/internal/eslint-plugin-astryx/focus-outline-shared.test.mjs index e033d4dcfc9b5..fd5e9d9de0765 100644 --- a/internal/eslint-plugin-astryx/focus-outline-shared.test.mjs +++ b/internal/eslint-plugin-astryx/focus-outline-shared.test.mjs @@ -70,6 +70,22 @@ ruleTester.run('focus-outline-shared', rule, { { code: `const doc = {outline: {':focus-visible': '2px solid blue'}};`, }, + // The focus-owner attribute's owner module writes the wrapper ring. + { + filename: + '/repo/packages/core/src/privateAttributes/focusOwner.stylex.ts', + code: ` + import * as stylex from '@stylexjs/stylex'; + const styles = stylex.create({ + focusWithinOwner: { + outlineWidth: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible)': WIDTH, + }, + }, + }); + `, + }, // The utility itself is the one place the ring is written. { filename: '/repo/packages/core/src/utils/focusOutline.stylex.ts', @@ -108,10 +124,7 @@ ruleTester.run('focus-outline-shared', rule, { }, }); `, - errors: [ - {messageId: 'handRolledRing'}, - {messageId: 'handRolledRing'}, - ], + errors: [{messageId: 'handRolledRing'}, {messageId: 'handRolledRing'}], }, // Reading the tokens directly is still a second definition of the ring. { diff --git a/packages/core/package.json b/packages/core/package.json index f42c37bda1d9a..3375b3a134f2f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -828,11 +828,6 @@ "source": "./src/internal/tokenizer-presentation.ts", "types": "./dist/internal/tokenizer-presentation.d.ts", "default": "./dist/internal/tokenizer-presentation.js" - }, - "./internal/focusOutline.stylex": { - "source": "./src/internal/focusOutline.stylex.ts", - "types": "./dist/internal/focusOutline.stylex.d.ts", - "default": "./dist/internal/focusOutline.stylex.js" } }, "files": [ diff --git a/packages/core/src/Item/Item.tsx b/packages/core/src/Item/Item.tsx index 54abff0ffab64..84a0e97097cf8 100644 --- a/packages/core/src/Item/Item.tsx +++ b/packages/core/src/Item/Item.tsx @@ -57,12 +57,12 @@ import {useLinkComponent} from '../Link/useLinkComponent'; import {useClickableContainer} from '../hooks/useClickableContainer'; import {useDevWarning} from '../hooks/useDevWarning'; import {themeProps} from '../utils/themeProps'; -import {focusOutlineProps} from '../utils/focusOutline.stylex'; +import {focusOutlineStyles} from '../utils/focusOutline.stylex'; import { - FOCUS_OWNER_ATTRIBUTE, - FOCUS_OWNER_GROUP_ATTRIBUTE, - focusWithinOwnerProps, -} from '../internal/focusOutline.stylex'; + focusOwnerGroupProps, + focusOwnerProps, + focusWithinOwner, +} from '../privateAttributes/focusOwner.stylex'; import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex'; import {usePressFeedback} from '../hooks/usePressFeedback'; import type {ItemSwipeBridge} from './ItemSwipeLayer'; @@ -797,7 +797,7 @@ export function Item({ rel={rel} aria-disabled={isDisabled || undefined} tabIndex={isDisabled ? -1 : undefined} - {...{[FOCUS_OWNER_ATTRIBUTE]: ''}} + {...focusOwnerProps} {...stylex.props( styles.invisibleAnchor, isInline && styles.inlineContent, @@ -810,7 +810,7 @@ export function Item({ type="button" onClick={onClick} disabled={isDisabled} - {...{[FOCUS_OWNER_ATTRIBUTE]: ''}} + {...focusOwnerProps} {...stylex.props( styles.invisibleButton, isInline && styles.inlineContent, @@ -912,7 +912,8 @@ export function Item({ // link, or in delegation mode the nested control that is its sole tab // stop. Any other control in it (a start or end slot) rings itself // (`architecture:interaction-modality` INV3). - (isDelegate ? focusOutlineProps.focusWithin : focusWithinOwnerProps)( + stylex.props( + isDelegate ? focusOutlineStyles.focusWithin : focusWithinOwner, styles.root, densityStyles[density], align === 'start' && styles.alignStart, @@ -938,7 +939,7 @@ export function Item({ { /** Ref forwarded to the container element. */ ref?: Ref; @@ -42,11 +43,9 @@ export interface TokenLinkProps extends HTMLAttributes { /** Resolved link component (native `
` or a router link). */ LinkComponent: LinkComponentType; /** `stylex.props()` output applied to the invisible inner link. */ - linkStyleProps: { - className?: string; - style?: CSSProperties; - 'data-astryx-focus-owner'?: string; - }; + linkStyleProps: {className?: string; style?: CSSProperties} & Partial< + typeof focusOwnerProps + >; /** Visible (or visually hidden) label node rendered inside the link. */ labelContent: ReactNode; /** Optional leading icon. */ diff --git a/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts b/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts index fb74921ca6909..38c55ac1b4280 100644 --- a/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts +++ b/packages/core/src/__tests__/FocusRingOwner.a11y.chromium.spec.ts @@ -78,6 +78,16 @@ for (const width of [1280, 390]) { } }); + test(`at ${width} px, a Token inside an Item rings itself, not the row`, async ({ + page, + }) => { + await open(page, 'core-item--with-token-action', width); + // The row's own action, then the Token's: one ring at each. + for (const {focused, rings} of await ringsAtEachStop(page, 2)) { + expect(rings, `rings while "${focused}" is focused`).toHaveLength(1); + } + }); + test(`at ${width} px, each tab stop in a Token shows one ring`, async ({ page, }) => { diff --git a/packages/core/src/__tests__/focusOwner.source-build.test.mjs b/packages/core/src/__tests__/focusOwner.source-build.test.mjs new file mode 100644 index 0000000000000..cde39ff941cb6 --- /dev/null +++ b/packages/core/src/__tests__/focusOwner.source-build.test.mjs @@ -0,0 +1,52 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file focusOwner.source-build.test.mjs + * @input Uses Babel, the core StyleX build config, the StyleX rule processor, + * and privateAttributes/focusOwner.stylex.ts + * @output Verifies the shared focus-owner ring compiles to a ring on the + * wrapper itself, keyed to its marked control's `:focus-visible`, and never + * to a rule that reaches every descendant (`:has(...) *`) + * @position Regression test for the focus-owner wrapper ring + * (`architecture:interaction-modality` INV3) + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {transformAsync} from '@babel/core'; +import stylexBabelPlugin from '@stylexjs/babel-plugin'; +import {describe, expect, it} from 'vitest'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SRC = path.resolve(__dirname, '..'); +const CORE_ROOT = path.resolve(SRC, '..'); + +async function compile(relative) { + const filename = path.join(SRC, relative); + const result = await transformAsync(await fs.readFile(filename, 'utf8'), { + babelrc: false, + configFile: path.join(CORE_ROOT, 'babel.config.json'), + filename, + }); + return stylexBabelPlugin.processStylexRules( + result?.metadata?.stylex ?? [], + false, + ); +} + +describe('focus-owner wrapper ring', () => { + it('rings the wrapper for its own marked control only', async () => { + const css = await compile('privateAttributes/focusOwner.stylex.ts'); + const ringRules = css + .split('}') + .filter(rule => rule.includes('var(--focus-outline-')); + expect(ringRules.length).toBeGreaterThan(0); + for (const rule of ringRules) { + // On the wrapper, while its marked control shows keyboard focus. + expect(rule).toMatch(/:has\(> \[data-astryx-focus-owner\]:focus-visible/); + // Never every descendant. + expect(rule).not.toMatch(/:has\([^{]*\)\)?\s+\*/); + } + }); +}); diff --git a/packages/core/src/internal/focusOutline.stylex.ts b/packages/core/src/internal/focusOutline.stylex.ts deleted file mode 100644 index 3b35c051a4b0a..0000000000000 --- a/packages/core/src/internal/focusOutline.stylex.ts +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Meta Platforms, Inc. and affiliates. - -/** - * @file internal/focusOutline.stylex.ts - * @input focus ring tokens - * @output FOCUS_OWNER_ATTRIBUTE and focusOwnerProps, the ring a wrapper paints - * only for its marked primary control - * @position The internal half of the focus outline utility: same tokens as - * utils/focusOutline.stylex, but no published entry point exports it, so the - * owner marker and its ring stay a detail of Item and Token - * (`architecture:public-component-api` INV12) - */ - -import * as stylex from '@stylexjs/stylex'; -import {focusVars} from '../theme/tokens.stylex'; - -/** Marks the direct child a focus-owner wrapper rings for. */ -export const FOCUS_OWNER_ATTRIBUTE = 'data-astryx-focus-owner'; - -/** - * Marks a layout wrapper (Item's content cell beside a list marker) whose - * direct child may be the owner, so the owner can sit one level down. - */ -export const FOCUS_OWNER_GROUP_ATTRIBUTE = 'data-astryx-focus-owner-group'; - -// Rings only for the direct child marked with FOCUS_OWNER_ATTRIBUTE, wherever -// it sits among its siblings (or one level down, inside a marked group), so -// every other control inside rings itself -// (`architecture:interaction-modality` INV3). Only a direct child counts, so a -// nested component's own owner never rings this wrapper. Written as -// longhands, like focusOutlineStyles. -const styles = stylex.create({ - focusWithinOwner: { - outlineWidth: { - default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': - focusVars['--focus-outline-width'], - }, - outlineStyle: { - default: 'none', - ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': - focusVars['--focus-outline-style'], - }, - outlineColor: { - default: null, - ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': - focusVars['--focus-outline-color'], - }, - outlineOffset: { - default: '0', - ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': - focusVars['--focus-outline-offset'], - }, - }, -}); - -/** The owner ring, with caller styles last (as focusOutlineProps does). */ -export function focusWithinOwnerProps(...extra: unknown[]) { - return stylex.props(styles.focusWithinOwner as never, ...(extra as never[])); -} diff --git a/packages/core/src/privateAttributes/focusOwner.stylex.ts b/packages/core/src/privateAttributes/focusOwner.stylex.ts new file mode 100644 index 0000000000000..033b5875bbd11 --- /dev/null +++ b/packages/core/src/privateAttributes/focusOwner.stylex.ts @@ -0,0 +1,63 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file privateAttributes/focusOwner.stylex.ts + * @input focus ring tokens + * @output focusWithinOwner, the ring a wrapper draws only while its primary + * control has keyboard focus, and focusOwnerProps / focusOwnerGroupProps, + * which mark that control (and, where layout puts it one level down, the + * cell holding it) + * @position Owner module of the private focus-owner attribute + * (`architecture:public-component-api` INV23): the only file that writes + * its name or its selector. Core wrappers spread these exports + * (`architecture:interaction-modality` INV3). No package entry exports it. + */ + +import * as stylex from '@stylexjs/stylex'; +import {focusVars} from '../theme/tokens.stylex'; + +/** Spread on the wrapper's primary control: the control the ring is for. */ +export const focusOwnerProps = {'data-astryx-focus-owner': ''} as const; + +/** + * Spread on a layout cell that holds the primary control one level down + * (Item's content cell beside a list marker), so the wrapper's ring still + * reaches it. + */ +export const focusOwnerGroupProps = { + 'data-astryx-focus-owner-group': '', +} as const; + +// Rings only for the wrapper's own marked control, a direct child or one level +// down inside a marked group, so every other control inside rings itself and a +// nested component's owner never rings this wrapper. Every condition of each +// property, rest state included, is here: a caller spreading this style does +// not set these properties again on the same element. Written as longhands, +// like focusOutlineStyles, so a base style cannot erase them through `outline`. +const styles = stylex.create({ + focusWithinOwner: { + outlineWidth: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-width'], + }, + outlineStyle: { + default: 'none', + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-style'], + }, + outlineColor: { + default: null, + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-color'], + }, + outlineOffset: { + default: '0', + ':has(> [data-astryx-focus-owner]:focus-visible, > [data-astryx-focus-owner-group] > [data-astryx-focus-owner]:focus-visible)': + focusVars['--focus-outline-offset'], + }, + }, +}); + +/** The wrapper's ring. Spread it first; caller styles that follow keep it. */ +export const focusWithinOwner = styles.focusWithinOwner; diff --git a/scripts/sync-exports.js b/scripts/sync-exports.js index 0caf6da25028e..ee5316c7916a1 100644 --- a/scripts/sync-exports.js +++ b/scripts/sync-exports.js @@ -239,7 +239,6 @@ const FILE_MODULE_SUBPATH_EXPORTS = [ // (architecture:react-component-runtime INV13). 'internal/touch', 'internal/tokenizer-presentation', - 'internal/focusOutline.stylex', ]; /**