Skip to content

feat(segmented-controller): collapsing icon-first variant with reserved width#187

Open
srice-godaddy wants to merge 1 commit into
godaddy:mainfrom
srice-godaddy:feat/segmented-controller-icon-interactions
Open

feat(segmented-controller): collapsing icon-first variant with reserved width#187
srice-godaddy wants to merge 1 commit into
godaddy:mainfrom
srice-godaddy:feat/segmented-controller-icon-interactions

Conversation

@srice-godaddy

@srice-godaddy srice-godaddy commented May 14, 2026

Copy link
Copy Markdown

https://www.loom.com/share/cfaa58c3858840d5ba8ddcaabeed9340

Summary

Implements the icon-first compact variant of SegmentedController for the Airo agent UI work tracked in DF-4867 (under epic DF-4700). When collapseInactive is set, only the selected segment shows its label; the rest collapse to icon-only buttons that surface their label via tooltip on hover or keyboard focus, and the container reserves a stable min-width so the surrounding layout doesn't shift as selection moves between short and long labels.

Changes Made

  • SegmentedControllerItem: new optional icon prop. Required when the parent has collapseInactive (dev warning + label-stays-visible fallback if missing). Existing call-sites that put <Icon /> directly inside children keep working unchanged.
  • SegmentedController: new collapseInactive boolean prop. Internal context tracks the selected value and drives label visibility and tooltip enabling per item.
  • Reserved width: when collapseInactive is on, a hidden, aria-hidden mirror renders each item twice (expanded and collapsed) and the controller measures both states via ResizeObserver + getBoundingClientRect. The worst-case width is applied as min-width on the inner content. Buttons distribute via justify-content: space-between so the first segment is always at the leading edge and the last at the trailing edge.
  • Animations: labels transition width: auto ↔ 0 (using Baseline-2024 interpolate-size: allow-keywords) plus opacity, and the icon/label gap closes to 0 when collapsed — all on the same 200ms timeline as the selection indicator. Falls back to an abrupt swap on browsers without interpolate-size. prefers-reduced-motion disables the transitions.
  • Accessibility: collapsed labels stay in the DOM and a fallback aria-label is set on collapsed items so the accessible name survives the width: 0 collapse. TooltipTrigger is isDisabled for the selected item so it never shows its own tooltip.
  • Storybook + docs: new Collapse Inactive story + README section, playground updated with the new flag.
  • Tests: SSR snapshot for the new example, plus browser tests covering label accessibility, visual hide/show on selection swap, tooltip on focus for unselected, no tooltip on selected, reserved min-width, and stable container footprint across selections.

Test Plan

  • nx run @godaddy/antares:test — all green at 100% coverage
  • Storybook smoke (components/SegmentedControllerCollapseInactive):
    • First / middle / last selections each anchor correctly (leading / centered-via-space-between / trailing)
    • Container width does not change as selection moves
    • Hover / focus on collapsed segments shows the tooltip with the correct label
    • Selected segment's tooltip stays disabled
    • Labels and button widths animate together with the indicator
  • Existing Basic, Controlled, Sizes, Icon, IconOnly, Disabled, Overflow, RTL examples regression-checked via SSR snapshots

Notes for Reviewers

  • New CSS dependency on interpolate-size: allow-keywords (Baseline 2024). Older browsers gracefully degrade to an abrupt label swap — collapse functionality is unchanged, just no smooth animation.
  • package-lock.json is included with peer-dep normalization that surfaced after running npm install during this work.

🤖 Generated with Claude Code

…ed width

Adds `collapseInactive` to SegmentedController and an explicit `icon` prop on
SegmentedControllerItem. When the controller has `collapseInactive` set, only
the selected segment shows its label; the rest collapse to icon-only buttons
that surface their label via tooltip on hover or keyboard focus. The container
measures every segment's expanded and collapsed widths and reserves a
worst-case `min-width` so the outer footprint stays stable as selection moves
between short and long labels. Labels animate `width: auto ↔ 0` on the same
200ms timeline as the selection indicator (relies on `interpolate-size`,
falls back to an abrupt swap on older browsers), and `prefers-reduced-motion`
is honored. Resolves DF-4867.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@srice-godaddy srice-godaddy requested a review from a team as a code owner May 14, 2026 21:24
@changeset-bot

changeset-bot Bot commented May 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 50c6640

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@godaddy/antares Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@rmarkins-godaddy rmarkins-godaddy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@srice-godaddy Your PR has lockfile changes that I don't think were necessary as part of the work you've done. Please undo that so we can have the test suite run correctly. Also

Comment on lines +105 to +114
{icon !== undefined ? (
<>
{icon}
{children !== undefined ? (
<span className={cx(styles.label, !showLabel && styles.labelHidden)}>{children}</span>
) : null}
</>
) : (
children
)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do not nest ternary statements. If that is needed please move this to a function with if/else logic for better readability

const out: { value: string; icon: ReactNode; label: ReactNode }[] = [];
React.Children.toArray(children).forEach(function visit(child) {
if (!React.isValidElement(child) || child.type !== SegmentedControllerItem) return;
const p = child.props as SegmentedControllerItemProps;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No single letter variables

const padInline =
(parseFloat(cs.paddingInlineStart || cs.paddingLeft || '0') || 0) +
(parseFloat(cs.paddingInlineEnd || cs.paddingRight || '0') || 0);
const n = measureItems!.length;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ditto here

Comment on lines +315 to +349
{measureItems ? (
<div ref={mirrorRef} aria-hidden="true" className={styles.mirror}>
{measureItems.map(function expanded(item) {
return (
<Flex
as="span"
key={`e-${item.value}`}
alignItems="center"
gap="xs"
className={styles.item}
data-measure="expanded"
data-value={item.value}
>
{item.icon}
<span className={styles.label}>{item.label}</span>
</Flex>
);
})}
{measureItems.map(function collapsed(item) {
return (
<Flex
as="span"
key={`c-${item.value}`}
alignItems="center"
gap="xs"
className={styles.item}
data-measure="collapsed"
data-value={item.value}
>
{item.icon}
</Flex>
);
})}
</div>
) : null}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please move this out of children and into a sub function w/ if/else logic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants