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..b68e77160411a 100644 --- a/packages/core/src/Link/Link.doc.mjs +++ b/packages/core/src/Link/Link.doc.mjs @@ -169,10 +169,12 @@ 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: 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.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, @@ -301,10 +303,12 @@ 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: 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.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, @@ -324,10 +328,12 @@ 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: 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.' }, ], anatomy: [ {name: 'Label', required: true, description: 'The visible text of the link.'}, diff --git a/packages/core/src/Link/Link.test.tsx b/packages/core/src/Link/Link.test.tsx index fafaf5c561286..8df3883977372 100644 --- a/packages/core/src/Link/Link.test.tsx +++ b/packages/core/src/Link/Link.test.tsx @@ -415,3 +415,105 @@ 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 flex layout for the button-rendered form', () => { + const {container} = render( + {}} role="button"> + Action + , + ); + const button = container.querySelector('button')!; + expect(displayDeclarationsFor(button)).toContain('inline-flex'); + }); +}); diff --git a/packages/core/src/Link/Link.tsx b/packages/core/src/Link/Link.tsx index 72ffa07cdd048..6dcaa9300b4a7 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', }, @@ -329,6 +343,7 @@ export function Link({ // render as a