From 96b7019b7ee37d1396fe21725ab38e7b64dc1ef6 Mon Sep 17 00:00:00 2001 From: cixzhang Date: Sun, 2 Aug 2026 15:18:00 +0000 Subject: [PATCH] fix(theme): consolidate shared focus outlines --- .changeset/focus-outline-consistency.md | 6 ++ .../commands/build-theme.registry.test.mjs | 7 +- packages/core/src/Avatar/Avatar.tsx | 30 +------ .../src/AvatarGroup/AvatarGroupOverflow.tsx | 11 +-- packages/core/src/Button/Button.doc.mjs | 2 - packages/core/src/Button/Button.tsx | 44 +-------- packages/core/src/Calendar/Calendar.tsx | 3 +- packages/core/src/Calendar/styles.ts | 8 -- .../core/src/ClickableCard/ClickableCard.tsx | 17 ++-- packages/core/src/CodeBlock/CodeBlock.tsx | 11 +-- packages/core/src/Collapsible/Collapsible.tsx | 11 +-- packages/core/src/Dialog/Dialog.tsx | 11 +-- packages/core/src/Item/Item.tsx | 14 +-- packages/core/src/Link/Link.tsx | 15 +--- packages/core/src/Outline/Outline.tsx | 7 +- packages/core/src/Pagination/Pagination.tsx | 11 +-- packages/core/src/Resizable/ResizeHandle.tsx | 11 +-- .../SegmentedControl/SegmentedControlItem.tsx | 11 +-- .../src/SelectableCard/SelectableCard.tsx | 13 +-- packages/core/src/TabList/Tab.tsx | 11 +-- packages/core/src/TabList/TabMenu.tsx | 17 +--- .../plugins/sortable/useTableSortable.tsx | 8 +- packages/core/src/Thumbnail/Thumbnail.tsx | 11 +-- packages/core/src/Timestamp/Timestamp.tsx | 19 ++-- packages/core/src/Token/Token.tsx | 32 ++----- packages/core/src/TopNav/TopNavItem.tsx | 24 +---- packages/core/src/TopNav/TopNavMegaMenu.tsx | 14 ++- .../core/src/TopNav/TopNavMegaMenuItem.tsx | 11 +-- packages/core/src/TopNav/TopNavMenu.tsx | 24 ++--- packages/core/src/TreeList/TreeListItem.tsx | 48 ++++------ packages/core/src/theme/Theme.doc.mjs | 13 ++- .../core/src/utils/focusOutline.stylex.ts | 90 +++++++++++++++++++ 32 files changed, 211 insertions(+), 354 deletions(-) create mode 100644 .changeset/focus-outline-consistency.md create mode 100644 packages/core/src/utils/focusOutline.stylex.ts diff --git a/.changeset/focus-outline-consistency.md b/.changeset/focus-outline-consistency.md new file mode 100644 index 000000000000..f8b8139241f2 --- /dev/null +++ b/.changeset/focus-outline-consistency.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Consolidate general interactive focus outlines so components share the same accent-colored ring geometry. +@cixzhang diff --git a/packages/cli/clients/cli/commands/build-theme.registry.test.mjs b/packages/cli/clients/cli/commands/build-theme.registry.test.mjs index 5162874eaebf..d590fa28164a 100644 --- a/packages/cli/clients/cli/commands/build-theme.registry.test.mjs +++ b/packages/cli/clients/cli/commands/build-theme.registry.test.mjs @@ -60,7 +60,10 @@ function renderedClassLiterals() { const full = path.join(dir, entry.name); if (entry.isDirectory()) { walk(full); - } else if (entry.name.endsWith('.tsx') && !entry.name.endsWith('.test.tsx')) { + } else if ( + (entry.name.endsWith('.tsx') || entry.name.endsWith('.ts')) && + !entry.name.includes('.test.') + ) { const text = fs.readFileSync(full, 'utf8'); for (const re of [ /themeProps\(\s*'([^']+)'/g, @@ -110,6 +113,7 @@ describe('theme build emits a live TextInput selector (#4109)', () => { ` 'side-nav-item': { base: { borderRadius: '12px' } },\n` + ` 'chat-composer': { base: { padding: '10px' } },\n` + ` 'chat-message-bubble': { 'variant:ghost': { borderRadius: '18px' } },\n` + + ` 'focus-outline': { base: { outlineOffset: '4px' } },\n` + ` },\n` + `};\n`, ); @@ -122,6 +126,7 @@ describe('theme build emits a live TextInput selector (#4109)', () => { expect(css).toContain('.astryx-side-nav-item'); expect(css).toContain('.astryx-chat-composer'); expect(css).toContain('.astryx-chat-message-bubble.ghost'); + expect(css).toContain('.astryx-focus-outline'); }); it('emits .astryx-text-input (the rendered class), not the dead .astryx-textinput', async () => { diff --git a/packages/core/src/Avatar/Avatar.tsx b/packages/core/src/Avatar/Avatar.tsx index cd2774ad0588..c1e70ace50b8 100644 --- a/packages/core/src/Avatar/Avatar.tsx +++ b/packages/core/src/Avatar/Avatar.tsx @@ -31,6 +31,7 @@ import {AvatarSizeContext} from './AvatarSizeContext'; import {useAvatarGroup} from '../AvatarGroup/AvatarGroupContext'; import {mergeProps, mergeRefs} from '../utils'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {useTooltip} from '../Tooltip/useTooltip'; import {useLinkComponent} from '../Link/useLinkComponent'; import type {LinkComponentType} from '../Link/types'; @@ -152,16 +153,7 @@ const styles = stylex.create({ // Visible focus ring for the name-tooltip tab stop, matching the repo-wide // focus-visible outline treatment (see Timestamp, Token, Thumbnail). Only // applied when a tooltip is active so keyboard users can reveal it. - focusable: { - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, - }, + focusable: {}, // Reset the intrinsic styling of the interactive element (/ ); @@ -396,7 +376,7 @@ export function Token({ {...sharedProps} {...mergeProps( themeProps('token', {color, size}), - stylex.props( + focusOutlineProps.focusVisible( styles.base, sizeStyles[size], colorStyles[color], @@ -441,7 +421,7 @@ export function Token({ {...sharedProps} {...mergeProps( themeProps('token', {color, size}), - stylex.props( + focusOutlineProps.focusWithin( styles.base, sizeStyles[size], colorStyles[color], @@ -473,7 +453,7 @@ export function Token({ {...sharedProps} {...mergeProps( themeProps('token', {color, size}), - stylex.props( + focusOutlineProps.focusWithin( styles.base, sizeStyles[size], colorStyles[color], diff --git a/packages/core/src/TopNav/TopNavItem.tsx b/packages/core/src/TopNav/TopNavItem.tsx index b6f7872fed35..c63382810ada 100644 --- a/packages/core/src/TopNav/TopNavItem.tsx +++ b/packages/core/src/TopNav/TopNavItem.tsx @@ -35,6 +35,7 @@ import {navItemStyles, type NavItemSize} from '../NavItem/navItemStyles.stylex'; import {mergeProps} from '../utils'; import {useAppShellMobile} from '../AppShell/AppShellMobileContext'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; /** * NavItem styles with hover/selected states @@ -63,14 +64,6 @@ const styles = stylex.create({ }, ':active': colorVars['--color-overlay-pressed'], }, - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, }, selected: { color: colorVars['--color-text-primary'], @@ -87,16 +80,7 @@ const styles = stylex.create({ paddingInline: spacingVars['--spacing-2'], }, // Drawer mode — focus outline (base item + selected come from navItemStyles) - drawerFocus: { - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, - }, + drawerFocus: {}, }); export interface TopNavItemProps extends BaseProps { @@ -246,7 +230,7 @@ export function TopNavItem({ mode: 'drawer', selected: isSelected ? 'selected' : null, }), - stylex.props( + focusOutlineProps.focusVisible( navItemStyles.item, navItemStyles[size], styles.drawerFocus, @@ -283,7 +267,7 @@ export function TopNavItem({ themeProps('top-nav-item', { selected: isSelected ? 'selected' : null, }), - stylex.props( + focusOutlineProps.focusVisible( styles.base, isSelected && styles.selected, isDisabled && navItemStyles.disabled, diff --git a/packages/core/src/TopNav/TopNavMegaMenu.tsx b/packages/core/src/TopNav/TopNavMegaMenu.tsx index d8a753689907..f0799d24becd 100644 --- a/packages/core/src/TopNav/TopNavMegaMenu.tsx +++ b/packages/core/src/TopNav/TopNavMegaMenu.tsx @@ -51,6 +51,7 @@ import {navItemStyles} from '../NavItem/navItemStyles.stylex'; import {useTopNavSlot} from './TopNavContext'; import {useTopNavRenderMode} from './TopNavRenderContext'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; // ============================================================================= // Styles @@ -79,14 +80,6 @@ const styles = stylex.create({ '@media (hover: hover)': colorVars['--color-overlay-hover'], }, }, - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, border: 'none', fontFamily: 'inherit', }, @@ -468,7 +461,10 @@ function DefaultMegaMenu({ onMouseLeave={handleMouseLeave} {...mergeProps( themeProps('top-nav-mega-menu'), - stylex.props(styles.trigger, popover.isOpen && styles.triggerOpen), + focusOutlineProps.focusVisible( + styles.trigger, + popover.isOpen && styles.triggerOpen, + ), )}> {label} {icon &&
{icon}
}
diff --git a/packages/core/src/TopNav/TopNavMenu.tsx b/packages/core/src/TopNav/TopNavMenu.tsx index e8e3c5b5394a..2cfb0e0d73f4 100644 --- a/packages/core/src/TopNav/TopNavMenu.tsx +++ b/packages/core/src/TopNav/TopNavMenu.tsx @@ -37,6 +37,7 @@ import {useTopNavRenderMode} from './TopNavRenderContext'; import {useAppShellMobile} from '../AppShell/AppShellMobileContext'; import {useLinkComponent} from '../Link/useLinkComponent'; import {themeProps} from '../utils/themeProps'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; import { colorVars, spacingVars, @@ -74,14 +75,6 @@ const styles = stylex.create({ '@media (hover: hover)': colorVars['--color-overlay-hover'], }, }, - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, border: 'none', fontFamily: 'inherit', }, @@ -128,14 +121,6 @@ const styles = stylex.create({ }, }, border: 'none', - outline: { - default: null, - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: '0', - ':focus-visible': '2px', - }, }, menuItemIcon: { display: 'flex', @@ -495,7 +480,10 @@ export function TopNavMenu({ {...triggerProps} {...mergeProps( themeProps('top-nav-menu'), - stylex.props(styles.trigger, popover.isOpen && styles.triggerOpen), + focusOutlineProps.focusVisible( + styles.trigger, + popover.isOpen && styles.triggerOpen, + ), )}> {label} + {...focusOutlineProps.focusVisible(styles.menuItem)}>
{item.icon}
diff --git a/packages/core/src/TreeList/TreeListItem.tsx b/packages/core/src/TreeList/TreeListItem.tsx index 761d809eee5c..dabaffb16784 100644 --- a/packages/core/src/TreeList/TreeListItem.tsx +++ b/packages/core/src/TreeList/TreeListItem.tsx @@ -24,6 +24,7 @@ import { easeVars, typeScaleVars, } from '../theme/tokens.stylex'; +import {focusOutlineProps} from '../utils/focusOutline.stylex'; import {getIcon} from '../Icon/globalIconRegistry'; import {mergeProps, rtlStyles} from '../utils'; import {useLinkComponent} from '../Link/useLinkComponent'; @@ -51,14 +52,6 @@ const styles = stylex.create({ // redeclares these vars (default: 'none' / '0'), so a descendant row's // default shadows an ancestor's active value — the ring can never leak // past the nearest containing treeitem, however deep the tree nests. - '--_tree-focus-outline': { - default: 'none', - ':focus-visible': `2px solid ${colorVars['--color-accent']}`, - }, - '--_tree-focus-outline-offset': { - default: '0', - ':focus-visible': '2px', - }, }, childGroup: { margin: 0, @@ -102,19 +95,6 @@ const styles = stylex.create({ ':active': `linear-gradient(${colorVars['--color-overlay-pressed']}, ${colorVars['--color-overlay-pressed']})`, }, }, - focusVisibleOutline: { - outline: { - // Reads the row's own --_tree-focus-outline (published on the
  • in - // `wrapper`), which resolves to the nearest containing treeitem only. - default: 'var(--_tree-focus-outline, none)', - // Also support inner focusable actions. - ':has(:focus-visible)': `2px solid ${colorVars['--color-accent']}`, - }, - outlineOffset: { - default: 'var(--_tree-focus-outline-offset, 0)', - ':has(:focus-visible)': '2px', - }, - }, disabled: { cursor: 'not-allowed', opacity: 0.5, @@ -520,7 +500,7 @@ export function TreeListItem({ data-tree-id={id} data-tree-level={nestedLevel + 1} data-tree-disabled={isDisabled || undefined} - {...stylex.props(styles.wrapper)}> + {...focusOutlineProps.publishFocusVisibleVars(styles.wrapper)}> {variant !== 'noGuides' && (
    diff --git a/packages/core/src/theme/Theme.doc.mjs b/packages/core/src/theme/Theme.doc.mjs index 6c39470e5e31..6a6dcff9ccd0 100644 --- a/packages/core/src/theme/Theme.doc.mjs +++ b/packages/core/src/theme/Theme.doc.mjs @@ -39,7 +39,7 @@ export const docs = { }, usage: { description: - 'Wraps a subtree with a specific Astryx theme. For static production themes, use `astryx theme build` and import the generated CSS plus built theme object for first-paint and SSR performance. Use runtime `defineTheme()` when themes are dynamic or for prototyping.\n\n`defineTheme` accepts a `tokens` object whose keys are CSS custom property names (always prefixed with `--`). Common token names include `--color-accent`, `--color-background-surface`, `--color-background-body`, `--color-text-primary`, `--color-text-secondary`, `--radius-container`, `--spacing-1` through `--spacing-6`. Values can be a string (same for light/dark) or a `[light, dark]` tuple.\n\nExample:\n```ts\nimport {defineTheme} from \'@astryxdesign/core/theme\';\nconst myTheme = defineTheme({\n name: \'ocean\',\n tokens: {\n \'--color-accent\': [\'#0077B6\', \'#48CAE4\'],\n \'--color-background-surface\': [\'#F0F8FF\', \'#0A1628\'],\n \'--color-text-primary\': [\'#0A1317\', \'#FFFFFF\'],\n \'--radius-container\': \'16px\',\n },\n});\n```', + "Wraps a subtree with a specific Astryx theme. For static production themes, use `astryx theme build` and import the generated CSS plus built theme object for first-paint and SSR performance. Use runtime `defineTheme()` when themes are dynamic or for prototyping.\n\n`defineTheme` accepts a `tokens` object whose keys are CSS custom property names (always prefixed with `--`). Common token names include `--color-accent`, `--color-background-surface`, `--color-background-body`, `--color-text-primary`, `--color-text-secondary`, `--radius-container`, `--spacing-1` through `--spacing-6`. Values can be a string (same for light/dark) or a `[light, dark]` tuple.\n\nExample:\n```ts\nimport {defineTheme} from '@astryxdesign/core/theme';\nconst myTheme = defineTheme({\n name: 'ocean',\n tokens: {\n '--color-accent': ['#0077B6', '#48CAE4'],\n '--color-background-surface': ['#F0F8FF', '#0A1628'],\n '--color-text-primary': ['#0A1317', '#FFFFFF'],\n '--radius-container': '16px',\n },\n});\n```", bestPractices: [ { guidance: true, @@ -63,6 +63,17 @@ export const docs = { }, ], }, + + theming: { + targets: [ + { + className: 'astryx-focus-outline', + description: + 'Global focus-ring target applied to general interactive controls. Use this to theme the shared keyboard focus outline; form/input focus treatments remain component-specific.', + states: [':focus-visible', ':has(:focus-visible)'], + }, + ], + }, props: [ { name: 'theme', diff --git a/packages/core/src/utils/focusOutline.stylex.ts b/packages/core/src/utils/focusOutline.stylex.ts new file mode 100644 index 000000000000..827f1de6bd6b --- /dev/null +++ b/packages/core/src/utils/focusOutline.stylex.ts @@ -0,0 +1,90 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file focusOutline.stylex.ts + * @input Uses StyleX and theme color tokens + * @output Exports shared focus-outline prop builders + * @position Internal utility for consistent keyboard focus outlines across core components + * + * Centralizes the standard Astryx focus outline, shown only for keyboard focus. + * Components apply these props directly so the shared focus treatment stays + * a single theme target (`astryx-focus-outline`). Future theming can attach to + * this class-shaped abstraction instead of re-plumbing per-component outline + * values. + */ + +import * as stylex from '@stylexjs/stylex'; +import {colorVars} from '../theme/tokens.stylex'; +import {mergeProps} from './mergeProps'; +import {themeProps} from './themeProps'; + +const FOCUS_OUTLINE_WIDTH = '2px'; +const FOCUS_OUTLINE_OFFSET = '2px'; +const FOCUS_OUTLINE_COLOR = colorVars['--color-accent']; +const FOCUS_OUTLINE = `${FOCUS_OUTLINE_WIDTH} solid ${FOCUS_OUTLINE_COLOR}`; + +export const focusOutlineStyles = stylex.create({ + focusVisible: { + outline: { + default: 'none', + ':focus-visible': FOCUS_OUTLINE, + }, + outlineOffset: { + default: '0', + ':focus-visible': FOCUS_OUTLINE_OFFSET, + }, + }, + focusWithin: { + outline: { + default: 'none', + ':has(:focus-visible)': FOCUS_OUTLINE, + }, + outlineOffset: { + default: '0', + ':has(:focus-visible)': FOCUS_OUTLINE_OFFSET, + }, + }, + publishFocusVisibleVars: { + '--_focus-outline': { + default: 'none', + ':focus-visible': FOCUS_OUTLINE, + }, + '--_focus-outline-offset': { + default: '0', + ':focus-visible': FOCUS_OUTLINE_OFFSET, + }, + }, + focusWithinOrPublished: { + outline: { + default: 'var(--_focus-outline, none)', + ':has(:focus-visible)': FOCUS_OUTLINE, + }, + outlineOffset: { + default: 'var(--_focus-outline-offset, 0)', + ':has(:focus-visible)': FOCUS_OUTLINE_OFFSET, + }, + }, +}); + +// StyleX does not expose a stable public input type for stylex.props(); +// keep this helper permissive so it mirrors stylex.props() itself. +type StyleXPropsArg = unknown; + +function makeFocusOutlineProps(style: StyleXPropsArg) { + return (...styles: StyleXPropsArg[]) => + mergeProps( + themeProps('focus-outline'), + stylex.props(style as never, ...(styles as never[])), + ); +} + +export const focusOutlineProps = { + focusVisible: makeFocusOutlineProps(focusOutlineStyles.focusVisible), + focusWithin: makeFocusOutlineProps(focusOutlineStyles.focusWithin), + publishFocusVisibleVars: makeFocusOutlineProps( + focusOutlineStyles.publishFocusVisibleVars, + ), + focusWithinOrPublished: makeFocusOutlineProps( + focusOutlineStyles.focusWithinOrPublished, + ), +} as const;