Skip to content
7 changes: 7 additions & 0 deletions .changeset/focus-ring-one-owner-item-token.md
Original file line number Diff line number Diff line change
@@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: delegation rows (CheckboxListItem, RadioListItem, any Item with interactiveRef) keep the any-descendant ring, so an endContent control there still shows the row's ring too. Consider scoping this sentence to rows and tokens that have their own button or link.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Delegation rows (CheckboxListItem, RadioListItem, any Item with interactiveRef) keep the any-descendant ring, so an end control there still shows the row's outline too (2 outlines in Chromium). Consider scoping this sentence to rows and tokens that have their own button or link.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Delegation rows (CheckboxListItem, RadioListItem, any Item with interactiveRef) keep the any-descendant ring (packages/core/src/Item/Item.tsx:924), so a button in their endContent still shows the row's ring beside its own. Please scope this to rows with their own onClick or href, for example: "Focusing a control in the start or end slot of an Item with its own onClick or href, or a Token's remove button, no longer also rings the whole row or token."


@cixzhang
67 changes: 67 additions & 0 deletions apps/storybook/stories/Item.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {useState, type ReactNode} from 'react';
import {Button} from '@astryxdesign/core/Button';
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';
Expand All @@ -21,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: {
Expand Down Expand Up @@ -51,6 +53,8 @@ import {
ArchiveBoxIcon,
EnvelopeIcon,
TrashIcon,
ShareIcon,
ArrowUturnLeftIcon,
} from '@heroicons/react/24/outline';

const meta: Meta<typeof Item> = {
Expand Down Expand Up @@ -321,6 +325,69 @@ export const SearchResults: Story = {
),
};

/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This block landed under /** Disabled items. */ (line 254), so Storybook now gives With End Actions the description "Disabled items." followed by this text, and Disabled (line 292) has none. Move /** Disabled items. */ down to sit directly above export const Disabled.

* 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: () => (
<div style={{maxWidth: 420}}>
<Item
label="Quarterly report"
description="Opens the report"
onClick={() => {}}
endContent={
<IconButton
label="Share Quarterly report"
icon={<Icon icon={ShareIcon} />}
variant="ghost"
size="sm"
/>
}
/>
<Item
label="Archived notes"
description="No row action"
endContent={
<IconButton
label="Restore Archived notes"
icon={<Icon icon={ArrowUturnLeftIcon} />}
variant="ghost"
size="sm"
/>
}
/>
</div>
),
};

/** A row with its own action and a clickable Token in its end slot. */
export const WithTokenAction: Story = {
render: () => (
<div style={{maxWidth: 420}}>
<Item
label="Launch plan"
description="Opens the plan"
onClick={() => {}}
endContent={<Token label="Design" onClick={() => {}} />}
/>
</div>
),
};

/** Numbered rows with their own action: the marker sits beside the row. */
export const NumberedWithActions: Story = {
render: () => (
<div style={{maxWidth: 420}}>
<List listStyle="decimal">
<ListItem label="Open the report" onClick={() => {}} />
<ListItem label="Read the summary" href="#summary" />
</List>
</div>
),
};

/** Disabled items. */
export const Disabled: Story = {
render: () => (
Expand Down
14 changes: 14 additions & 0 deletions apps/storybook/stories/Token.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => (
<div style={{display: 'flex', gap: '8px', alignItems: 'center'}}>
<Token label="Design" onClick={() => {}} onRemove={() => {}} />
<Token label="Docs" href="#" onRemove={() => {}} />
</div>
),
};

export const AsLink: Story = {
render: () => (
<div style={{display: 'flex', gap: '8px', alignItems: 'center'}}>
Expand Down
15 changes: 12 additions & 3 deletions internal/eslint-plugin-astryx/focus-outline-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 17 additions & 4 deletions internal/eslint-plugin-astryx/focus-outline-shared.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.
{
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,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 {
focusOwnerGroupProps,
focusOwnerProps,
focusWithinOwner,
} from '../privateAttributes/focusOwner.stylex';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

astryx swizzle rewrites this import to @astryxdesign/core/privateAttributes/focusOwner.stylex (packages/cli/api/swizzle/copy/copy.mjs:71-73), which packages/core/package.json does not export, so a swizzled Item, and a swizzled Token (Token.tsx:43), no longer resolves its imports until the swizzle copy change lands.

import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
import {usePressFeedback} from '../hooks/usePressFeedback';
import type {ItemSwipeBridge} from './ItemSwipeLayer';
Expand Down Expand Up @@ -800,6 +805,7 @@ export function Item({
{...(isDisabled
? {role: 'link', 'aria-disabled': true, tabIndex: -1}
: {href, target, rel})}
{...focusOwnerProps}
{...stylex.props(
styles.invisibleAnchor,
isInline && styles.inlineContent,
Expand All @@ -812,6 +818,7 @@ export function Item({
type="button"
onClick={onClick}
disabled={isDisabled}
{...focusOwnerProps}
{...stylex.props(
styles.invisibleButton,
isInline && styles.inlineContent,
Expand Down Expand Up @@ -909,7 +916,12 @@ 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).
stylex.props(
isDelegate ? focusOutlineStyles.focusWithin : focusWithinOwner,
styles.root,
densityStyles[density],
align === 'start' && styles.alignStart,
Expand All @@ -933,6 +945,9 @@ export function Item({
{isRenderable(marker) && marker}
{isRenderable(marker) && columnMarker === marker ? (
<span
// The row's own button or link sits in this cell, so the row's
// ring reaches it here.
{...focusOwnerGroupProps}
{...stylex.props(
styles.markerContent,
align === 'first-line' && styles.markerContentFirstLine,
Expand Down
20 changes: 17 additions & 3 deletions packages/core/src/Token/Token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ import {TokenLink} from './TokenLink';
import type {BaseProps} from '../BaseProps';
import {themeProps} from '../utils/themeProps';
import {focusOutlineProps} from '../utils/focusOutline.stylex';
import {
focusOwnerProps,
focusWithinOwner,
} from '../privateAttributes/focusOwner.stylex';
import {interactionOverlayStyles} from '../utils/interactionOverlay.stylex';
import {usePressFeedback} from '../hooks/usePressFeedback';
import {useTranslator} from '../i18n';
Expand Down Expand Up @@ -448,7 +452,10 @@ export function Token({
icon={icon}
endContent={endContent}
removeButton={removeButton}
linkStyleProps={stylex.props(styles.invisibleButton)}
linkStyleProps={{
...stylex.props(styles.invisibleButton),
...focusOwnerProps,
}}
{...pressable}
labelContent={
<span
Expand All @@ -465,7 +472,10 @@ export function Token({
size,
disabled: isDisabled ? 'disabled' : null,
}),
focusOutlineProps.focusWithin(
// Rings for the token's own button or link only; the remove button
// rings itself (`architecture:interaction-modality` INV3).
stylex.props(
focusWithinOwner,
styles.base,
sizeStyles[size],
colorStyles[color],
Expand Down Expand Up @@ -504,7 +514,10 @@ export function Token({
size,
disabled: isDisabled ? 'disabled' : null,
}),
focusOutlineProps.focusWithin(
// Rings for the token's own button or link only; the remove button
// rings itself (`architecture:interaction-modality` INV3).
stylex.props(
focusWithinOwner,
styles.base,
sizeStyles[size],
colorStyles[color],
Expand All @@ -525,6 +538,7 @@ export function Token({
{...labellingProps}
aria-disabled={isDisabled || undefined}
{...(isDisabled ? disabledInteractionProps() : null)}
{...focusOwnerProps}
{...stylex.props(styles.invisibleButton)}>
<span
{...stylex.props(
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/Token/TokenLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {composeEventHandlers} from '../utils';

import {useMergedRefs} from '../hooks/useMergedRefs';
import {disabledInteractionProps} from '../utils/disabledInteraction';
import type {focusOwnerProps} from '../privateAttributes/focusOwner.stylex';
export interface TokenLinkProps extends HTMLAttributes<HTMLElement> {
/** Ref forwarded to the container element. */
ref?: Ref<HTMLElement>;
Expand All @@ -43,7 +44,9 @@ export interface TokenLinkProps extends HTMLAttributes<HTMLElement> {
/** Resolved link component (native `<a>` 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} & Partial<
typeof focusOwnerProps
>;
/** Visible (or visually hidden) label node rendered inside the link. */
labelContent: ReactNode;
/** Optional leading icon. */
Expand Down
Loading
Loading