diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index c344f5a7a6..523edf05b5 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -1850,6 +1850,9 @@ export function ComponentDetailPage({ {t("components.preview.navigationPanelMoreItem")} + + {t("components.preview.cardDescription")} + diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index d269968099..9771701307 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -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 @@ -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 diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts index 538038af0a..2a11e65046 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.meta.ts @@ -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" }, diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css index 4377888c89..c8a0ae4aeb 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.module.css @@ -103,6 +103,11 @@ white-space: nowrap; } + .staticLabel { + overflow: visible; + white-space: inherit; + } + .metadata, .shortcut, .actions { diff --git a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx index c7593438b2..15d1b4ae75 100644 --- a/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx +++ b/design-system/packages/ui/src/components/ActionItem/ActionItem.tsx @@ -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; @@ -42,6 +44,7 @@ export const ActionItem = forwardRef(functio className, disabled, leading, + labelBehavior = "overflow", metadata, reserveLeadingSpace = false, shortcut, @@ -71,7 +74,11 @@ export const ActionItem = forwardRef(functio {leading} )} - {children} + {labelBehavior === "static" ? ( + {children} + ) : ( + {children} + )} {metadata !== undefined && metadata !== null && ( {metadata} 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 1448d8d41e..f55e8e50e4 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.meta.ts @@ -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" }, diff --git a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx index 67b18ae710..840eaebb90 100644 --- a/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx +++ b/design-system/packages/ui/src/components/TabGroup/TabGroup.tsx @@ -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, "className" | "style" | "title" | "aria-label" | "aria-describedby"> + & Record<`data-${string}`, string | number | boolean | undefined>; value: string; } @@ -144,10 +147,11 @@ export const TabGroup = forwardRef(function TabGr key={item.value} > - ))} - + value={activeTab} + onValueChange={value => setActiveTab(value as SessionUsagePanelTab)} + items={TABS.map(tab => ({ + value: tab, + id: tabId(tab), + panelId: tabPanelId(tab), + label: t(`usage.tabs.${tab}`), + tabProps: { + className: `session-usage-panel__tab${activeTab === tab ? ' session-usage-panel__tab--active' : ''}`, + 'data-openbitfun-product-component': 'session-usage-panel', + 'data-openbitfun-product-part': 'tab', + 'data-openbitfun-tab': tab, + 'data-openbitfun-state': activeTab === tab ? 'active' : undefined, + }, + }))} + />
void; }) { return ( -
+
{label} {value} {onCopy && copyLabel && ( @@ -621,7 +582,7 @@ function UsageOverview({ report }: { report: SessionUsageReport }) { ]; return ( -
+
{report.coverage.level !== 'complete' && (
@@ -633,7 +594,7 @@ function UsageOverview({ report }: { report: SessionUsageReport }) { {metrics.map(metric => { const Icon = metric.icon; return ( -
+
{metric.label} @@ -952,7 +913,7 @@ function UsageFiles({ }), [handleJumpToFileTurn, handleOpenFileDiff, openingDiffKey, redactPaths, report.files.files, report.files.scope, sessionId, t, workspacePath]); return ( -
+
{t('usage.panel.fileScope')} +
{t('usage.panel.errorScope')} +
{t('usage.sections.slowest')} +
{emptyDescription && {emptyDescription}}
@@ -1273,7 +1234,7 @@ function UsageTable({ empty, emptyLabel, emptyDescription, emptyHelp, headers, r return ( <> -
+
diff --git a/src/web-ui/src/flow_chat/components/usage/appearance.ts b/src/web-ui/src/flow_chat/components/usage/appearance.ts index afb177faa0..e6af481546 100644 --- a/src/web-ui/src/flow_chat/components/usage/appearance.ts +++ b/src/web-ui/src/flow_chat/components/usage/appearance.ts @@ -2,6 +2,7 @@ import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; export const sessionUsagePanelAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'session-usage-panel', + componentAttribute: 'data-openbitfun-product-component', parts: [ { id: 'root' }, { id: 'header' }, diff --git a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts index 398a14fa13..c5a2685ddd 100644 --- a/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts +++ b/src/web-ui/src/infrastructure/appearance/compiler/AppearanceCompiler.test.ts @@ -1011,7 +1011,7 @@ describe('AppearanceCompiler', () => { expect(snapshot.cssText).toContain('[data-openbitfun-component="remote-connect-dialog"][data-openbitfun-part="sidebar"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="remote-connect-dialog"][data-openbitfun-part="overviewAction"][data-openbitfun-group="account"][data-openbitfun-state~="authenticated"]'); expect(snapshot.cssText).toContain('[data-openbitfun-scene="agents"][data-openbitfun-part="catalogGrid"]'); - expect(snapshot.cssText).toContain('[data-openbitfun-component="session-usage-panel"][data-openbitfun-part="tab"][data-openbitfun-tab="models"][data-openbitfun-state~="active"]'); + expect(snapshot.cssText).toContain('[data-openbitfun-product-component="session-usage-panel"][data-openbitfun-product-part="tab"][data-openbitfun-tab="models"][data-openbitfun-state~="active"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="acp-agents-config"][data-openbitfun-part="root"][data-openbitfun-view="json"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="acp-agents-config"][data-openbitfun-part="remoteServer"]'); expect(snapshot.cssText).toContain('[data-openbitfun-component="tiptap-editor"][data-openbitfun-part="inlineAiPanel"]'); diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx index 716cb0e8c0..023b8d4d1f 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.test.tsx @@ -31,7 +31,7 @@ const mocks = vi.hoisted(() => ({ me: { user: { githubId: 1, login: 'reviewer', avatarUrl: '' }, isAdmin: true, - }, + } as { user: { githubId: number; login: string; avatarUrl: string }; isAdmin: boolean } | null, }, })); @@ -39,7 +39,9 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); -vi.mock('@openbitfun/ui', () => ({ +vi.mock('@openbitfun/ui', async (importOriginal) => ({ + NavigationPanelItem: (await importOriginal()).NavigationPanelItem, + DialogHeaderActions: (await importOriginal()).DialogHeaderActions, ScrollArea: ({ children, ...props }: React.HTMLAttributes) =>
{children}
, Icon: ({ name, ...props }: { name: string } & React.HTMLAttributes) => , OverflowText: ({ children, behavior: _behavior, marqueeActive: _marqueeActive, ...props }: any) => {children}, @@ -102,7 +104,7 @@ vi.mock('@/infrastructure/confirm-dialog', () => ({ })); vi.mock('@/features/market-account', () => ({ - AccountIdentityControls: () =>
, + AccountIdentityControls: () => , })); vi.mock('@/infrastructure/account-identity', () => ({ @@ -189,6 +191,10 @@ describe('AppearanceMarketDialog', () => { let root: ReturnType; beforeEach(() => { + mocks.accountState.me = { + user: { githubId: 1, login: 'reviewer', avatarUrl: '' }, isAdmin: true, + }; + mocks.accountState.status = 'signed-in'; mocks.getCachedPage.mockReset(); mocks.browse.mockReset().mockResolvedValue({ items: [summary] }); mocks.getListing.mockReset().mockResolvedValue({ @@ -255,6 +261,11 @@ describe('AppearanceMarketDialog', () => { }); await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night')); expect(container.querySelector('[data-testid="shared-market-account-controls"]')).not.toBeNull(); + expect(container.querySelector('h2')?.textContent).toBe('package.market.title'); + const accountControls = container.querySelector('[data-testid="shared-market-account-controls"]')!; + const headerActions = accountControls.closest('[data-openbitfun-part="header-actions"]'); + expect(headerActions).not.toBeNull(); + expect(headerActions?.querySelector('button[aria-label="Close"]')).not.toBeNull(); expect(container.textContent).toContain('package.market.updateAvailable'); const listingButton = [...container.querySelectorAll('button')] @@ -288,6 +299,11 @@ describe('AppearanceMarketDialog', () => { }); expect(mocks.activate).not.toHaveBeenCalled(); expect(container.textContent).toContain('package.market.noAutoApply'); + const browse = container.querySelector('.appearance-market__nav [aria-current="page"]')!; + expect(browse.textContent).toBe('package.market.views.browse'); + await act(async () => browse.querySelector('[data-openbitfun-part="label"]')!.click()); + expect(container.querySelector('.appearance-market__detail')).toBeNull(); + expect(container.querySelector('.appearance-market__browse')).not.toBeNull(); }); it('holds the grid with placeholder cards while the first page loads', async () => { @@ -400,19 +416,62 @@ describe('AppearanceMarketDialog', () => { const submissionsTab = [...container.querySelectorAll('button')] .find(button => button.textContent === 'package.market.views.submissions'); - await act(async () => submissionsTab?.click()); + await act(async () => submissionsTab?.querySelector('[data-openbitfun-part="label"]')?.click()); await vi.waitFor(() => expect(container.textContent).toContain('Tokyo Night candidate')); expect(mocks.listSubmissions).toHaveBeenCalledOnce(); + expect(submissionsTab?.getAttribute('aria-current')).toBe('page'); + await act(async () => submissionsTab?.click()); + expect(mocks.listSubmissions).toHaveBeenCalledOnce(); const reviewTab = [...container.querySelectorAll('button')] .find(button => button.textContent === 'package.market.views.review'); await act(async () => reviewTab?.click()); await vi.waitFor(() => expect(mocks.getReviewSubmission).toHaveBeenCalledWith('submission-1')); expect(mocks.listReviewSubmissions).toHaveBeenCalledOnce(); + expect(reviewTab?.getAttribute('aria-current')).toBe('page'); expect(container.textContent).toContain('package.market.review.approve'); expect(container.textContent).toContain('package.market.review.reject'); }); + it.each([ + { signedIn: false, admin: false, count: 1 }, + { signedIn: true, admin: false, count: 2 }, + { signedIn: true, admin: true, count: 3 }, + ])('keeps navigation visibility for account state $signedIn / admin $admin', async ({ signedIn, admin, count }) => { + mocks.accountState.me = signedIn ? { + user: { githubId: 1, login: 'member', avatarUrl: '' }, isAdmin: admin, + } : null; + mocks.accountState.status = signedIn ? 'signed-in' : 'signed-out'; + const onClose = vi.fn(); + await act(async () => root.render()); + const nav = container.querySelector('nav')!; + const buttons = nav.querySelectorAll('button'); + expect(buttons).toHaveLength(count); + expect(nav.querySelectorAll('[data-openbitfun-component="action-item"]')).toHaveLength(count); + expect(nav.querySelectorAll('[aria-current="page"]')).toHaveLength(1); + expect(nav.querySelector('[role="tab"]')).toBeNull(); + expect(nav.querySelector('[data-overflow-behavior]')).toBeNull(); + await act(async () => buttons[0].click()); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('returns to browse when account permissions remove the active navigation entry', async () => { + const onClose = vi.fn(); + await act(async () => root.render()); + const review = [...container.querySelectorAll('nav button')] + .find(button => button.textContent === 'package.market.views.review')!; + await act(async () => review.click()); + expect(review.getAttribute('aria-current')).toBe('page'); + mocks.accountState.me = { ...mocks.accountState.me!, isAdmin: false }; + await act(async () => root.render()); + expect(container.querySelectorAll('nav button')).toHaveLength(2); + expect(container.querySelector('nav [aria-current="page"]')?.textContent).toBe('package.market.views.browse'); + mocks.accountState.me = null; + await act(async () => root.render()); + expect(container.querySelectorAll('nav button')).toHaveLength(1); + expect(onClose).not.toHaveBeenCalled(); + }); + it('shows a moderated Skin as unpublished instead of approved', async () => { mocks.listSubmissions.mockResolvedValue([{ submissionId: 'submission-unpublished', diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx index 3f03fbcb64..204f078789 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx +++ b/src/web-ui/src/infrastructure/config/components/AppearanceMarketDialog.tsx @@ -1,5 +1,6 @@ import { OverflowText, Button, + NavigationPanelItem, Icon, ScrollArea, SearchField, @@ -8,6 +9,7 @@ import { OverflowText, DialogBody, DialogClose, DialogHeader, + DialogHeaderActions, DialogHeading, DialogTitle, } from '@openbitfun/ui'; @@ -456,11 +458,14 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial size="xl" data-testid="appearance-market-dialog" > - + - {t('package.market.title')}{} + {t('package.market.title')} - + + + +
@@ -475,33 +480,42 @@ export function AppearanceMarketDialog({ isOpen, onClose }: AppearanceMarketDial data-openbitfun-part="marketNav" aria-label={t('package.market.views.label')} > - + {account.me && ( - + )} {account.me?.isAdmin && ( - + )} {view !== 'browse' ? : detail ? renderDetail() : ( diff --git a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss index 9ba5fcf1b1..c35d4b4593 100644 --- a/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss +++ b/src/web-ui/src/infrastructure/config/components/AppearanceSettingsPage.scss @@ -338,6 +338,10 @@ } +.appearance-market__dialog-header { + align-items: center; +} + /* Fixed height: every view scrolls internally so the dialog never resizes while loading. */ .appearance-market__modal { min-height: min(720px, 78vh); @@ -359,7 +363,20 @@ padding-bottom: var(--openbitfun-space-2); border-bottom: 1px solid var(--openbitfun-color-border-default); - button { + .appearance-market__nav-item { + inline-size: auto; + min-inline-size: min-content; + min-block-size: 0; + align-items: stretch; + padding: 0; + gap: 0; + border-radius: var(--openbitfun-radius-sm); + background: transparent; + box-shadow: none; + transition: none; + } + + .appearance-market__nav-item > [data-openbitfun-part='trigger'] { padding: var(--openbitfun-space-2) var(--openbitfun-space-3); border: 0; border-radius: var(--openbitfun-radius-sm); @@ -367,8 +384,19 @@ color: var(--openbitfun-color-content-secondary); font: inherit; font-size: var(--openbitfun-type-label-md-font-size); + text-align: center; + white-space: normal; cursor: pointer; + @layer openbitfun.components { + outline: revert-layer; + } + + > [data-openbitfun-part='label'] { + font: inherit; + letter-spacing: inherit; + } + &:hover { background: var(--openbitfun-color-surface-subtle); color: var(--openbitfun-color-content-primary); diff --git a/src/web-ui/src/shared/ui/OverflowText.test.tsx b/src/web-ui/src/shared/ui/OverflowText.test.tsx index 6388037812..1e6d634492 100644 --- a/src/web-ui/src/shared/ui/OverflowText.test.tsx +++ b/src/web-ui/src/shared/ui/OverflowText.test.tsx @@ -75,6 +75,28 @@ describe('overflow text full-content access', () => { expect(button.getAttribute('aria-describedby')).toBe('help'); }); + it('keeps interaction-only ellipsis idle on virtual selection and reveals full text on focus', () => { + render(); + const label = host.querySelector('[data-overflow]')!; + expect(label.getAttribute('data-overflow')).toBe('true'); + expect(label.getAttribute('data-marquee-active')).toBeNull(); + reveal(); + expect(tooltip()).toBeNull(); + act(() => host.querySelector('button')!.focus()); + reveal(); + expect(tooltip()?.textContent).toBe(longLabel); + act(() => { + availableWidth = 1000; + resizeCallbacks.forEach(callback => callback()); + }); + expect(label.getAttribute('data-overflow')).toBe('false'); + expect(tooltip()).toBeNull(); + }); + it('keeps the command tooltip open when portal enter precedes native trigger leave', () => { render(); expect(host.querySelector('[title]')).toBeNull();