From d68afa4e2d9c798975fbf74c440ac14c9e1310cf Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 23 Oct 2026 16:51:52 +0800 Subject: [PATCH] fix(ui): keep tab titles clear of stationary close actions --- design-system/packages/ui/README.md | 13 ++++++-- .../src/components/TabGroup/TabGroup.meta.ts | 2 +- .../components/TabGroup/TabGroup.module.css | 33 +++++++++++++++++++ .../packages/ui/tests/tab-group.test.mjs | 10 ++++-- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index d1fa8eeb6a..fbf2c26448 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -394,6 +394,15 @@ continues to own selection, keyboard navigation, label overflow, and end-action anatomy. Product wrappers own document states and layout, without replacing the tab's control styles or creating another tablist. +`TabGroupItem.endAction` reserves space inside the tab surface. On pointer devices, +the icon, label, and non-interactive suffix are centered together at rest, then +shift toward the leading edge as the action fades in on hover. The tab and action +hit areas keep fixed positions and widths. Keyboard focus reveals the action and +moves the content immediately; touch keeps the action visible and the content +clear of it. Reduced motion disables both transitions. Tabs without an end action +keep their ordinary centered layout. Label overflow and rolling-text replacement +remain inside the label slot, independent of this movement. + ## Mobile controls Touch-first controls use the isolated mobile entry so compact and foldable @@ -519,8 +528,8 @@ the content immediately; touch keeps the action visible and the content clear of it. Reduced motion disables both transitions. Capsules without an action do not reserve empty space. Normal constrained-width `OverflowText` behavior remains available. -`TabGroup.endAction` and removable `MultiSelect` tags retain the overlay contract, -including existing native tab buttons. +`TabGroupItem.endAction` uses a reserved slot as described above; removable +`MultiSelect` tags retain the overlay contract. Leading identity icons stay in their label layout; this contract governs the independent trailing actions. Product CSS owns placement of capsules, not their action offsets, backgrounds, or visibility. diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts index 9ea22f538d..7d609f1ac7 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const tabGroupMeta = { category: "navigation", - description: "A compact single-selection tab list with leading icons, glass end actions floating over intact labels, and automatic keyboard activation.", + description: "A compact single-selection tab list with leading icons, a fixed internal slot for glass end actions, and automatic keyboard activation. Content is centered at rest and shifts aside when an action appears without resizing the tab.", maturity: "stable", name: "TabGroup", props: [ diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css index 69438b2bea..7e352e4e4d 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.module.css @@ -5,6 +5,11 @@ --_tab-group-item-padding-inline: var(--openbitfun-control-tab-group-item-padding-inline); --_tab-group-item-gap: var(--openbitfun-control-tab-group-item-gap); --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size); + --_tab-group-inline-direction: 1; + --_tab-group-action-padding: max( + var(--_tab-group-item-padding-inline), + calc((var(--_tab-group-item-height) + var(--openbitfun-control-tab-group-item-action-size)) / 2 + var(--_tab-group-item-gap)) + ); display: inline-flex; align-items: center; @@ -19,6 +24,8 @@ --_tab-group-item-icon-size: var(--openbitfun-control-tab-group-item-icon-size-sm); } + .tabGroup:dir(rtl) { --_tab-group-inline-direction: -1; } + .item { --_floating-action-host-height: var(--_tab-group-item-height); --_floating-action-size: var(--openbitfun-control-tab-group-item-action-size); @@ -93,6 +100,30 @@ cursor: not-allowed; } + /* The action owns a fixed slot inside the tab; hover never resizes either hit area. */ + .item[data-has-end-action="true"] > .tab { + padding-inline-end: var(--_tab-group-action-padding); + } + + @media (hover: hover) and (pointer: fine) { + .item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix) { + transform: translateX(calc( + (var(--_tab-group-action-padding) - var(--_tab-group-item-padding-inline)) / 2 + * var(--_tab-group-inline-direction) + )); + transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .item[data-has-end-action="true"]:is(:hover, :focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix) { + transform: none; + } + } + + .item[data-has-end-action="true"]:is(:focus-visible, :has(:focus-visible)) > .tab > :is(.icon, .label, .labelSuffix), + .item:is(:focus-visible, :has(:focus-visible)) > .endAction > button { + transition: none; + } + .icon { --openbitfun-opacity-icon-artwork: 1; display: inline-flex; @@ -143,6 +174,7 @@ font: inherit; cursor: pointer; transition: + opacity var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } @@ -163,6 +195,7 @@ @media (prefers-reduced-motion: reduce) { .tab, + .item[data-has-end-action="true"] > .tab > :is(.icon, .label, .labelSuffix), .endAction, .endAction > :where(button, [role="button"]):where(:not([data-openbitfun-component="icon-button"])) { transition: none; diff --git a/design-system/packages/ui/tests/tab-group.test.mjs b/design-system/packages/ui/tests/tab-group.test.mjs index 9c7d6e4eab..f33eb795ed 100644 --- a/design-system/packages/ui/tests/tab-group.test.mjs +++ b/design-system/packages/ui/tests/tab-group.test.mjs @@ -167,10 +167,14 @@ test("end actions are rendered beside tabs instead of nesting interactive contro assert.equal((markup.match(/type="button"/g) ?? []).length, 3); }); -test("end actions float over the label without reserving or clipping text", async () => { +test("end actions reserve a fixed slot and move content without resizing hit areas", async () => { const styles = await readFile(new URL("../src/components/TabGroup/TabGroup.module.css", import.meta.url), "utf8"); - assert.doesNotMatch(styles, /action-reserve|clip-path|mask-image/); - assert.doesNotMatch(styles, /data-has-end-action[^}]*padding-inline/s); + assert.match(styles, /\.item\[data-has-end-action="true"\] > \.tab\s*\{\s*padding-inline-end:/); + assert.doesNotMatch(styles, /clip-path|mask-image/); + const hoverRules = [...styles.matchAll(/([^{}]*:hover[^{}]*)\{([^{}]*)\}/g)]; + for (const [, , declarations] of hoverRules) { + assert.doesNotMatch(declarations, /(?:padding|margin|(?:inline|block)-size|width|height)[\w-]*\s*:/); + } }); test("TabGroup styling uses its geometry contract and Button semantic colors", async () => {