diff --git a/.changeset/link-anchor-clamp.md b/.changeset/link-anchor-clamp.md new file mode 100644 index 0000000000000..8360121a8bead --- /dev/null +++ b/.changeset/link-anchor-clamp.md @@ -0,0 +1,6 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Render a plain Link anchor as `inline` so an ancestor `` clamp can truncate it; flex layout is kept for the external-link icon and button forms, and Link/Text docs now cover the external-link clamp limitation. (#6021) +@ManoharPaturi diff --git a/packages/core/src/Link/Link.doc.mjs b/packages/core/src/Link/Link.doc.mjs index 48cc492bdb4f2..406e8b84dcf49 100644 --- a/packages/core/src/Link/Link.doc.mjs +++ b/packages/core/src/Link/Link.doc.mjs @@ -133,6 +133,13 @@ export const docs = { description: 'Maximum lines before truncation.', default: '0', }, + { + name: 'hasBlockChild', + type: 'boolean', + description: + 'Set when children render a block-level element (e.g. an HStack composing an icon with the label), so the Link keeps its inline-flex root and stays visibly focused. Trade-off: an ancestor can no longer truncate it — pass `maxLines` to the Link itself instead.', + default: 'false', + }, { name: 'children', type: 'ReactNode', @@ -169,10 +176,14 @@ export const docs = { bestPractices: [ { guidance: true, description: 'Write descriptive, concise link text that clearly communicates the destination.' }, { guidance: true, description: 'Set `isStandalone` when the link appears outside of inline text, so it receives proper base font sizing.' }, + { guidance: true, description: 'Let a plain Link be truncated by an ancestor ; the anchor participates in the surrounding line boxes.' }, { guidance: true, description: 'Only set `label` when the link content is not descriptive text (e.g. an icon-only link). For text links, the visible text is already the accessible name; adding `label` overrides it for screen readers, which is harmful.' }, { guidance: false, description: 'Use Link for actions that do not navigate; use a Button instead.' }, { guidance: false, description: 'Use generic text like "click here" or "read more"; describe the destination.' }, { guidance: false, description: 'Set `label` on text links; `aria-label` prevents assistive technology from reading the actual link content.' }, + { guidance: true, description: 'Set `hasBlockChild` when Link children render a block-level element (e.g. an HStack composing an icon with the label), so keyboard focus stays visible.' }, + { guidance: false, description: 'Expect an ancestor to truncate an external Link. Its external-link icon uses an inline-flex layout, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, + { guidance: false, description: 'Expect an ancestor to truncate a Link with `hasBlockChild` set. Its block-level content needs the inline-flex root, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, @@ -301,10 +312,14 @@ export const docsZh = { bestPractices: [ { guidance: true, description: 'Write descriptive, concise link text that clearly communicates the destination.' }, { guidance: true, description: 'Set `isStandalone` when the link appears outside of inline text, so it receives proper base font sizing.' }, + { guidance: true, description: 'Let a plain Link be truncated by an ancestor ; the anchor participates in the surrounding line boxes.' }, { guidance: true, description: 'Only set `label` when the link content is not descriptive text (e.g. an icon-only link). For text links, the visible text is already the accessible name; adding `label` overrides it for screen readers, which is harmful.' }, { guidance: false, description: 'Use Link for actions that do not navigate; use a Button instead.' }, { guidance: false, description: 'Use generic text like "click here" or "read more"; describe the destination.' }, { guidance: false, description: 'Set `label` on text links; `aria-label` prevents assistive technology from reading the actual link content.' }, + { guidance: true, description: 'Set `hasBlockChild` when Link children render a block-level element (e.g. an HStack composing an icon with the label), so keyboard focus stays visible.' }, + { guidance: false, description: 'Expect an ancestor to truncate an external Link. Its external-link icon uses an inline-flex layout, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, + { guidance: false, description: 'Expect an ancestor to truncate a Link with `hasBlockChild` set. Its block-level content needs the inline-flex root, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, @@ -324,10 +339,14 @@ export const docsDense = { bestPractices: [ { guidance: true, description: 'Write descriptive, concise link text that clearly communicates the destination.' }, { guidance: true, description: 'Set `isStandalone` when the link appears outside of inline text, so it receives proper base font sizing.' }, + { guidance: true, description: 'Let a plain Link be truncated by an ancestor ; the anchor participates in the surrounding line boxes.' }, { guidance: true, description: 'Only set `label` when the link content is not descriptive text (e.g. an icon-only link). For text links, the visible text is already the accessible name; adding `label` overrides it for screen readers, which is harmful.' }, { guidance: false, description: 'Use Link for actions that do not navigate; use a Button instead.' }, { guidance: false, description: 'Use generic text like "click here" or "read more"; describe the destination.' }, { guidance: false, description: 'Set `label` on text links; `aria-label` prevents assistive technology from reading the actual link content.' }, + { guidance: true, description: 'Set `hasBlockChild` when Link children render a block-level element (e.g. an HStack composing an icon with the label), so keyboard focus stays visible.' }, + { guidance: false, description: 'Expect an ancestor to truncate an external Link. Its external-link icon uses an inline-flex layout, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, + { guidance: false, description: 'Expect an ancestor to truncate a Link with `hasBlockChild` set. Its block-level content needs the inline-flex root, which an ancestor clamp cannot reach — pass `maxLines` to the Link itself.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, @@ -355,6 +374,8 @@ export const docsDense = { onClick: 'Click event handler', tooltip: 'Tooltip text on hover', isStandalone: 'Applies base font sizing', + hasBlockChild: + 'Set when children render a block-level element (e.g. HStack + icon), so the Link keeps its inline-flex root and stays visibly focused. Trade-off: an ancestor maxLines clamp can no longer reach it.', children: 'Link content', }, }, diff --git a/packages/core/src/Link/Link.test.tsx b/packages/core/src/Link/Link.test.tsx index fafaf5c561286..637de184a98a6 100644 --- a/packages/core/src/Link/Link.test.tsx +++ b/packages/core/src/Link/Link.test.tsx @@ -14,6 +14,9 @@ import {fireEvent, render, screen} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {Link} from './Link'; import {LinkProvider} from './LinkProvider'; +import {HStack} from '../HStack'; +import {Icon} from '../Icon'; +import {Text} from '../Text'; function CustomLink({ children, @@ -415,3 +418,149 @@ describe('Link', () => { expect(link.className).toContain('secondary'); }); }); + +// ============================================================================= +// Display: an ancestor clamp () can only truncate an anchor +// that participates in the surrounding line boxes, so the plain anchor must +// be `inline`; flex layout is reserved for the icon/button forms (#6021). +// ============================================================================= + +/** + * Extracts class-name tokens from a CSS selector (e.g. the selectorText of + * a CSSStyleRule), so callers can match against an element's classList by + * exact token rather than by substring. Handles compound selectors + * (`.a.b:hover`) and comma-separated selector lists (`.a, .b`). + */ +function classTokensFromSelector(selectorText: string): string[] { + const tokens: string[] = []; + const classRegex = /\.([-_a-zA-Z0-9]+)/g; + let match: RegExpExecArray | null; + while ((match = classRegex.exec(selectorText)) !== null) { + tokens.push(match[1]); + } + return tokens; +} + +/** + * Recursively collects every CSSStyleRule from a rule list, descending into + * grouping rules (e.g. `@media`) so declarations nested in a condition are + * not missed. + */ +function collectStyleRules(rules: CSSRuleList, out: CSSStyleRule[]): void { + for (const rule of Array.from(rules)) { + if (rule instanceof CSSStyleRule) { + out.push(rule); + } else if ('cssRules' in rule && rule.cssRules) { + collectStyleRules((rule as CSSGroupingRule).cssRules, out); + } + } +} + +/** + * Returns the `display` value of every injected CSS rule that targets `el`, + * matched via the CSSOM (real selector tokens + real class list) rather than + * substring checks on raw selector/body text — a hashed atomic class like + * `x1a` can be a substring of an unrelated class like `x1a2b3c4`, so + * string-based matching can both false-positive and false-negative. + */ +function displayDeclarationsFor(el: Element): string[] { + const classes = new Set(Array.from(el.classList)); + const styleRules: CSSStyleRule[] = []; + for (const sheet of Array.from(document.styleSheets)) { + let rules: CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + continue; + } + collectStyleRules(rules, styleRules); + } + + const declarations: string[] = []; + for (const rule of styleRules) { + const matchesElement = classTokensFromSelector(rule.selectorText).some( + token => classes.has(token), + ); + if (!matchesElement) { + continue; + } + const display = rule.style.getPropertyValue('display'); + if (display) { + declarations.push(display.trim()); + } + } + return declarations; +} + +describe('Link display', () => { + it('renders a plain anchor inline so an ancestor clamp can reach it', () => { + const {container} = render(Documentation); + const anchor = container.querySelector('a')!; + expect(displayDeclarationsFor(anchor)).toContain('inline'); + expect(displayDeclarationsFor(anchor)).not.toContain('inline-flex'); + }); + + it('keeps flex layout for the external-link icon form', () => { + const {container} = render( + + External docs + , + ); + const anchor = container.querySelector('a')!; + expect(displayDeclarationsFor(anchor)).toContain('inline-flex'); + }); + + it('keeps the flex root (visible focus box) for display="block" links', () => { + const {container} = render( + + Documentation + , + ); + expect(displayDeclarationsFor(container.querySelector('a')!)).toContain( + 'inline-flex', + ); + }); + + it('keeps the flex root for links clamping themselves via maxLines', () => { + const {container} = render( + + Documentation + , + ); + expect(displayDeclarationsFor(container.querySelector('a')!)).toContain( + 'inline-flex', + ); + }); + + it('keeps flex layout for the button-rendered form', () => { + const {container} = render( + {}} role="button"> + Action + , + ); + const button = container.querySelector('button')!; + expect(displayDeclarationsFor(button)).toContain('inline-flex'); + }); + + it('keeps the flex root (visible focus box) when hasBlockChild flags a composed child like HStack + Icon', () => { + const {container} = render( + + + + Documentation + + , + ); + const anchor = container.querySelector('a')!; + expect(displayDeclarationsFor(anchor)).toContain('inline-flex'); + }); + + it('stays inline for a plain-text Link so an ancestor clamp still reaches it', () => { + const {container} = render( + Plain text, no composed children, + ); + const anchor = container.querySelector('a')!; + expect(displayDeclarationsFor(anchor)).toContain('inline'); + expect(displayDeclarationsFor(anchor)).not.toContain('inline-flex'); + }); +}); diff --git a/packages/core/src/Link/Link.tsx b/packages/core/src/Link/Link.tsx index 72ffa07cdd048..43bed7672638a 100644 --- a/packages/core/src/Link/Link.tsx +++ b/packages/core/src/Link/Link.tsx @@ -52,9 +52,12 @@ import {useTranslator} from '../i18n'; */ const styles = stylex.create({ base: { - display: 'inline-flex', - alignItems: 'center', - gap: spacingVars['--spacing-0-5'], + // `inline` (not `inline-flex`) so the anchor participates in the + // surrounding line boxes and an ancestor clamp (e.g. ) + // can truncate it. An inline-flex box establishes its own formatting + // context, which an ancestor -webkit-line-clamp cannot reach — + // ... silently did nothing. + display: 'inline', fontFamily: 'inherit', fontSize: 'inherit', lineHeight: 'inherit', @@ -84,6 +87,17 @@ const styles = stylex.create({ pointerEvents: 'auto', position: 'relative', }, + /** + * Flex layout for the cases that need it: external links append an icon + * after the text (icon centering + gap), and the button-rendered form + * keeps its previous inline-flex layout. On plain anchors this is + * deliberately NOT applied — see the `base` display note. + */ + flexLayout: { + display: 'inline-flex', + alignItems: 'center', + gap: spacingVars['--spacing-0-5'], + }, hasUnderline: { textDecoration: 'underline', }, @@ -256,6 +270,19 @@ export interface LinkProps extends BaseProps< * @default 0 */ maxLines?: number; + /** + * Set this when `children` renders a block-level element — for example a + * layout primitive like `HStack`/`VStack` composing an icon with the + * label. A plain anchor is `inline` so an ancestor `` clamp + * can truncate it, but an inline anchor wrapping a block-level child + * computes a focus outline that paints nothing in Chromium. Setting this + * keeps the Link's inline-flex root so keyboard focus stays visible; the + * trade-off is that an ancestor clamp can no longer truncate it (same + * limitation as `isExternalLink` — pass `maxLines` to the Link itself + * instead). + * @default false + */ + hasBlockChild?: boolean; /** * Link content (required). */ @@ -307,6 +334,7 @@ export function Link({ color = 'accent', display = 'inline', maxLines = 0, + hasBlockChild = false, children, rel: relFromProps, xstyle, @@ -329,6 +357,19 @@ export function Link({ // render as a