From 3954e51d86f47391139138382ddc43db1fc801eb Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 23 Oct 2026 16:25:46 +0800 Subject: [PATCH] fix(ui): reveal capsule actions without covering labels or shifting rows --- .../design-lab/src/preview/CapsulePreview.tsx | 2 +- design-system/packages/ui/README.md | 18 ++++-- .../ui/src/components/Capsule/Capsule.meta.ts | 3 +- .../src/components/Capsule/Capsule.module.css | 62 +++++++++++++++++-- .../ui/src/components/Capsule/Capsule.tsx | 10 ++- .../packages/ui/tests/capsule.test.mjs | 42 +++++++------ .../ai/ModelSettingsPage.loading.test.tsx | 28 ++++++--- .../settings/pages/ai/ModelSettingsPage.tsx | 30 ++++++++- .../ssh-remote/WorkspaceChoiceCapsules.tsx | 4 +- 9 files changed, 154 insertions(+), 45 deletions(-) diff --git a/design-system/apps/design-lab/src/preview/CapsulePreview.tsx b/design-system/apps/design-lab/src/preview/CapsulePreview.tsx index 18844261dd..ccf928f3f1 100644 --- a/design-system/apps/design-lab/src/preview/CapsulePreview.tsx +++ b/design-system/apps/design-lab/src/preview/CapsulePreview.tsx @@ -8,7 +8,7 @@ export function CapsulePreview({ state = "default" }: { state?: string }) { const disabled = state === "disabled"; return {(["xs", "sm"] as const).filter(size => !removed.includes(size)).map(size => ( - } disabled={disabled} onClick={() => setRemoved(current => [...current, size])} /> diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 7f6f88d935..d1fa8eeb6a 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -507,10 +507,20 @@ available. Its right inset equals the top and bottom clearance at every density. ``` -The action floats over the intact label: do not clip, mask, hide, resize, or add -action padding to its text when revealing the icon. Normal constrained-width -`OverflowText` behavior remains available. `TabGroup.endAction` and removable -`MultiSelect` tags share this contract, including existing native tab buttons. +The default `actionPlacement="overlay"` floats over the intact label: do not clip, +mask, hide, resize, or add action padding to its text when revealing the icon. +Use `actionPlacement="reserved"` with a `Button` child for short selectable labels. +The Button always reserves trailing padding for the action inside its pill surface, +keeping the gaps between capsules compact. Its content is centered at rest, then +shifts toward the leading edge as the icon fades in on pointer hover. Only the +content transform and icon opacity change: hovering a row-end capsule cannot +change its width or trigger wrapping. 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. 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. 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/Capsule/Capsule.meta.ts b/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts index 10cd8e4d7d..13799d2831 100644 --- a/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts +++ b/design-system/packages/ui/src/components/Capsule/Capsule.meta.ts @@ -2,12 +2,13 @@ import type { ComponentMeta } from "../../registry.types"; export const capsuleMeta = { category: "action", - description: "A capsule composition with a circular glass action floating over its unmodified label. Pointer hover and keyboard focus reveal the action; touch keeps it available.", + description: "A capsule composition with a circular glass action that overlays the label or occupies reserved space inside its Button surface. Reserved content is centered at rest and shifts aside when the action appears, without changing layout; touch keeps the action available.", maturity: "stable", name: "Capsule", props: [ { name: "children", type: "ReactNode" }, { name: "action", type: "ReactNode" }, + { name: "actionPlacement", type: "overlay | reserved", defaultValue: "overlay" }, { name: "size", type: "xs | sm | md | lg", defaultValue: "sm" }, ], states: ["default", "hover", "focus-visible", "disabled"], diff --git a/design-system/packages/ui/src/components/Capsule/Capsule.module.css b/design-system/packages/ui/src/components/Capsule/Capsule.module.css index 496a721189..de14824c85 100644 --- a/design-system/packages/ui/src/components/Capsule/Capsule.module.css +++ b/design-system/packages/ui/src/components/Capsule/Capsule.module.css @@ -1,6 +1,9 @@ @layer openbitfun.components { .root { - --_floating-action-host-height: var(--openbitfun-control-height-sm); + --_capsule-height: var(--openbitfun-control-height-sm); + --_capsule-padding-inline: var(--openbitfun-space-4); + --_capsule-inline-direction: 1; + --_floating-action-host-height: var(--_capsule-height); display: inline-flex; flex: 0 1 auto; align-items: center; @@ -8,7 +11,58 @@ max-inline-size: 100%; } - .root[data-size="xs"] { --_floating-action-host-height: var(--openbitfun-control-button-xs-height); } - .root[data-size="md"] { --_floating-action-host-height: var(--openbitfun-control-height-md); } - .root[data-size="lg"] { --_floating-action-host-height: var(--openbitfun-control-height-lg); } + .root[data-size="xs"] { + --_capsule-height: var(--openbitfun-control-button-xs-height); + --_capsule-padding-inline: var(--openbitfun-control-button-xs-padding-inline); + } + + .root[data-size="md"] { + --_capsule-height: var(--openbitfun-control-height-md); + --_capsule-padding-inline: var(--openbitfun-space-5); + } + + .root[data-size="lg"] { + --_capsule-height: var(--openbitfun-control-height-lg); + --_capsule-padding-inline: var(--openbitfun-space-6); + } + + .root:dir(rtl) { --_capsule-inline-direction: -1; } + + /* Reserve space inside the Button's surface, leaving inter-capsule gaps intact. */ + .root[data-action-placement="reserved"] { + --_capsule-action-padding: calc( + (var(--_capsule-height) + var(--openbitfun-control-icon-button-xs-size)) / 2 + + var(--openbitfun-space-1) + ); + } + + .root[data-action-placement="reserved"] > [data-openbitfun-component="button"] { + padding-inline: var(--_capsule-padding-inline) var(--_capsule-action-padding); + } + + @media (hover: hover) and (pointer: fine) { + /* Only the content moves; the pill and its action keep fixed hit areas. */ + .root[data-action-placement="reserved"] > [data-openbitfun-component="button"] > [data-openbitfun-part="content"] { + transform: translateX(calc( + (var(--_capsule-action-padding) - var(--_capsule-padding-inline)) / 2 + * var(--_capsule-inline-direction) + )); + transition: transform var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + } + + .root[data-action-placement="reserved"]:is(:hover, :focus-visible, :has(:focus-visible), [data-openbitfun-preview-state="hover"], [data-openbitfun-preview-state="focus-visible"]) > [data-openbitfun-component="button"] > [data-openbitfun-part="content"] { + transform: none; + } + } + + .root[data-action-placement="reserved"]:is(:focus-visible, :has(:focus-visible), [data-openbitfun-preview-state="focus-visible"]) > [data-openbitfun-component="button"] > [data-openbitfun-part="content"], + .root:is(:focus-visible, :has(:focus-visible), [data-openbitfun-preview-state="focus-visible"]) > .reservedAction > button { + transition: none; + } + + @media (prefers-reduced-motion: reduce) { + .root[data-action-placement="reserved"] > [data-openbitfun-component="button"] > [data-openbitfun-part="content"] { + transition: none; + } + } } diff --git a/design-system/packages/ui/src/components/Capsule/Capsule.tsx b/design-system/packages/ui/src/components/Capsule/Capsule.tsx index fe957e7a63..327142ee2a 100644 --- a/design-system/packages/ui/src/components/Capsule/Capsule.tsx +++ b/design-system/packages/ui/src/components/Capsule/Capsule.tsx @@ -7,13 +7,16 @@ import styles from "./Capsule.module.css"; export interface CapsuleProps extends HTMLAttributes { /** One IconButton (optionally wrapped in Tooltip), rendered beside the content. */ action?: ReactNode; + /** Reserve a fixed action slot inside a Button child's trailing padding. */ + actionPlacement?: "overlay" | "reserved"; /** Match the size of the child Button, Input, or label surface. */ size?: "xs" | "sm" | "md" | "lg"; } -/** Keeps label layout intact beneath a circular glass action with equal edge gaps. */ +/** Composes content with an independent circular glass action. */ export const Capsule = forwardRef(function Capsule({ action, + actionPlacement = "overlay", children, className, size = "sm", @@ -21,9 +24,10 @@ export const Capsule = forwardRef(function Capsul }, ref) { return ( + data-openbitfun-component="capsule" data-size={size} data-action-placement={action != null ? actionPlacement : undefined} data-overflow-trigger ref={ref}> {children} - {action != null && {action}} + {action != null && {action}} ); }); diff --git a/design-system/packages/ui/tests/capsule.test.mjs b/design-system/packages/ui/tests/capsule.test.mjs index 7de04336c7..885c6f2ac0 100644 --- a/design-system/packages/ui/tests/capsule.test.mjs +++ b/design-system/packages/ui/tests/capsule.test.mjs @@ -8,28 +8,32 @@ const action = (props = {}) => h(IconButton, { "aria-label": "Remove provider", icon: h(Icon, { name: "delete" }), ...props, }); -test("a tooltip-wrapped capsule action keeps its accessible name and destructive state with shared glass presentation", () => { - const markup = renderToStaticMarkup(h(Capsule, { - size: "sm", - action: h(Tooltip, { content: "Remove provider" }, action({ tone: "danger", variant: "fill", loading: true })), - }, h(Button, { size: "sm", "aria-label": "Edit provider" }, "Long provider name"))); - assert.match(markup, /data-openbitfun-component="capsule"/); - assert.match(markup, /data-openbitfun-part="action"/); - assert.match(markup, /data-openbitfun-variant="glass"/); - assert.match(markup, /data-openbitfun-shape="circle"/); - assert.match(markup, /data-openbitfun-tone="danger"/); - assert.match(markup, /aria-label="Remove provider"/); - assert.match(markup, /aria-busy="true"/); - assert.match(markup, /disabled=""/); - assert.match(markup, /Long provider name/); - assert.equal((markup.match(/") < markup.lastIndexOf(" { + const markup = renderToStaticMarkup(h(Capsule, { + size: "sm", + actionPlacement, + action: h(Tooltip, { content: "Remove provider" }, action({ tone: "danger", variant: "fill", loading: true })), + }, h(Button, { size: "sm", "aria-label": "Edit provider" }, "Long provider name"))); + assert.match(markup, /data-openbitfun-component="capsule"/); + assert.ok(markup.includes(`data-action-placement="${actionPlacement}"`)); + assert.match(markup, /data-openbitfun-part="action"/); + assert.match(markup, /data-openbitfun-variant="glass"/); + assert.match(markup, /data-openbitfun-shape="circle"/); + assert.match(markup, /data-openbitfun-tone="danger"/); + assert.match(markup, /aria-label="Remove provider"/); + assert.match(markup, /aria-busy="true"/); + assert.match(markup, /disabled=""/); + assert.match(markup, /Long provider name/); + assert.equal((markup.match(/") < markup.lastIndexOf(" { - const markup = renderToStaticMarkup(h(Capsule, { size: "xs" }, "A tag")); + const markup = renderToStaticMarkup(h(Capsule, { size: "xs", actionPlacement: "reserved" }, "A tag")); assert.match(markup, />A tag<\/span>/); - assert.doesNotMatch(markup, /tabindex|role=|data-openbitfun-part="action"/); + assert.doesNotMatch(markup, /tabindex|role=|data-openbitfun-part="action"|data-action-placement/); }); test("tab actions and removable MultiSelect labels adopt the same glass presentation", () => { diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx index 48b864524b..c0b9ffc4ef 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.loading.test.tsx @@ -314,9 +314,12 @@ describe('ModelSettingsPage loading and provider actions', () => { expect(button('actions.addModel').disabled).toBe(false); }); - it.each(['api', 'subscription'] as const)( - 'confirms %s provider deletion from its editor before removing only that provider’s models', - async kind => { + it.each([ + ['api', 'capsule'], ['api', 'editor'], + ['subscription', 'capsule'], ['subscription', 'editor'], + ] as const)( + 'confirms %s provider deletion from its %s before removing only that provider’s models', + async (kind, entryPoint) => { const providerModels: AIModelConfig[] = [model, { ...model, id: 'second-model', model_name: 'second-model' }] .map(entry => ({ ...entry, @@ -340,13 +343,20 @@ describe('ModelSettingsPage loading and provider actions', () => { reads.get('ai.agent_model_selections')!.resolve({}); }); - expect(container.querySelector('.openbitfun-model-settings__source-pill-delete')).toBeNull(); const providerButton = [...container.querySelectorAll('.openbitfun-model-settings__source-pill')] .find(element => element.textContent === model.name)!; - await act(async () => providerButton.click()); - const editor = document.querySelector('.openbitfun-model-settings__editor-dialog')!; - const deleteButton = [...editor.querySelectorAll('button')] - .find(element => element.textContent === copy('actions.deleteProvider'))!; + const capsule = providerButton.closest('[data-openbitfun-component="capsule"]')!; + expect(capsule.getAttribute('data-action-placement')).toBe('reserved'); + expect(capsule.querySelectorAll('button')).toHaveLength(2); + expect(providerButton.querySelector('button')).toBeNull(); + let deleteButton = capsule.querySelector('.openbitfun-model-settings__source-pill-delete')!; + expect(deleteButton.getAttribute('aria-label')).toBe(`${copy('actions.deleteProvider')}: ${model.name}`); + if (entryPoint === 'editor') { + await act(async () => providerButton.click()); + const editor = document.querySelector('.openbitfun-model-settings__editor-dialog')!; + deleteButton = [...editor.querySelectorAll('button')] + .find(element => element.textContent === copy('actions.deleteProvider'))!; + } await act(async () => deleteButton.click()); const dialog = document.querySelector('[role="alertdialog"]')!; @@ -354,7 +364,7 @@ describe('ModelSettingsPage loading and provider actions', () => { expect(dialog.textContent).toContain(String(i18nService.getI18nInstance().t('providerDeleteConfirm.message', { ns: 'settings/models', name: model.name, modelCount: 2, referenceCount: 1, }))); - expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(1); + expect(document.querySelectorAll('[role="dialog"]')).toHaveLength(entryPoint === 'editor' ? 1 : 0); expect(updateConfig).not.toHaveBeenCalled(); const cancel = dialog.querySelector('button[data-openbitfun-variant="fill"]')!; diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx index 0f3161a741..7ec4bbc7eb 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx @@ -4597,7 +4597,20 @@ const ModelSettingsPage: React.FC = () => {
    {apiProviderGroups.map(group => (
  • - + + void requestProviderDelete(group)} + icon={} + /> + + )}>