Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions design-system/packages/ui/tests/tab-group.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading