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
Original file line number Diff line number Diff line change
Expand Up @@ -1850,6 +1850,9 @@ export function ComponentDetailPage({
<NavigationPanelItem reserveLeadingSpace>
{t("components.preview.navigationPanelMoreItem")}
</NavigationPanelItem>
<NavigationPanelItem labelBehavior="static" style={{ maxInlineSize: 180, whiteSpace: "normal" }}>
{t("components.preview.cardDescription")}
</NavigationPanelItem>
</NavigationPanelSection>
</NavigationPanelContent>
</NavigationPanelBody>
Expand Down
22 changes: 22 additions & 0 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,15 @@ its virtual active state through this contract. Selected
tabs do not animate automatically. Motion respects `prefers-reduced-motion`;
reduced-motion users keep the static fade. Text and movement follow RTL direction.

Use `overflowStyle="ellipsis"` when a single-line slot requires a visible ellipsis
instead of a resting fade. It works with static `behavior="fade"` labels and with
`behavior="marquee"`, which reveals the full text on interaction and returns to
the ellipsis afterward. Reduced motion keeps the ellipsis. Multiline clamps retain
their existing behavior. Set `marqueeTrigger="interaction"` to ignore virtual
active state on both the label and its owner; the default
`"interaction-or-active"` preserves existing listbox behavior. This also prevents
virtual activation from opening the label's tooltip; actual hover/focus still works.

Rich children default to fade to preserve the label's existing inline composition.
Composite containers keep their icons/actions fixed and give each text slot its
own `OverflowText`. Marquee measures and
Expand Down Expand Up @@ -228,6 +237,19 @@ Hosts without Web Animations render the current text immediately.
The **RollingText** Design Lab entry includes manual standalone and TabGroup
examples for repeated replacement and long labels.

`ActionItem` and its `NavigationPanelItem` composition also accept
`labelBehavior="static"` to preserve wrapping labels without an implicit overflow
tooltip or marquee. Their default remains `"overflow"`. The public `label` part
retains its typography, and the static label inherits whitespace from its owner.
`NavigationPanelItem.className` styles the outer row; native button attributes and
the ref reach its `trigger` part. Keep row layout separate from trigger styling.

`TabGroupItem.tabProps` applies `className`, `style`, `title`, `aria-label`,
`aria-describedby`, and `data-*` attributes directly to the native tab button.
Use product-namespaced data attributes for product appearance identity. The
component retains its own part marker, IDs, panel association, disabled state,
selection, focus, and event handling. Keep IDs and panel IDs in the item fields.

`TabGroup.renderItem(item, node, index)` can wrap the supplied standard item in
a tooltip, context-menu owner, or drag target. Keep `node` intact so TabGroup
continues to own selection, keyboard navigation, label overflow, and end-action
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const actionItemMeta = {
maturity: "stable",
name: "ActionItem",
props: [
{ defaultValue: "overflow", name: "labelBehavior", type: "overflow | static" },
{ name: "children", type: "ReactNode" },
{ name: "leading", type: "ReactNode" },
{ defaultValue: "false", name: "reserveLeadingSpace", type: "boolean" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
white-space: nowrap;
}

.staticLabel {
overflow: visible;
white-space: inherit;
}

.metadata,
.shortcut,
.actions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface ActionItemProps
children: ReactNode;
className?: string;
leading?: ReactNode;
/** Static labels preserve wrapping without implicit overflow motion or tooltips. */
labelBehavior?: "overflow" | "static";
metadata?: ReactNode;
reserveLeadingSpace?: boolean;
shortcut?: ReactNode;
Expand All @@ -42,6 +44,7 @@ export const ActionItem = forwardRef<HTMLButtonElement, ActionItemProps>(functio
className,
disabled,
leading,
labelBehavior = "overflow",
metadata,
reserveLeadingSpace = false,
shortcut,
Expand Down Expand Up @@ -71,7 +74,11 @@ export const ActionItem = forwardRef<HTMLButtonElement, ActionItemProps>(functio
{leading}
</span>
)}
<OverflowText className={styles.label} data-openbitfun-part="label">{children}</OverflowText>
{labelBehavior === "static" ? (
<span className={classNames(styles.label, styles.staticLabel)} data-openbitfun-part="label">{children}</span>
) : (
<OverflowText className={styles.label} data-openbitfun-part="label">{children}</OverflowText>
)}
{metadata !== undefined && metadata !== null && (
<span className={styles.metadata} data-openbitfun-part="metadata">
{metadata}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const tabGroupMeta = {
{ name: "items", type: "readonly TabGroupItem[]" },
{ name: "items[].labelTransitionKey", type: "string | number" },
{ name: "items[].labelSuffix", type: "ReactNode" },
{ name: "items[].tabProps", type: "TabGroupItem['tabProps']" },
{ name: "value", type: "string" },
{ name: "defaultValue", type: "string" },
{ name: "onValueChange", type: "(value: string) => void" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export interface TabGroupItem {
/** Opt plain text into vertical replacement without remounting the tab. */
labelTransitionKey?: string | number;
panelId?: string;
/** Presentation and product data attributes on the native tab; selection stays owned by TabGroup. */
tabProps?: Pick<HTMLAttributes<HTMLButtonElement>, "className" | "style" | "title" | "aria-label" | "aria-describedby">
& Record<`data-${string}`, string | number | boolean | undefined>;
value: string;
}

Expand Down Expand Up @@ -144,10 +147,11 @@ export const TabGroup = forwardRef<HTMLDivElement, TabGroupProps>(function TabGr
key={item.value}
>
<button
{...item.tabProps}
aria-controls={item.panelId}
aria-disabled={item.disabled || undefined}
aria-selected={selected}
className={styles.tab}
className={classNames(styles.tab, item.tabProps?.className)}
data-openbitfun-part="tab"
data-openbitfun-value={item.value}
disabled={item.disabled}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
padding-block: calc(var(--openbitfun-space-1) / 2);
}

.root[data-overflow-style="ellipsis"] > .content {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
}

.root[data-overflow-lines] {
display: -webkit-box;
-webkit-box-orient: vertical;
Expand All @@ -51,8 +57,8 @@
}

/* Keep resting specificity low so the owning control can reveal the full end. */
.root[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]),
.root[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) {
.root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]),
.root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) {
-webkit-mask-image: linear-gradient(
to right,
currentColor 0,
Expand All @@ -69,8 +75,8 @@
mask-repeat: no-repeat;
}

.root:where(:dir(rtl))[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]),
.root:where(:dir(rtl))[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) {
.root:not([data-overflow-style="ellipsis"]):where(:dir(rtl))[data-overflow-behavior="fade"][data-overflow="true"]:not([data-overflow-lines]),
.root:not([data-overflow-style="ellipsis"]):where(:dir(rtl))[data-overflow-behavior="marquee"][data-overflow="true"]:where(:not(:hover):not(:focus-visible):not([data-marquee-active="true"])) {
-webkit-mask-image: linear-gradient(
to left,
currentColor 0,
Expand All @@ -85,15 +91,20 @@
);
}

:where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] {
:where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"])):not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"],
:where([data-overflow-trigger]:is(:hover, :focus-within)) .root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"] {
-webkit-mask-image: none;
mask-image: none;
}

:where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content,
:where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"]))[data-overflow-behavior="marquee"][data-overflow="true"] .content,
:where([data-overflow-trigger]:is(:hover, :focus-within)) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"]:hover .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"]:focus-visible .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"][data-marquee-active="true"] .content {
min-inline-size: max-content;
overflow: visible;
text-overflow: clip;
animation: openbitfun-overflow-text-marquee var(--_overflow-text-marquee-duration) linear infinite alternate;
will-change: transform;
}
Expand All @@ -110,7 +121,7 @@
}

@media (prefers-reduced-motion: reduce) {
.root[data-overflow-behavior="marquee"][data-overflow="true"] {
.root:not([data-overflow-style="ellipsis"])[data-overflow-behavior="marquee"][data-overflow="true"] {
-webkit-mask-image: linear-gradient(
to right,
currentColor 0,
Expand All @@ -127,7 +138,7 @@
mask-repeat: no-repeat;
}

.root:dir(rtl)[data-overflow-behavior="marquee"][data-overflow="true"] {
.root:not([data-overflow-style="ellipsis"]):dir(rtl)[data-overflow-behavior="marquee"][data-overflow="true"] {
-webkit-mask-image: linear-gradient(
to left,
currentColor 0,
Expand All @@ -142,20 +153,27 @@
);
}

:where([data-overflow-trigger]:is(:hover, :focus-within, [data-overflow-active="true"])) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content,
:where([data-overflow-trigger][data-overflow-active="true"]) .root:where(:not([data-marquee-trigger="interaction"]))[data-overflow-behavior="marquee"][data-overflow="true"] .content,
:where([data-overflow-trigger]:is(:hover, :focus-within)) .root[data-overflow-behavior="marquee"][data-overflow="true"] .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"]:hover .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"]:focus-visible .content,
.root[data-overflow-behavior="marquee"][data-overflow="true"][data-marquee-active="true"] .content {
animation: none;
transform: none;
will-change: auto;
}

.root[data-overflow-style="ellipsis"][data-overflow-behavior="marquee"][data-overflow="true"] > .content {
min-inline-size: 0;
overflow: hidden;
text-overflow: ellipsis;
}
}

@media (forced-colors: active) {
.root:dir(rtl)[data-overflow-behavior][data-overflow="true"],
.root[data-overflow-behavior][data-overflow="true"],
.root[data-overflow-behavior][data-overflow="true"]:not(:hover):not(:focus-visible):not([data-marquee-active="true"]) {
.root:not([data-overflow-style="ellipsis"]):dir(rtl)[data-overflow-behavior][data-overflow="true"],
.root:not([data-overflow-style="ellipsis"])[data-overflow-behavior][data-overflow="true"],
.root:not([data-overflow-style="ellipsis"])[data-overflow-behavior][data-overflow="true"]:not(:hover):not(:focus-visible):not([data-marquee-active="true"]) {
-webkit-mask-image: none;
mask-image: none;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export interface OverflowTextProps extends HTMLAttributes<HTMLElement> {
lines?: number;
/** Plain text defaults to marquee; rich composition defaults to a static fade. */
behavior?: OverflowTextBehavior;
/** Single-line resting treatment; multiline clamps keep their existing layout. */
overflowStyle?: "fade" | "ellipsis";
/** Interaction-only text ignores virtual active state on itself and its owner. */
marqueeTrigger?: "interaction-or-active" | "interaction";
/** Runs an overflowing marquee while its owning control is virtually active. */
marqueeActive?: boolean;
}
Expand All @@ -54,6 +58,8 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(
className,
lines,
marqueeActive = false,
marqueeTrigger = "interaction-or-active",
overflowStyle = "fade",
style,
title,
...props
Expand Down Expand Up @@ -124,7 +130,7 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(

useIsomorphicLayoutEffect(() => {
updateOverflow();
}, [behavior, children, lines, updateOverflow]);
}, [behavior, children, lines, overflowStyle, updateOverflow]);

useEffect(() => {
if (measurementRef.current.isOverflowing) prepareTooltip();
Expand Down Expand Up @@ -160,7 +166,7 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(
element.ownerDocument.defaultView?.removeEventListener("resize", updateOverflow);
}
};
}, [behavior, prepareTooltip, textOnly, updateOverflow]);
}, [behavior, overflowStyle, prepareTooltip, textOnly, updateOverflow]);

const marqueeDuration = Math.max(
MARQUEE_MIN_DURATION_MS,
Expand All @@ -182,25 +188,27 @@ export const OverflowText = forwardRef<HTMLElement, OverflowTextProps>(
<Tag
{...props}
className={classNames(styles.root, className)}
data-marquee-active={marqueeActive ? "true" : undefined}
data-marquee-active={marqueeActive && marqueeTrigger !== "interaction" ? "true" : undefined}
data-marquee-trigger={marqueeTrigger}
data-overflow={measurement.isOverflowing ? "true" : "false"}
data-overflow-behavior={behavior}
data-overflow-lines={lines}
data-overflow-style={lines ? undefined : overflowStyle}
data-overflow-tooltip={hasOverflowTooltip ? "true" : undefined}
data-overflow-text={title}
ref={setElementRef}
style={resolvedStyle}
title={hasOverflowTooltip ? undefined : title}
>
{behavior === "marquee" || (textOnly && lines === undefined) ? (
{behavior === "marquee" || ((textOnly || overflowStyle === "ellipsis") && lines === undefined) ? (
<span className={styles.content} data-openbitfun-part="content" data-overflow-content="" ref={contentRef}>
{children}
</span>
) : children}
</Tag>
{hasOverflowTooltip && (
<Tooltip
active={marqueeActive}
active={marqueeActive && marqueeTrigger !== "interaction"}
content={tooltipText}
interactive
onBeforeShow={prepareTooltip}
Expand Down
15 changes: 15 additions & 0 deletions design-system/packages/ui/tests/navigation-panel.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ import {
NavigationPanelSeparator,
} from "../dist/index.js";

test("NavigationPanelItem forwards static labels and keeps native navigation semantics", () => {
const markup = renderToStaticMarkup(createElement(NavigationPanelItem, {
selected: true, labelBehavior: "static", className: "horizontal-navigation",
title: "My submissions", "data-active": true,
}, "My submitted appearance packages"));
assert.match(markup, /<span[^>]+class="[^"]+ horizontal-navigation"/);
assert.match(markup, /<button[^>]+aria-current="page"/);
assert.match(markup, /data-active="true"/);
assert.match(markup, /type="button"/);
assert.match(markup, /data-openbitfun-part="label">My submitted appearance packages<\/span>/);
assert.doesNotMatch(markup, /data-overflow-behavior|data-overflow-content|labelBehavior/);
const defaultMarkup = renderToStaticMarkup(createElement(NavigationPanelItem, null, "Navigation"));
assert.match(defaultMarkup, /data-overflow-behavior="marquee"/);
});

test("NavigationPanel composes independent header, grouped body, and footer regions", () => {
const markup = renderToStaticMarkup(
createElement(
Expand Down
20 changes: 18 additions & 2 deletions design-system/packages/ui/tests/overflow-text.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn
assert.match(source, /measurementRef\.current/);
assert.match(
source,
/useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, updateOverflow\]\);/s,
/useIsomorphicLayoutEffect\(\(\) => \{\s*updateOverflow\(\);\s*\}, \[behavior, children, lines, overflowStyle, updateOverflow\]\);/s,
);
assert.match(source, /new ResizeObserver\(updateOverflow\)/);
assert.match(source, /resizeObserver\?\.observe\(contentRef\.current\)/);
Expand All @@ -77,5 +77,21 @@ test("OverflowText measures real clipping for fade and marquee treatments", asyn
assert.match(styles, /openbitfun-overflow-text-marquee/);
assert.match(styles, /prefers-reduced-motion:\s*reduce/);
assert.match(styles, /\.root:dir\(rtl\)/);
assert.doesNotMatch(styles, /text-overflow:\s*ellipsis/);
assert.match(styles, /\.root\[data-overflow-style="ellipsis"\] > \.content\s*\{[^}]*text-overflow:\s*ellipsis/s);
assert.match(styles, /data-overflow-active="true"[^\n]+:not\(\[data-marquee-trigger="interaction"\]\)/);
});

test("OverflowText makes resting ellipsis and interaction-only activation explicit", () => {
const markup = renderToStaticMarkup(createElement(OverflowText, {
overflowStyle: "ellipsis", marqueeTrigger: "interaction", marqueeActive: true,
}, "The complete description"));
assert.match(markup, /data-overflow-style="ellipsis"/);
assert.match(markup, /data-marquee-trigger="interaction"/);
assert.doesNotMatch(markup, /data-marquee-active/);
assert.match(markup, /The complete description/);
const multiline = renderToStaticMarkup(createElement(OverflowText, {
overflowStyle: "ellipsis", lines: 2,
}, "The complete description"));
assert.match(multiline, /data-overflow-lines="2"/);
assert.doesNotMatch(multiline, /data-overflow-style/);
});
27 changes: 27 additions & 0 deletions design-system/packages/ui/tests/tab-group.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,33 @@ test("TabGroup exposes a single selected tab with native button behavior", () =>
assert.equal((markup.match(/data-overflow-trigger="true"/g) ?? []).length, 2);
});

test("tab presentation props reach the button without replacing its owned semantics", () => {
const markup = renderToStaticMarkup(createElement(TabGroup, {
items: [{ ...items[0], id: "product-tab", tabProps: {
className: "product-tab", "data-openbitfun-product-component": "product",
"data-openbitfun-product-part": "tab", title: "Full title",
"aria-describedby": "help", style: { opacity: 0.8 },
// Untyped callers must not override component-owned interaction/identity.
role: "button", type: "submit", tabIndex: 8, id: "wrong-id",
"aria-selected": false, "aria-controls": "wrong-panel", "data-openbitfun-part": "wrong-part",
} }],
}));
const button = markup.match(/<button\b[^>]*>/)?.[0] ?? "";
assert.match(button, /class="[^" ]+ product-tab"/);
assert.match(button, /data-openbitfun-product-component="product"/);
assert.match(button, /data-openbitfun-product-part="tab"/);
assert.match(button, /aria-describedby="help"/);
assert.match(button, /style="opacity:0.8"/);
assert.match(button, /title="Full title"/);
assert.match(button, /id="product-tab"/);
assert.match(button, /role="tab"/);
assert.match(button, /type="button"/);
assert.match(button, /tabindex="0"/);
assert.match(button, /aria-selected="true"/);
assert.match(button, /aria-controls="welcome-panel"/);
assert.match(button, /data-openbitfun-part="tab"/);
});

test("controlled value and disabled items preserve selection and focus contracts", () => {
const markup = renderToStaticMarkup(
createElement(TabGroup, {
Expand Down
Loading
Loading