ARM: before-main BASE: /Users/cindyxz/astryx/worktrees/main @ d4d4fc58b0bd2e21ad7ab23a492edcb4aafa4bc5 DELTA: none; warm main baseline served on :6100 UNRELATED DELTA: none in worktree; compared files unchanged from PR merge-base to origin/main ARM: after-pr BASE: /Users/cindyxz/astryx/worktrees/review-pr-5558-badge-truncate @ 1733f9bf784f7a491409e0b59c1d789b83d8ac03 DELTA: final PR diff below (origin/main...HEAD; node_modules symlink was added and removed inside the commit history and is absent at head) UNRELATED DELTA: evidence files and storybook pid/log are untracked local review artifacts only .changeset/badge-clamp-long-label.md | 36 +++++++++++++++++++ apps/storybook/stories/Badge.stories.tsx | 62 ++++++++++++++++++++++++++++++++ packages/core/src/Badge/Badge.tsx | 20 ++++++++++- 3 files changed, 117 insertions(+), 1 deletion(-) diff --git a/.changeset/badge-clamp-long-label.md b/.changeset/badge-clamp-long-label.md new file mode 100644 index 00000000000..0cddbb1c5f4 --- /dev/null +++ b/.changeset/badge-clamp-long-label.md @@ -0,0 +1,36 @@ +--- +'@astryxdesign/core': patch +--- + +[fix] Badge: a long label no longer escapes its container. + +`Badge` set `white-space: nowrap` with nothing to clip it — the one pairing +that neither wraps nor truncates. A label wider than the space available +rendered _outside_ the badge's container and over whatever sat beside it. + +```tsx +
+ +
+``` + +Measured in Chromium: that badge came out **163px** wide in a 100px column, +spilling 63px past it; in a fixed-layout table cell it painted 64px over the +text in the next cell. The badge now clamps to the width it is given and cuts +the label with an ellipsis. + +A badge that already fits is untouched — same width, same height, same DOM. +Measured before and after, a badge with room to spare is 53px either way; only +the cases that were already overflowing change. `Badge` uses no hooks and +stays server-renderable. + +The ellipsis sits on an inner label span rather than the badge itself, because +`text-overflow` needs a block container and taking the root off `inline-flex` +to get one would cost the icon its centring. With an icon, the icon holds its +place and the label gives way. + +Not included: a tooltip carrying the full text. That needs runtime measurement +and would make `Badge` a client component, so it is a separate change with its +own trade-off to weigh. + +@freddymeta diff --git a/apps/storybook/stories/Badge.stories.tsx b/apps/storybook/stories/Badge.stories.tsx index ada01cb53dc..c26306b59f9 100644 --- a/apps/storybook/stories/Badge.stories.tsx +++ b/apps/storybook/stories/Badge.stories.tsx @@ -92,3 +92,65 @@ export const NonSemanticColors: Story = { ), }; + +// A badge is one line, so a label wider than the space it has is cut short +// with an ellipsis rather than escaping the container. Hover a truncated one +// for the full text. +export const LongLabels: Story = { + name: 'Long labels in tight space', + render: () => ( +
+
+
+ In a 100px column +
+
+ +
+
+
+
+ With an icon — the icon holds its place, the label gives way +
+
+
+
+
+
+ Room to spare — unchanged +
+
+ +
+
+
+ ), + parameters: { + docs: { + description: { + story: + 'A badge has a fixed height and never wraps, so a label wider than its container is truncated with an ellipsis rather than escaping the container. A badge that fits is untouched — the clamp only ever changes the case that would otherwise overflow.', + }, + }, + }, +}; diff --git a/packages/core/src/Badge/Badge.tsx b/packages/core/src/Badge/Badge.tsx index 14fe58053f3..3fe3a669020 100644 --- a/packages/core/src/Badge/Badge.tsx +++ b/packages/core/src/Badge/Badge.tsx @@ -46,6 +46,24 @@ const styles = stylex.create({ lineHeight: typeScaleVars['--text-supporting-leading'], fontWeight: fontWeightVars['--font-weight-medium'], whiteSpace: 'nowrap', + // A badge is one line by construction — fixed height, `nowrap` — so a + // label wider than the space available has to go somewhere. Without these + // it went *outside* its container: `nowrap` with nothing to clip it + // neither wraps nor truncates, it just escapes, and lands on whatever sits + // beside it. `minWidth: 0` matters as much as the max: as a flex item the + // automatic minimum size would otherwise hold the badge at its full text + // width and push the clamp back out again. + maxWidth: '100%', + minWidth: 0, + }, + // The ellipsis goes on the label rather than the badge itself, because + // `text-overflow` needs a block container and taking the root off + // `inline-flex` to get one would cost the icon its centring. The label is a + // flex item, so it needs its own `minWidth: 0` for the same reason as above. + label: { + overflow: 'hidden', + textOverflow: 'ellipsis', + minWidth: 0, }, }); @@ -174,7 +192,7 @@ export function Badge({ )} {...props}> {icon} - {label} + {label} ); }