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 @@ -8,7 +8,7 @@ export function CapsulePreview({ state = "default" }: { state?: string }) {
const disabled = state === "disabled";
return <Stack direction="horizontal" align="center" gap="3">
{(["xs", "sm"] as const).filter(size => !removed.includes(size)).map(size => (
<Capsule key={size} size={size} data-openbitfun-preview-state={state} action={(
<Capsule key={size} size={size} actionPlacement={size === "xs" ? "reserved" : "overlay"} data-openbitfun-preview-state={state} action={(
<Tooltip content={t("components.preview.close")} trigger="hover-focus">
<IconButton aria-label={t("components.preview.close")} icon={<Icon name="xmark" />}
disabled={disabled} onClick={() => setRemoved(current => [...current, size])} />
Expand Down
18 changes: 14 additions & 4 deletions design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -507,10 +507,20 @@ available. Its right inset equals the top and bottom clearance at every density.
</Capsule>
```

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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,68 @@
@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;
min-inline-size: 0;
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;
}
}
}
10 changes: 7 additions & 3 deletions design-system/packages/ui/src/components/Capsule/Capsule.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,27 @@ import styles from "./Capsule.module.css";
export interface CapsuleProps extends HTMLAttributes<HTMLSpanElement> {
/** 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<HTMLSpanElement, CapsuleProps>(function Capsule({
action,
actionPlacement = "overlay",
children,
className,
size = "sm",
...props
}, ref) {
return (
<span {...props} className={classNames(actionStyles.host, styles.root, className)}
data-openbitfun-component="capsule" data-size={size} data-overflow-trigger ref={ref}>
data-openbitfun-component="capsule" data-size={size} data-action-placement={action != null ? actionPlacement : undefined} data-overflow-trigger ref={ref}>
{children}
{action != null && <FloatingAction data-openbitfun-part="action">{action}</FloatingAction>}
{action != null && <FloatingAction data-openbitfun-part="action"
className={actionPlacement === "reserved" ? styles.reservedAction : undefined}>{action}</FloatingAction>}
</span>
);
});
42 changes: 23 additions & 19 deletions design-system/packages/ui/tests/capsule.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<button\b/g) ?? []).length, 2);
assert.ok(markup.indexOf("</button>") < markup.lastIndexOf("<button"), "Primary and secondary actions must be siblings");
});
for (const actionPlacement of ["overlay", "reserved"]) {
test(`a tooltip-wrapped ${actionPlacement} capsule action keeps its accessible name and destructive state with shared glass presentation`, () => {
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(/<button\b/g) ?? []).length, 2);
assert.ok(markup.indexOf("</button>") < markup.lastIndexOf("<button"), "Primary and secondary actions must be siblings");
});
}

test("capsules without actions preserve ordinary content and do not add a focus target", () => {
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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -340,21 +343,28 @@ 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<HTMLButtonElement>('.openbitfun-model-settings__source-pill')]
.find(element => element.textContent === model.name)!;
await act(async () => providerButton.click());
const editor = document.querySelector<HTMLElement>('.openbitfun-model-settings__editor-dialog')!;
const deleteButton = [...editor.querySelectorAll<HTMLButtonElement>('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<HTMLButtonElement>('.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<HTMLElement>('.openbitfun-model-settings__editor-dialog')!;
deleteButton = [...editor.querySelectorAll<HTMLButtonElement>('button')]
.find(element => element.textContent === copy('actions.deleteProvider'))!;
}
await act(async () => deleteButton.click());

const dialog = document.querySelector<HTMLElement>('[role="alertdialog"]')!;
expect(dialog.textContent).toContain(copy('providerDeleteConfirm.title'));
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<HTMLButtonElement>('button[data-openbitfun-variant="fill"]')!;
Expand Down
30 changes: 28 additions & 2 deletions src/web-ui/src/app/scenes/settings/pages/ai/ModelSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4597,7 +4597,20 @@ const ModelSettingsPage: React.FC = () => {
<ul className="openbitfun-model-settings__import-summary" aria-label={t('acquisition.api')}>
{apiProviderGroups.map(group => (
<li key={group.key} className="openbitfun-model-settings__import-item">
<Capsule size="sm">
<Capsule size="sm" actionPlacement="reserved" action={(
<Tooltip content={t('actions.deleteProvider')} trigger="hover-focus">
<IconButton
className="openbitfun-model-settings__source-pill-delete"
aria-label={`${t('actions.deleteProvider')}: ${group.providerName}`}
size="xs"
shape="circle"
variant="quiet"
tone="danger"
onClick={() => void requestProviderDelete(group)}
icon={<Icon name="delete" size="xs" />}
/>
</Tooltip>
)}>
<Tooltip content={group.models[0].base_url} trigger="hover-focus">
<Button size="sm" variant="outline" className="openbitfun-model-settings__source-pill"
aria-label={t('acquisition.configureProvider', { provider: group.providerName })}
Expand Down Expand Up @@ -4635,7 +4648,20 @@ const ModelSettingsPage: React.FC = () => {
<ul className="openbitfun-model-settings__import-summary" aria-label={t('acquisition.subscriptions')}>
{subscriptionProviderGroups.map(group => (
<li key={group.key} className="openbitfun-model-settings__import-item">
<Capsule size="sm">
<Capsule size="sm" actionPlacement="reserved" action={(
<Tooltip content={t('actions.deleteProvider')} trigger="hover-focus">
<IconButton
className="openbitfun-model-settings__source-pill-delete"
aria-label={`${t('actions.deleteProvider')}: ${group.providerName}`}
size="xs"
shape="circle"
variant="quiet"
tone="danger"
onClick={() => void requestProviderDelete(group)}
icon={<Icon name="delete" size="xs" />}
/>
</Tooltip>
)}>
<Button size="sm" variant="outline" className="openbitfun-model-settings__source-pill"
aria-label={t('acquisition.configureProvider', { provider: group.providerName })}
onClick={() => handleEditProvider(group.models[0])}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface WorkspaceChoice {
action?: ReactNode;
}

/** Uses the same public composition as the model provider's selected models. */
/** Keeps connection selection and editing in separate hit areas. */
export function WorkspaceChoiceCapsules({ choices, value, disabled, label, onValueChange, onEdit }: {
choices: readonly WorkspaceChoice[];
value?: string;
Expand All @@ -22,7 +22,7 @@ export function WorkspaceChoiceCapsules({ choices, value, disabled, label, onVal
const { t } = useI18n('common');
return <div className="workspace-choice-capsules" role="group" aria-label={label}
data-openbitfun-component="ssh-remote" data-openbitfun-part="sourceOptions">
{choices.map(choice => <Capsule size="xs" key={choice.id} data-workspace-choice={choice.id}
{choices.map(choice => <Capsule size="xs" actionPlacement="reserved" key={choice.id} data-workspace-choice={choice.id}
action={onEdit ? <Tooltip content={t('actions.edit')}><IconButton size="xs" shape="circle" variant="quiet"
disabled={disabled} icon={<Icon name="edit" size="xs" />} aria-label={`${t('actions.edit')} · ${choice.label}`}
onClick={() => onEdit(choice.id)} /></Tooltip> : choice.action}>
Expand Down
Loading