From 6968e00d6dbb815d6d40b2e505390acad8608bdc Mon Sep 17 00:00:00 2001 From: GCWing Date: Sat, 5 Sep 2026 16:53:58 +0800 Subject: [PATCH] feat(ui): unify picker controls and surface iconography --- .../src/pages/ComponentDetailPage.tsx | 20 +- .../vite/component-detail-contract.test.mjs | 12 +- .../theme-openbitfun/src/dark.tokens.json | 2 +- .../theme-openbitfun/src/light.tokens.json | 2 +- .../tests/theme-contract.test.mjs | 10 +- design-system/packages/ui/README.md | 38 +- .../src/components/Combobox/Combobox.meta.ts | 8 +- .../components/Combobox/Combobox.module.css | 113 ++++- .../ui/src/components/Combobox/Combobox.tsx | 194 ++++---- .../ui/src/components/Icon/Icon.meta.ts | 3 +- .../ui/src/components/Icon/Icon.module.css | 10 + .../packages/ui/src/components/Icon/Icon.tsx | 38 +- .../packages/ui/src/components/Icon/index.ts | 1 + .../ui/src/components/Input/Input.meta.ts | 1 - .../ui/src/components/Input/Input.module.css | 12 +- .../MultiSelect/MultiSelect.meta.ts | 8 +- .../NumberInput/NumberInput.meta.ts | 2 +- .../NumberInput/NumberInput.module.css | 8 +- .../SearchField/SearchField.meta.ts | 2 +- .../SearchField/SearchField.module.css | 15 +- .../components/SearchField/SearchField.tsx | 5 +- .../ui/src/components/Select/Select.meta.ts | 27 +- .../src/components/Select/Select.module.css | 260 +++++++++-- .../ui/src/components/Select/Select.tsx | 416 ++++++++++++++++-- .../ui/src/components/Select/index.ts | 1 + .../src/components/Textarea/Textarea.meta.ts | 2 +- .../components/Textarea/Textarea.module.css | 8 +- design-system/packages/ui/src/index.ts | 1 + .../ui/src/internal/useAnchoredLayer.ts | 42 +- .../packages/ui/tests/combobox.test.mjs | 37 +- .../packages/ui/tests/icon-slots.test.mjs | 14 + design-system/packages/ui/tests/icon.test.mjs | 21 + .../packages/ui/tests/search-field.test.mjs | 36 +- .../packages/ui/tests/select.test.mjs | 48 +- .../desktop/src/generated/bootstrap_theme.css | 2 +- .../generated/default_appearance_style.html | 4 +- .../components/GalleryLayout/GalleryEmpty.tsx | 9 +- .../GalleryLayout/GalleryLayout.scss | 16 + .../components/NavBar/NavBar.appearance.ts | 2 +- .../src/app/components/NavBar/NavBar.scss | 16 + .../src/app/components/NavBar/NavBar.tsx | 22 +- .../src/app/components/NavPanel/MainNav.tsx | 20 +- .../components/DeviceStatusControl.tsx | 16 +- .../NavPanel/components/NavItem.tsx | 10 +- .../components/WorkspaceSessionFilterMenu.tsx | 2 +- .../WorkspaceSessionGroupingToggle.tsx | 7 +- .../navigationIconIntegration.test.ts | 24 +- .../sections/sessions/SessionsSection.tsx | 2 +- .../workspaces/WorkspaceAcpSessionSubmenu.tsx | 4 +- .../sections/workspaces/WorkspaceItem.tsx | 31 +- .../workspaces/WorkspaceListSection.scss | 21 - .../WorkspaceListSectionLayout.test.ts | 13 +- .../WorkspaceProjectPermissionsDialog.scss | 2 +- .../WorkspaceProjectPermissionsDialog.tsx | 4 +- .../WorkspaceRelatedPathsDialog.tsx | 2 +- .../workspaces/WorkspaceSessionBatchModal.tsx | 6 +- .../src/app/components/panels/base/types.ts | 4 +- .../src/app/components/panels/base/utils.ts | 80 ++-- .../app/global-search/GlobalSearchRoot.tsx | 84 ++-- .../globalSearchArchitecture.test.ts | 2 +- .../src/app/scenes/agents/AgentsScene.tsx | 41 +- .../src/app/scenes/agents/agentsIcons.ts | 51 +-- .../scenes/agents/components/AgentCard.tsx | 12 +- .../agents/components/CoreAgentCard.tsx | 12 +- .../agents/components/CreateAgentPage.tsx | 27 +- .../EcosystemCompatibilityScene.scss | 8 +- .../EcosystemCompatibilityScene.tsx | 45 +- .../file-viewer/FileViewerNav.appearance.ts | 2 +- .../app/scenes/file-viewer/FileViewerNav.scss | 59 +-- .../app/scenes/file-viewer/FileViewerNav.tsx | 112 ++--- .../scenes/miniapps/utils/miniAppIcons.tsx | 32 +- .../miniapps/views/MiniAppGalleryView.tsx | 4 +- .../miniapps/views/MiniAppLibraryView.tsx | 2 +- .../miniapps/views/MiniAppMarketView.tsx | 4 +- .../miniapps/views/MiniAppSubmissionsView.tsx | 2 +- .../app/scenes/my-agent/InsightsScene.scss | 2 +- .../src/app/scenes/my-agent/InsightsScene.tsx | 60 +-- src/web-ui/src/app/scenes/nav-registry.ts | 37 +- .../src/app/scenes/pages/PagesScene.scss | 2 +- .../src/app/scenes/pages/PagesScene.tsx | 6 +- .../scenes/profile/views/NurseryGallery.tsx | 8 +- .../src/app/scenes/settings/SettingsNav.scss | 20 - .../app/scenes/settings/SettingsNav.test.tsx | 4 +- .../src/app/scenes/settings/SettingsNav.tsx | 52 +-- .../settings/SettingsNavTypography.test.ts | 3 +- .../src/app/scenes/skills/SkillsScene.tsx | 34 +- .../scenes/skills/components/SkillCard.tsx | 2 +- .../skills/components/SkillsSuiteView.tsx | 10 +- .../src/infrastructure/config/AGENTS.md | 27 ++ .../components/AppearanceMarketWorkflows.tsx | 1 + .../AppearancePackageConfigSection.tsx | 4 +- ...licationSettingsPages.presentation.test.ts | 17 + .../components/ApplicationSettingsPages.tsx | 16 +- .../config/components/SkillsConfig.tsx | 4 +- ...WebSearchSettingsPage.presentation.test.ts | 3 +- .../components/WebSearchSettingsPage.tsx | 51 ++- .../common/SettingsControlSizing.test.ts | 63 +++ .../IconUsageIntegration.test.tsx | 37 ++ .../locales/en-US/settings/web-search.json | 21 +- .../locales/zh-CN/settings/web-search.json | 21 +- .../locales/zh-TW/settings/web-search.json | 21 +- .../components/NotificationCenter.test.tsx | 18 + .../components/NotificationCenter.tsx | 2 +- src/web-ui/src/shared/ui/Combobox.test.tsx | 82 +++- src/web-ui/src/shared/ui/Select.test.tsx | 164 +++++++ .../editor/components/EditorBreadcrumb.tsx | 10 +- 106 files changed, 2177 insertions(+), 860 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/AGENTS.md create mode 100644 src/web-ui/src/infrastructure/config/components/common/SettingsControlSizing.test.ts create mode 100644 src/web-ui/src/shared/ui/Select.test.tsx diff --git a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx index a702196d25..a691451490 100644 --- a/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx +++ b/design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx @@ -313,6 +313,7 @@ export function ComponentDetailPage({ const [iconSize, setIconSize] = useState("lg"); const [iconTone, setIconTone] = useState("inherit"); const [selectValue, setSelectValue] = useState("ask"); + const [multiSelectValues, setMultiSelectValues] = useState>(["ask", "plan"]); const [size, setSize] = useState("md"); const [fieldOrientation, setFieldOrientation] = useState("horizontal"); const [fieldShowLabelAction, setFieldShowLabelAction] = useState(false); @@ -393,8 +394,9 @@ export function ComponentDetailPage({ return ["raised", "subtle", "media"] as const; case "Input": case "SearchField": - case "Select": return ["default", "hover", "focus-visible", "invalid", "disabled"] as const; + case "Select": + return ["default", "hover", "focus-visible", "open", "invalid", "disabled"] as const; case "Field": case "Icon": case "KeyHint": @@ -1126,7 +1128,8 @@ export function ComponentDetailPage({ if (component.name === "Combobox") { return ( value : undefined} aria-label="Mode" + size={size} value={selectValue} /> ); @@ -1147,19 +1151,22 @@ export function ComponentDetailPage({ if (component.name === "MultiSelect") { return ( value : undefined} - options={state === "loading" ? [] : [ + onValueChange={setMultiSelectValues} + options={state === "empty" || state === "loading" ? [] : [ { label: "Ask", value: "ask" }, { label: "Plan", value: "plan" }, { disabled: true, label: "Agent", value: "agent" }, ]} aria-label="Modes" - value={["ask", "plan"]} + showSelectAll + size={size} + value={multiSelectValues} /> ); } @@ -1192,6 +1199,7 @@ export function ComponentDetailPage({ invalid={state === "invalid"} leading={} onValueChange={(value) => setSelectValue(String(value))} + open={state === "open" ? true : undefined} options={[ { label: "Ask", value: "ask" }, { label: "Plan", value: "plan" }, @@ -2056,7 +2064,7 @@ export function ComponentDetailPage({ {renderPreview(previewState)} - ) : component.name === "Combobox" ? ( + ) : component.name === "Combobox" || component.name === "MultiSelect" ? (
{stateLabel(previewState)} {renderPreview(previewState)} diff --git a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs index 14be137b04..c7940938b1 100644 --- a/design-system/apps/design-lab/vite/component-detail-contract.test.mjs +++ b/design-system/apps/design-lab/vite/component-detail-contract.test.mjs @@ -312,18 +312,19 @@ test("StatusPill preview exposes compact indicator anatomy and semantic tones", assert.match(detail, /tone=\{state as StatusPillTone\}/); }); -test("Select preview exposes native grouped selection and independent states", async () => { +test("Select preview exposes the unified open surface and independent states", async () => { const [catalog, detail] = await Promise.all([ readFile(catalogSource, "utf8"), readFile(detailSource, "utf8"), ]); assert.match(catalog, /case "Select"/); - assert.match(detail, /case "Select":\s*return \["default", "hover", "focus-visible", "invalid", "disabled"\] as const/); + assert.match(detail, /case "Select":\s*return \["default", "hover", "focus-visible", "open", "invalid", "disabled"\] as const/); assert.match(detail, /onValueChange=\{\(value\) => setSelectValue\(String\(value\)\)\}/); assert.match(detail, /leading=\{\}/); assert.match(detail, /disabled=\{state === "disabled"\}/); assert.match(detail, /invalid=\{state === "invalid"\}/); + assert.match(detail, /open=\{state === "open" \? true : undefined\}/); }); test("ActionItem preview keeps its trigger and end actions as separate contracts", async () => { @@ -412,7 +413,8 @@ test("ConfirmDialog preview exposes semantic, destructive, preview, and pending test("Input, KeyHint, and SearchField previews expose composable slot and state contracts", async () => { const source = await readFile(detailSource, "utf8"); - assert.match(source, /case "Input":\s*case "SearchField":\s*case "Select":\s*return \["default", "hover", "focus-visible", "invalid", "disabled"\] as const/); + assert.match(source, /case "Input":\s*case "SearchField":\s*return \["default", "hover", "focus-visible", "invalid", "disabled"\] as const/); + assert.match(source, /case "Select":\s*return \["default", "hover", "focus-visible", "open", "invalid", "disabled"\] as const/); assert.match(source, /component\.name === "Input"/); assert.match(source, /component\.name === "KeyHint"/); assert.match(source, /component\.name === "SearchField"/); @@ -579,9 +581,11 @@ test("Toolbar preview keeps leading, centered, trailing, and overflow compositio test("Combobox details render their own live state and menus include nested interaction", async () => { const detail = await readFile(detailSource, "utf8"); assert.match(detail, /data-component="combobox"/); - assert.match(detail, /defaultOpen=\{state === "open" \|\| state === "searching"\}/); + assert.match(detail, /defaultOpen=\{state === "open" \|\| state === "searching" \|\| state === "loading" \|\| state === "empty"\}/); assert.match(detail, /onCreateValue=\{state === "custom"/); assert.match(detail, /component\.name === "MultiSelect"/); + assert.match(detail, /onValueChange=\{setMultiSelectValues\}/); + assert.match(detail, /value=\{multiSelectValues\}/); assert.match(detail, / { } }); -test("primary text and primary action pairs meet normal text contrast", async () => { +test("text, action, and field focus pairs meet their contrast requirements", async () => { const [reference, light, dark, highContrastLight, highContrastDark] = await Promise.all([ readSource("reference.tokens.json"), readSource("light.tokens.json"), @@ -183,6 +183,14 @@ test("primary text and primary action pairs meet normal text contrast", async () for (const [mode, variant] of variants) { const backdrop = parseColor(variant["color.surface.canvas"].value); + assert.ok( + contrastRatio( + variant["color.field.background"].value, + variant["color.field.borderFocus"].value, + backdrop, + ) >= 3, + `${mode} field focus contrast fell below 3:1`, + ); assert.ok( contrastRatio( variant["color.surface.canvas"].value, diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 0ef28dfccd..cb9a77bb70 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -88,6 +88,23 @@ opacity. Theme colors remain caller-owned through `currentColor`. Asset fingerprints are reviewed with intentional resource updates so replacing a glyph with a similarly named substitute cannot pass unnoticed. +Prefer a catalog `name` whenever it is an exact semantic match. When the +catalog has no matching symbol, pass the Lucide component through `glyph` so +the shared boundary applies the standard 1.6 line weight, semantic sizing, +tone and accessibility behavior: + +```tsx +import { Icon } from "@openbitfun/ui"; +import { Network } from "lucide-react"; + + +``` + +Do not set `strokeWidth` at product call sites. Let a button, menu, tab or +navigation slot own the final glyph geometry; use `size` only for standalone +icons. Raw Lucide rendering remains appropriate for intentionally filled +marks, progress indicators, illustrations, or a reviewed optical exception. + Use `canonicalIconNames` for galleries and pickers. `iconNames` also keeps the legacy `download`, `circle` and `turn` entries for compatibility; prefer `arrow-down`, `unselected` and `` respectively. @@ -102,7 +119,8 @@ catalog asset. ## Advanced selection and menus -Use native `Select` for simple options. `Combobox` adds searchable single +Use `Select` for simple options; its hidden native control preserves form +participation. `Combobox` adds searchable single selection, grouped options, explicit custom-value creation and async loading states. `MultiSelect` owns multiple selection, removable tags and select-all. Controlled values are authoritative; option discovery remains host-owned. @@ -112,6 +130,24 @@ The Web UI's legacy Select implementation is retired. Like retired Button and Switch overrides, legacy `components.select` Appearance rules are ignored at the existing read-only migration boundary; original packages are not rewritten. Selection visuals now come from the public field/menu semantic tokens. +Choose `size` explicitly when composing form rows: selectors default to `md`, +while `Input` defaults to `sm`. The shared `control.height.sm/md/lg` tokens and +active density own the actual heights; consumers must not replace them with +page-level heights or padding overrides. Picker bodies stay single-line and +token-sized, with labels and validation messages outside that height. Select +keeps its in-flow anchor mounted when the unified popup covers it, so opening +does not change the surrounding layout. + +`Combobox` and `MultiSelect` use the same joined-surface pattern: the portalled +search header covers the closed trigger, with a divider and scrollable options +inside one border and shadow. Flipping above the field keeps the search header +beside the anchor. Labels, validation, and the field id follow the active input; +Escape or selection restores the trigger, and Tab continues from its position +in the form. Search, typed values, and multiple selection remain component-owned. +`SearchField variant="embedded"` removes its standalone pill surface for these +compositions; its container must supply padding, height, and visible focus +treatment. The default SearchField appearance is unchanged. + `FieldGroup fieldSurface="ambient"` keeps text and picker field borders while letting their shells reuse the grouped surface. The default field surface stays theme-owned, and portalled menus remain on the opaque panel surface. diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts b/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts index 9a4f3b022f..8c58e4f530 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts +++ b/design-system/packages/ui/src/components/Combobox/Combobox.meta.ts @@ -2,7 +2,7 @@ import type { ComponentMeta } from "../../registry.types"; export const comboboxMeta = { category: "form", - description: "An anchored searchable single-value selector with grouped, clearable, and explicit value-creation support.", + description: "A searchable single-value selector with an embedded search header and grouped options on one anchored surface, plus clearing and explicit value creation.", maturity: "stable", name: "Combobox", props: [ @@ -13,7 +13,7 @@ export const comboboxMeta = { { defaultValue: "false", name: "clearable", type: "boolean" }, { defaultValue: "md", name: "size", type: "sm | md | lg" }, ], - states: ["default", "open", "searching", "custom", "invalid", "disabled", "loading"], + states: ["default", "open", "searching", "custom", "invalid", "disabled", "loading", "empty"], tokens: [ "color.field.background", "color.field.backgroundHover", @@ -24,6 +24,7 @@ export const comboboxMeta = { "color.content.secondary", "color.content.muted", "color.content.disabled", + "color.control.highlight.background", "color.action.neutral.content", "color.action.neutral.surface", "color.border.subtle", @@ -37,10 +38,13 @@ export const comboboxMeta = { "control.select.radius", "control.select.indicatorSize", "control.select.contentGap", + "control.select.trailingInset", + "control.iconButton.xsSize", "overlay.menu.inlineSize", "overlay.menu.surfacePadding", "overlay.menu.surfaceRadius", "overlay.menu.itemIconSize", "shadow.menu", + "type.label.selected.fontWeight", ], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.module.css b/design-system/packages/ui/src/components/Combobox/Combobox.module.css index eb3956443c..5519a3c686 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.module.css +++ b/design-system/packages/ui/src/components/Combobox/Combobox.module.css @@ -13,14 +13,19 @@ .indicator, .popover, .search, + .divider, + .options, .message, .error { box-sizing: border-box; } - .root { + .root, + .popover { --_combobox-height: var(--openbitfun-control-height-md); + } + .root { position: relative; display: flex; inline-size: 100%; @@ -30,11 +35,13 @@ color: var(--openbitfun-color-content-primary); } - .root[data-size="sm"] { + .root[data-size="sm"], + .popover[data-size="sm"] { --_combobox-height: var(--openbitfun-control-height-sm); } - .root[data-size="lg"] { + .root[data-size="lg"], + .popover[data-size="lg"] { --_combobox-height: var(--openbitfun-control-height-lg); } @@ -59,8 +66,10 @@ .control { position: relative; display: grid; + grid-template-rows: minmax(0, 1fr); inline-size: 100%; min-inline-size: 0; + block-size: var(--_combobox-height); } .trigger { @@ -69,9 +78,10 @@ align-items: center; inline-size: 100%; min-inline-size: 0; - min-block-size: var(--_combobox-height); + block-size: 100%; + min-block-size: 0; margin: 0; - padding-block: var(--openbitfun-space-1); + padding-block: 0; padding-inline-start: var(--openbitfun-control-select-padding-inline); padding-inline-end: calc( var(--openbitfun-control-select-trailing-inset) @@ -115,12 +125,16 @@ background: transparent; } - .trigger:focus-visible, - .root[data-open="true"] .trigger { + .trigger:focus-visible { border-color: var(--openbitfun-color-field-border-focus); box-shadow: 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring); } + .root[data-open="true"] .control { + visibility: hidden; + pointer-events: none; + } + .root[data-invalid="true"] .trigger { border-color: var(--openbitfun-color-status-danger-border); } @@ -275,33 +289,83 @@ position: fixed; z-index: var(--openbitfun-layer-popover); display: flex; - min-inline-size: var(--openbitfun-overlay-menu-inline-size); + min-inline-size: 0; max-inline-size: calc(100vw - var(--openbitfun-space-4)); min-block-size: 0; flex-direction: column; - gap: var(--openbitfun-space-1); - padding: var(--openbitfun-overlay-menu-surface-padding); + overflow: hidden; + padding: 0; border: var(--openbitfun-border-width-default) solid var(--openbitfun-color-border-subtle); border-radius: var(--openbitfun-overlay-menu-surface-radius); color: var(--openbitfun-color-action-neutral-content); background: var(--openbitfun-color-surface-panel); box-shadow: var(--openbitfun-shadow-menu); + animation: openbitfun-combobox-in var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-enter); + } + + .popover[data-placement="top"] { + flex-direction: column-reverse; + } + + .popover:focus-within { + border-color: var(--openbitfun-color-field-border-focus); + } + + .popover[data-invalid="true"] { + border-color: var(--openbitfun-color-status-danger-border); } .search { - flex: 0 0 auto; + display: flex; + flex: 0 0 calc( + var(--_combobox-height) + - var(--openbitfun-border-width-default) + - var(--openbitfun-border-width-default) + ); + block-size: calc( + var(--_combobox-height) + - var(--openbitfun-border-width-default) + - var(--openbitfun-border-width-default) + ); + align-items: center; + min-inline-size: 0; + gap: var(--openbitfun-control-select-content-gap); + padding-inline-start: var(--openbitfun-control-select-padding-inline); + padding-inline-end: calc( + var(--openbitfun-control-select-trailing-inset) + - (var(--openbitfun-control-icon-button-xs-size) - var(--openbitfun-control-select-indicator-size)) / 2 + ); } .searchField { + flex: 1 1 auto; inline-size: 100%; + min-inline-size: 0; + block-size: 100%; } - .searchField [data-openbitfun-component="input"] { - border-radius: var(--openbitfun-control-select-radius); + .collapse { + flex: 0 0 auto; + } + + .divider { + flex: 0 0 var(--openbitfun-border-width-default); + block-size: var(--openbitfun-border-width-default); + background: var(--openbitfun-color-border-subtle); + } + + .options { + display: flex; + flex: 1 1 auto; + overflow: hidden; + min-inline-size: 0; + min-block-size: 0; + padding: var(--openbitfun-overlay-menu-surface-padding); } .listbox { flex: 1 1 auto; + min-inline-size: 0; min-block-size: 0; } @@ -314,6 +378,14 @@ gap: var(--openbitfun-overlay-menu-section-gap); } + .listbox [data-openbitfun-listbox-option][data-selected="true"] [data-openbitfun-part="label"] { + font-weight: var(--openbitfun-type-label-selected-font-weight); + } + + .listbox [data-openbitfun-listbox-option][data-selected="true"] [data-openbitfun-part="indicator"] { + color: var(--openbitfun-color-control-highlight-background); + } + .message { display: flex; align-items: center; @@ -339,7 +411,8 @@ transition: none; } - .spinner { + .spinner, + .popover { animation: none; } } @@ -347,14 +420,24 @@ @media (forced-colors: active) { .trigger, .popover, + .popover[data-invalid="true"], .root[data-invalid="true"] .trigger { border-color: ButtonText; } .trigger:focus-visible, - .root[data-open="true"] .trigger { + .popover:focus-within { border-color: Highlight; box-shadow: 0 0 0 var(--openbitfun-focus-width) Highlight; } + + .listbox [data-openbitfun-listbox-option][data-selected="true"] [data-openbitfun-part="indicator"] { + color: Highlight; + } + } + + @keyframes openbitfun-combobox-in { + from { opacity: 0; } + to { opacity: 1; } } } diff --git a/design-system/packages/ui/src/components/Combobox/Combobox.tsx b/design-system/packages/ui/src/components/Combobox/Combobox.tsx index a1d0dc7388..80ad78ccdb 100644 --- a/design-system/packages/ui/src/components/Combobox/Combobox.tsx +++ b/design-system/packages/ui/src/components/Combobox/Combobox.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useId, + useLayoutEffect, useMemo, useRef, useState, @@ -10,6 +11,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from "react"; +import { flushSync } from "react-dom"; import { LoaderCircle } from "lucide-react"; import { Icon } from "../Icon"; import { classNames } from "../../internal/classNames"; @@ -29,6 +31,8 @@ import { import { SearchField } from "../SearchField"; import styles from "./Combobox.module.css"; +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + export type ComboboxValue = ListboxValue; export type ComboboxSize = "sm" | "md" | "lg"; export type ComboboxPlacement = Extract; @@ -182,9 +186,11 @@ const CollectionPicker = forwardRef(function Collec const [keyboardOpen, setKeyboardOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); + const searchRef = useRef(null); const popoverRef = useRef(null); + const restoreFocusAfterCloseRef = useRef(false); const dismissibleBranches = useMemo(() => [rootRef], []); - const resolvedOpen = open ?? uncontrolledOpen; + const resolvedOpen = !disabled && (open ?? uncontrolledOpen); const selectedValues = useMemo(() => { if (multiple) { const values = controlledValue as readonly ComboboxValue[] | undefined; @@ -208,6 +214,13 @@ const CollectionPicker = forwardRef(function Collec onOpenChange?.(nextOpen); }, [onOpenChange, open, resolvedOpen]); + const closeListbox = useCallback((restoreFocus: boolean) => { + restoreFocusAfterCloseRef.current = restoreFocus; + updateOpen(false); + setQuery(""); + setActiveIndex(-1); + }, [updateOpen]); + const commitValues = useCallback((nextValues: ComboboxValue[]) => { if (controlledValue === undefined) setUncontrolledValues(nextValues); if (multiple) { @@ -269,10 +282,8 @@ const CollectionPicker = forwardRef(function Collec return; } commitValues([option.value]); - setQuery(""); - updateOpen(false); - triggerRef.current?.focus(); - }, [commitValues, disabled, loading, multiple, selectedValues, updateOpen]); + closeListbox(true); + }, [closeListbox, commitValues, disabled, loading, multiple, selectedValues]); const submitCreateValue = useCallback((candidate: string) => { if (!onCreateValue || disabled || loading) return; @@ -284,11 +295,10 @@ const CollectionPicker = forwardRef(function Collec } } else { commitValues([createdValue]); - updateOpen(false); - triggerRef.current?.focus(); + closeListbox(true); } setQuery(""); - }, [commitValues, disabled, loading, multiple, onCreateValue, selectedValues, updateOpen]); + }, [closeListbox, commitValues, disabled, loading, multiple, onCreateValue, selectedValues]); const toggleSelectAll = useCallback(() => { if (!multiple || disabled || loading) return; @@ -312,6 +322,7 @@ const CollectionPicker = forwardRef(function Collec const openListbox = useCallback((direction: 1 | -1 = 1, keyboard = false) => { if (disabled) return; + restoreFocusAfterCloseRef.current = false; setKeyboardOpen(keyboard); updateOpen(true); if (!keyboard) { @@ -328,13 +339,6 @@ const CollectionPicker = forwardRef(function Collec : firstEnabledIndex(navigationItems, direction)); }, [disabled, navigationItems, selectedValues, updateOpen]); - const closeListbox = useCallback((restoreFocus: boolean) => { - updateOpen(false); - setQuery(""); - setActiveIndex(-1); - if (restoreFocus) triggerRef.current?.focus(); - }, [updateOpen]); - const handleTriggerKeyDown = useCallback((event: ReactKeyboardEvent) => { if (disabled) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { @@ -392,9 +396,13 @@ const CollectionPicker = forwardRef(function Collec || option.label.toLocaleLowerCase() === candidate.toLocaleLowerCase() )) : undefined; - if (exactOption && !exactOption.disabled) selectOption(exactOption); - else if (createCandidate) submitCreateValue(createCandidate); - closeListbox(false); + // Restore the in-flow trigger before native Tab/Shift+Tab traversal. + // Otherwise removing the focused portal input loses the field's tab position. + flushSync(() => { + if (exactOption && !exactOption.disabled) selectOption(exactOption); + else if (createCandidate) submitCreateValue(createCandidate); + closeListbox(true); + }); } }, [activateItem, activeIndex, closeListbox, createCandidate, moveActive, navigationItems, options, query, selectOption, submitCreateValue]); @@ -412,9 +420,20 @@ const CollectionPicker = forwardRef(function Collec layerRef: popoverRef, placement, matchWidth: true, + overlapAnchor: true, revision: `${query}:${filteredOptions.length}:${loading}`, }); + const positioned = layout !== null; + useIsomorphicLayoutEffect(() => { + if (resolvedOpen) { + if (positioned) searchRef.current?.focus({ preventScroll: true }); + } else if (restoreFocusAfterCloseRef.current) { + restoreFocusAfterCloseRef.current = false; + triggerRef.current?.focus({ preventScroll: true }); + } + }, [positioned, resolvedOpen]); + useEffect(() => { if (!resolvedOpen || activeIndex < 0) return; document.getElementById(optionId(activeIndex))?.scrollIntoView?.({ block: "nearest" }); @@ -492,7 +511,9 @@ const CollectionPicker = forwardRef(function Collec data-openbitfun-component={multiple ? "multi-select-popup" : "combobox-popup"} data-openbitfun-part="popover" data-keyboard-open={keyboardOpen ? "true" : "false"} + data-invalid={invalid ? "true" : "false"} data-placement={layout?.placement ?? placement} + data-size={size} ref={popoverRef} style={layout?.style ?? { position: "fixed", visibility: "hidden" }} > @@ -501,12 +522,18 @@ const CollectionPicker = forwardRef(function Collec aria-activedescendant={activeDescendant} aria-autocomplete="list" aria-controls={listboxId} + aria-describedby={resolvedDescribedBy} aria-expanded={resolvedOpen} - aria-label={designSystem.messages.searchOptions} + aria-label={resolvedTriggerLabel ?? (providedId || resolvedLabelledBy ? undefined : designSystem.messages.searchOptions)} + aria-labelledby={resolvedLabelledBy} + aria-required={required || undefined} + aria-busy={loading || undefined} autoComplete="off" className={styles.searchField} clearLabel={query ? designSystem.messages.clearSelection : undefined} - leadingIcon={} + id={id} + invalid={invalid} + leadingIcon={} onClear={query ? () => { setQuery(""); @@ -519,67 +546,78 @@ const CollectionPicker = forwardRef(function Collec setActiveIndex(-1); }} placeholder={designSystem.messages.searchOptions} - ref={(node) => { - if (node && resolvedOpen) node.focus(); - }} + ref={searchRef} role="combobox" size={size} value={query} + variant="embedded" + /> + } + onClick={() => closeListbox(true)} + size="xs" + variant="quiet" />
- - {loading && filteredOptions.length === 0 ? ( - - - ) : navigationItems.length === 0 ? ( - {designSystem.messages.noOptions} - ) : ( - <> - {navigationItems[0]?.kind === "all" && ( - : null} - onClick={toggleSelectAll} - onMouseDown={(event) => event.preventDefault()} - selected={allFilteredSelected} - > - {designSystem.messages.selectAll} - - )} - {groupedOptions.ungrouped.map(renderListboxOption)} - {[...groupedOptions.groups].map(([groupLabel, groupOptions]) => ( - - {groupOptions.map(renderListboxOption)} - - ))} - {createCandidate && (() => { - const createIndex = navigationItems.findIndex((item) => item.kind === "create"); - return ( + ) : null; @@ -603,6 +641,7 @@ const CollectionPicker = forwardRef(function Collec )}
(function Collec onMouseDown={(event) => event.preventDefault()} shape="circle" size="xs" + tabIndex={resolvedOpen ? -1 : undefined} variant="quiet" /> @@ -633,9 +673,9 @@ const CollectionPicker = forwardRef(function Collec )} + + ) : null; + return ( (function Select data-disabled={disabled ? "true" : "false"} data-field-surface={fieldSurface} data-has-leading={leading !== undefined && leading !== null ? "true" : "false"} - data-invalid={invalid ? "true" : "false"} + data-invalid={resolvedInvalid ? "true" : "false"} + data-open={resolvedOpen ? "true" : "false"} + data-placement={layout?.placement ?? placement} data-size={size} + ref={rootRef} > - {leading !== undefined && leading !== null && ( - - )} - + + {popup && ( + + {popup} + + )} ); }); diff --git a/design-system/packages/ui/src/components/Select/index.ts b/design-system/packages/ui/src/components/Select/index.ts index c0b3d6ee4a..0909a806a1 100644 --- a/design-system/packages/ui/src/components/Select/index.ts +++ b/design-system/packages/ui/src/components/Select/index.ts @@ -1,6 +1,7 @@ export { Select, type SelectOption, + type SelectPlacement, type SelectProps, type SelectSize, type SelectValue, diff --git a/design-system/packages/ui/src/components/Textarea/Textarea.meta.ts b/design-system/packages/ui/src/components/Textarea/Textarea.meta.ts index 7f1b079c2a..82c2a7a0e9 100644 --- a/design-system/packages/ui/src/components/Textarea/Textarea.meta.ts +++ b/design-system/packages/ui/src/components/Textarea/Textarea.meta.ts @@ -11,5 +11,5 @@ export const textareaMeta = { { name: "autoResize", type: "boolean", defaultValue: "false" }, { name: "showCount", type: "boolean", defaultValue: "false" }, ], states: ["default", "hover", "focus-visible", "invalid", "disabled"], - tokens: ["color.content.primary", "color.content.muted", "color.content.disabled", "color.content.requiredIndicator", "color.field.background", "color.field.border", "color.field.borderFocus", "color.focus.ring", "color.status.danger.border", "color.status.danger.content", "type.label.md.fontSize", "type.body.sm.fontSize", "type.support.fontSize", "type.code.md.fontSize"], + tokens: ["color.content.primary", "color.content.muted", "color.content.disabled", "color.content.requiredIndicator", "color.field.background", "color.field.border", "color.field.borderFocus", "color.status.danger.border", "color.status.danger.content", "type.label.md.fontSize", "type.body.sm.fontSize", "type.support.fontSize", "type.code.md.fontSize"], } as const satisfies ComponentMeta; diff --git a/design-system/packages/ui/src/components/Textarea/Textarea.module.css b/design-system/packages/ui/src/components/Textarea/Textarea.module.css index 504967b9ca..e493bc9fbc 100644 --- a/design-system/packages/ui/src/components/Textarea/Textarea.module.css +++ b/design-system/packages/ui/src/components/Textarea/Textarea.module.css @@ -21,22 +21,22 @@ font-weight: var(--openbitfun-type-body-sm-font-weight); line-height: var(--openbitfun-type-body-sm-line-height); letter-spacing: var(--openbitfun-type-body-sm-letter-spacing); - transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), box-shadow var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); + transition: background-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard), border-color var(--openbitfun-motion-duration-fast) var(--openbitfun-motion-easing-standard); } .root[data-auto-resize="true"] .textarea { overflow: hidden; resize: none; } .root[data-font="mono"] .textarea { font-family: var(--openbitfun-type-code-md-font-family); font-size: var(--openbitfun-type-code-md-font-size); font-weight: var(--openbitfun-type-code-md-font-weight); line-height: var(--openbitfun-type-code-md-line-height); letter-spacing: var(--openbitfun-type-code-md-letter-spacing); } .root[data-layout="fill"] .textarea { flex: 1 1 auto; min-block-size: 0; } .root[data-resize="none"] .textarea { resize: none; } .root[data-variant="filled"] .textarea { border-color: transparent; } - .textarea:hover:not(:disabled) { border-color: var(--openbitfun-color-field-border-hover); background: var(--openbitfun-color-field-background-hover); } + .textarea:hover:where(:not(:disabled)) { border-color: var(--openbitfun-color-field-border-hover); background: var(--openbitfun-color-field-background-hover); } .root[data-field-surface="ambient"] .textarea, .root[data-field-surface="ambient"] .textarea:hover:not(:disabled) { background: transparent; } - .textarea:focus { border-color: var(--openbitfun-color-field-border-focus); box-shadow: 0 0 0 var(--openbitfun-focus-width) var(--openbitfun-color-focus-ring); } + .root .textarea:focus { border-color: var(--openbitfun-color-field-border-focus); box-shadow: none; } .root[data-invalid="true"] .textarea { border-color: var(--openbitfun-color-status-danger-border); } .textarea:disabled { color: var(--openbitfun-color-content-disabled); cursor: not-allowed; } .textarea::placeholder, .hint, .count { color: var(--openbitfun-color-content-muted); } .support { display: flex; justify-content: space-between; gap: var(--openbitfun-space-3); font-family: var(--openbitfun-type-support-font-family); font-size: var(--openbitfun-type-support-font-size); font-weight: var(--openbitfun-type-support-font-weight); line-height: var(--openbitfun-type-support-line-height); letter-spacing: var(--openbitfun-type-support-letter-spacing); } .count { margin-inline-start: auto; white-space: nowrap; } @media (prefers-reduced-motion: reduce) { .textarea { transition: none; } } - @media (forced-colors: active) { .textarea { border-color: ButtonText; } .textarea:focus { border-color: Highlight; box-shadow: 0 0 0 var(--openbitfun-focus-width) Highlight; } } + @media (forced-colors: active) { .textarea { border-color: ButtonText; } .root .textarea:focus { border-color: Highlight; } } } diff --git a/design-system/packages/ui/src/index.ts b/design-system/packages/ui/src/index.ts index 2d8d9c3465..6983021cbb 100644 --- a/design-system/packages/ui/src/index.ts +++ b/design-system/packages/ui/src/index.ts @@ -116,6 +116,7 @@ export { type IconName, type IconProps, type IconSize, + type IconSource, type IconTone, } from "./components/Icon"; export { IconButton, type IconButtonProps } from "./components/IconButton"; diff --git a/design-system/packages/ui/src/internal/useAnchoredLayer.ts b/design-system/packages/ui/src/internal/useAnchoredLayer.ts index 543e480486..a55ff8a5d3 100644 --- a/design-system/packages/ui/src/internal/useAnchoredLayer.ts +++ b/design-system/packages/ui/src/internal/useAnchoredLayer.ts @@ -1,4 +1,6 @@ -import { useLayoutEffect, useState, type CSSProperties, type RefObject } from "react"; +import { useEffect, useLayoutEffect, useState, type CSSProperties, type RefObject } from "react"; + +const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export type LayerPlacement = "top" | "bottom" | "left" | "right"; export type PortalTarget = Element | DocumentFragment | (() => Element | DocumentFragment | null) | null; @@ -9,17 +11,19 @@ export function resolveLayerPortal(target: PortalTarget | undefined, anchor: HTM return target ?? anchor?.closest("[data-openbitfun-design-system-root]") ?? anchor?.ownerDocument.body ?? null; } -export function useAnchoredLayer({ open, anchorRef, layerRef, placement = "bottom", matchWidth = false, revision, point }: { +export function useAnchoredLayer({ open, anchorRef, layerRef, placement = "bottom", matchWidth = false, overlapAnchor = false, revision, point }: { open: boolean; anchorRef: RefObject; layerRef: RefObject; placement?: LayerPlacement; matchWidth?: boolean; + /** Position the layer over the anchor when the layer visually replaces it. */ + overlapAnchor?: boolean; revision?: unknown; point?: { x: number; y: number }; }) { const [layout, setLayout] = useState<{ style: CSSProperties; placement: LayerPlacement } | null>(null); - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { if (!open) { setLayout(null); return; } const anchor = anchorRef.current; const layer = layerRef.current; @@ -27,9 +31,9 @@ export function useAnchoredLayer({ open, anchorRef, layerRef, placement = "botto if (!layer || !view || (!anchor && !point)) return; const update = () => { const padding = 8; - const gap = point ? 0 : 4; + const resolvedGap = point ? 0 : 4; const rect = point - ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0 } + ? { left: point.x, right: point.x, top: point.y, bottom: point.y, width: 0, height: 0 } : anchor!.getBoundingClientRect(); const viewport = view.visualViewport; const vx = viewport?.offsetLeft ?? 0; @@ -43,14 +47,30 @@ export function useAnchoredLayer({ open, anchorRef, layerRef, placement = "botto const horizontal = placement === "left" || placement === "right"; const before = horizontal ? rect.left - vx : rect.top - vy; const after = horizontal ? vx + vw - rect.right : vy + vh - rect.bottom; - const needs = (horizontal ? box.width : box.height) + gap + padding; + const anchorExtent = horizontal ? rect.width : rect.height; + const overlapExtent = overlapAnchor ? anchorExtent : -resolvedGap; + const beforeCapacity = before + overlapExtent; + const afterCapacity = after + overlapExtent; + const needs = (horizontal ? box.width : box.height) + padding; const preferBefore = placement === "top" || placement === "left"; - const useBefore = preferBefore ? before >= needs || before > after : after < needs && before > after; + const useBefore = preferBefore + ? beforeCapacity >= needs || beforeCapacity > afterCapacity + : afterCapacity < needs && beforeCapacity > afterCapacity; const side: LayerPlacement = horizontal ? (useBefore ? "left" : "right") : (useBefore ? "top" : "bottom"); - const maxHeight = horizontal ? vh - padding * 2 : Math.max(0, (useBefore ? before : after) - gap - padding); + const maxHeight = horizontal + ? vh - padding * 2 + : Math.max(0, (useBefore ? beforeCapacity : afterCapacity) - padding); const height = Math.min(box.height, maxHeight); - const left = horizontal ? (useBefore ? rect.left - box.width - gap : rect.right + gap) : rect.left; - const top = horizontal ? rect.top : (useBefore ? rect.top - height - gap : rect.bottom + gap); + const left = horizontal + ? overlapAnchor + ? (useBefore ? rect.right - box.width : rect.left) + : (useBefore ? rect.left - box.width - resolvedGap : rect.right + resolvedGap) + : rect.left; + const top = horizontal + ? rect.top + : overlapAnchor + ? (useBefore ? rect.bottom - height : rect.top) + : (useBefore ? rect.top - height - resolvedGap : rect.bottom + resolvedGap); const style: CSSProperties = { position: "fixed", width, maxWidth: vw - padding * 2, maxHeight, left: Math.max(vx + padding, Math.min(left, vx + vw - box.width - padding)), @@ -73,6 +93,6 @@ export function useAnchoredLayer({ open, anchorRef, layerRef, placement = "botto view.visualViewport?.removeEventListener("resize", update); view.visualViewport?.removeEventListener("scroll", update); }; - }, [open, anchorRef, layerRef, placement, matchWidth, revision, point?.x, point?.y]); + }, [open, anchorRef, layerRef, placement, matchWidth, overlapAnchor, revision, point?.x, point?.y]); return layout; } diff --git a/design-system/packages/ui/tests/combobox.test.mjs b/design-system/packages/ui/tests/combobox.test.mjs index 50c323a52b..5f59aa5c0f 100644 --- a/design-system/packages/ui/tests/combobox.test.mjs +++ b/design-system/packages/ui/tests/combobox.test.mjs @@ -27,7 +27,6 @@ test("Combobox consumes host localization and canonical invalid state", () => { test("MultiSelect exposes an explicit multi-value trigger contract", () => { const markup = renderToStaticMarkup(createElement(MultiSelect, { - defaultOpen: true, defaultValue: ["one"], options: [ { label: "One", value: "one" }, @@ -39,7 +38,7 @@ test("MultiSelect exposes an explicit multi-value trigger contract", () => { assert.match(markup, /data-openbitfun-component="multi-select"/); assert.match(markup, /role="combobox"/); - assert.match(markup, /aria-expanded="true"/); + assert.match(markup, /aria-expanded="false"/); assert.match(markup, /aria-label="Models"/); assert.match(markup, />One { +test("Combobox embeds search and scrollable options in one token-driven surface", async () => { const source = await readFile( new URL("../src/components/Combobox/Combobox.tsx", import.meta.url), "utf8", @@ -95,14 +94,40 @@ test("Combobox search and option geometry follows the standard menu rhythm", asy ); assert.match(source, /clearLabel=\{query \? designSystem\.messages\.clearSelection : undefined\}/); - assert.match(source, /leadingIcon=\{\}/); - assert.match(styles, /\.popover\s*\{[^}]*gap:\s*var\(--openbitfun-space-1\)/s); + assert.match(source, /leadingIcon=\{\}/); + assert.match(source, /variant="embedded"/); + assert.match(source, /overlapAnchor: true/); + assert.match(source, /data-openbitfun-part="divider"/); + assert.match(source, /data-openbitfun-part="options"/); + assert.match(styles, /\.popover\s*\{[^}]*min-inline-size:\s*0[^}]*padding:\s*0/s); assert.match( styles, - /\.searchField \[data-openbitfun-component="input"\]\s*\{[^}]*border-radius:\s*var\(--openbitfun-control-select-radius\)/s, + /\.popover\[data-placement="top"\]\s*\{[^}]*flex-direction:\s*column-reverse/s, ); + assert.match(styles, /\.search\s*\{[^}]*--_combobox-height[^}]*--openbitfun-border-width-default/s); + assert.match(styles, /\.options\s*\{[^}]*min-block-size:\s*0[^}]*--openbitfun-overlay-menu-surface-padding/s); + assert.match(styles, /\.root\[data-open="true"\] \.control\s*\{[^}]*visibility:\s*hidden/s); + assert.doesNotMatch(styles, /scale\(/); assert.match( styles, /\.listbox \[data-openbitfun-part="list"\],[^}]*gap:\s*calc\(var\(--openbitfun-space-1\) \/ 2\)/s, ); }); + +test("Combobox and MultiSelect keep the field height independent of text, tags, and clear actions", async () => { + const styles = await readFile( + new URL("../src/components/Combobox/Combobox.module.css", import.meta.url), + "utf8", + ); + const control = styles.match(/\.control\s*\{([^}]+)\}/)?.[1] ?? ""; + const trigger = styles.match(/\.trigger\s*\{([^}]+)\}/)?.[1] ?? ""; + + assert.match(control, /block-size:\s*var\(--_combobox-height\)/); + assert.match(control, /grid-template-rows:\s*minmax\(0, 1fr\)/); + assert.match(trigger, /block-size:\s*100%/); + assert.match(trigger, /min-block-size:\s*0/); + assert.match(trigger, /padding-block:\s*0/); + for (const size of ["sm", "md", "lg"]) { + assert.ok(styles.includes(`var(--openbitfun-control-height-${size})`)); + } +}); diff --git a/design-system/packages/ui/tests/icon-slots.test.mjs b/design-system/packages/ui/tests/icon-slots.test.mjs index f17fb5e390..b178652e81 100644 --- a/design-system/packages/ui/tests/icon-slots.test.mjs +++ b/design-system/packages/ui/tests/icon-slots.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { Button, Icon, IconButton, SessionIcon, TabGroup } from "../dist/index.js"; +import { Network } from "lucide-react"; const slots = [ ["Button", "icon", "inline-size", "100%"], @@ -57,6 +58,19 @@ test("buttons route native and default-size catalog icons through identical slot } }); +test("buttons constrain normalized line fallbacks through the same icon slot", () => { + const lineIcon = createElement(Icon, { glyph: Network }); + for (const [Component, props] of [ + [Button, { children: "Network", leadingIcon: lineIcon }], + [IconButton, { "aria-label": "Network", icon: lineIcon }], + ]) { + const markup = renderToStaticMarkup(createElement(Component, props)); + assert.match(markup, /data-openbitfun-component="icon"/); + assert.match(markup, /data-openbitfun-source="line"/); + assert.match(markup, /]*stroke-width="1.6"/); + } +}); + test("tabs keep native session icons and catalog scene icons in the same sized region", () => { const markup = renderToStaticMarkup(createElement(TabGroup, { "aria-label": "Scenes", diff --git a/design-system/packages/ui/tests/icon.test.mjs b/design-system/packages/ui/tests/icon.test.mjs index 81f3b6fa16..c08e6ce54d 100644 --- a/design-system/packages/ui/tests/icon.test.mjs +++ b/design-system/packages/ui/tests/icon.test.mjs @@ -5,6 +5,7 @@ import { readFile, readdir } from "node:fs/promises"; import test from "node:test"; import { createHash } from "node:crypto"; import { Icon, iconNames, canonicalIconNames, iconAliases } from "../dist/index.js"; +import { Network } from "lucide-react"; test("Icon exposes the complete named catalog without duplicate names", () => { assert.equal(iconNames.length, 66); @@ -47,6 +48,25 @@ test("Icon exposes semantic size, tone, and accessible label independently", () assert.match(markup, /data-openbitfun-tone="success"/); }); +test("Icon normalizes Lucide fallbacks without exposing product-owned line weight", () => { + const markup = renderToStaticMarkup(createElement(Icon, { + glyph: Network, + label: "Network", + size: "sm", + tone: "secondary", + })); + + assert.match(markup, /data-openbitfun-component="icon"/); + assert.match(markup, /data-openbitfun-source="line"/); + assert.match(markup, /data-size="sm"/); + assert.match(markup, /data-openbitfun-tone="secondary"/); + assert.match(markup, /role="img"/); + assert.match(markup, /aria-label="Network"/); + assert.match(markup, /]*stroke-width="1.6"/); + assert.match(markup, /]*aria-hidden="true"/); + assert.doesNotMatch(markup, /mask-image/); +}); + test("Icon styles consume only public geometry and semantic color tokens", async () => { const styles = await readFile(new URL("../dist/styles.css", import.meta.url), "utf8"); @@ -55,6 +75,7 @@ test("Icon styles consume only public geometry and semantic color tokens", async assert.match(styles, /--openbitfun-color-content-primary/); assert.match(styles, /--openbitfun-color-status-success-content/); assert.match(styles, /mask-size:contain/); + assert.match(styles, /data-openbitfun-source=line/); }); test("Icon mask assets are color-agnostic", async () => { diff --git a/design-system/packages/ui/tests/search-field.test.mjs b/design-system/packages/ui/tests/search-field.test.mjs index 1b040a2561..cf32315ff5 100644 --- a/design-system/packages/ui/tests/search-field.test.mjs +++ b/design-system/packages/ui/tests/search-field.test.mjs @@ -33,6 +33,25 @@ test("SearchField source preserves consumer key handling before Enter submission assert.match(source, /onSearch\?\.\(event\.currentTarget\.value\)/); }); +test("SearchField supports embedded composition without leaking its variant onto the input", async () => { + const markup = renderToStaticMarkup(createElement(SearchField, { + "aria-label": "Search modes", + size: "sm", + variant: "embedded", + })); + assert.match(markup, /data-openbitfun-component="search-field" data-variant="embedded"/); + assert.doesNotMatch(markup, /]*variant=/); + assert.match(markup, /type="search"/); + + const styles = await readFile(new URL("../src/components/SearchField/SearchField.module.css", import.meta.url), "utf8"); + const embedded = styles.match(/\.root\[data-variant="embedded"\][^{]+\{([^}]+)\}/)?.[1] ?? ""; + assert.match(embedded, /block-size:\s*100%/); + assert.match(embedded, /padding:\s*0/); + assert.match(embedded, /border:\s*0/); + assert.match(embedded, /background:\s*transparent/); + assert.match(embedded, /box-shadow:\s*none/); +}); + test("SearchField renders custom trailing content before the clear action", () => { const markup = renderToStaticMarkup( createElement(SearchField, { @@ -92,17 +111,18 @@ test("SearchField owns pill composition while reusing Input behavior", async () assert.match(styles, /--openbitfun-type-meta-font-size/); }); -test("SearchField focus changes only the existing border color", async () => { - const styles = await readFile( - new URL("../src/components/SearchField/SearchField.module.css", import.meta.url), - "utf8", - ); - const focusRule = styles.match( - /\.root \.field:not\(\[data-invalid="true"\]\):focus-within\s*\{([^}]+)\}/, +test("SearchField shares Input's single-border focus presentation", async () => { + const [styles, inputStyles] = await Promise.all([ + readFile(new URL("../src/components/SearchField/SearchField.module.css", import.meta.url), "utf8"), + readFile(new URL("../src/components/Input/Input.module.css", import.meta.url), "utf8"), + ]); + const focusRule = inputStyles.match( + /\.field:focus-within\s*\{([^}]+)\}/, )?.[1]; + assert.doesNotMatch(styles, /border-color:|\.field[^{}]*:focus-within\s*\{/); assert.ok(focusRule); - assert.match(focusRule, /border-color: var\(--openbitfun-color-content-primary\)/); + assert.match(focusRule, /border-color: var\(--openbitfun-color-field-border-focus\)/); assert.match(focusRule, /box-shadow: none/); assert.doesNotMatch(focusRule, /border-width|outline/); }); diff --git a/design-system/packages/ui/tests/select.test.mjs b/design-system/packages/ui/tests/select.test.mjs index d443a30271..4b7a88f6c9 100644 --- a/design-system/packages/ui/tests/select.test.mjs +++ b/design-system/packages/ui/tests/select.test.mjs @@ -11,14 +11,16 @@ const options = [ { group: "Advanced", label: "Agent", value: 3 }, ]; -test("Select preserves native selection and grouped option semantics", () => { +test("Select exposes a select-only combobox and preserves native form semantics", () => { const markup = renderToStaticMarkup(createElement(Select, { "aria-label": "Mode", options, value: "ask", })); + assert.match(markup, /data-openbitfun-component="select"/); assert.match(markup, / ({ value: appearance.id, label: builtinAppearanceDisplayName(appearance, tApplication), + group: t('package.builtinTheme'), testId: 'appearance-builtin-theme-option', testAttributes: { 'data-appearance-id': appearance.id }, })), - ], [builtinAppearances, tApplication]); + ], [builtinAppearances, t, tApplication]); const selectedBuiltinThemeId = defaultPackageSelected ? selectedAppearanceId : ''; const busy = loading || !initialized || status === 'applying'; diff --git a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.presentation.test.ts b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.presentation.test.ts index 74670c0fe1..d07e7e9520 100644 --- a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.presentation.test.ts +++ b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.presentation.test.ts @@ -42,4 +42,21 @@ describe('Application settings presentation', () => { loggingSection.slice(settingsSectionStart, settingsSectionEnd), ).not.toContain('previousUnexpectedExit'); }); + + it('keeps the command-shell fallback notice outside the terminal settings section', () => { + const terminalSection = source.match( + /function TerminalSection\(\)[\s\S]*?\r?\n}\r?\n\r?\nfunction WindowBehaviorSetting/, + )?.[0] ?? ''; + const noticeStart = terminalSection.indexOf('{shouldShowCmdFallbackNotice && ('); + const settingsSectionStart = terminalSection.indexOf(''); + + expect(terminalSection).not.toBe(''); + expect(noticeStart).toBeGreaterThanOrEqual(0); + expect(settingsSectionStart).toBeGreaterThan(noticeStart); + expect(settingsSectionEnd).toBeGreaterThan(settingsSectionStart); + expect( + terminalSection.slice(settingsSectionStart, settingsSectionEnd), + ).not.toContain('cmdFallbackMessage'); + }); }); diff --git a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx index 47d821c09e..b48c6e5b0a 100644 --- a/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx +++ b/src/web-ui/src/infrastructure/config/components/ApplicationSettingsPages.tsx @@ -732,16 +732,17 @@ function TerminalSection() {
+ {shouldShowCmdFallbackNotice && ( + + )} + - {shouldShowCmdFallbackNotice && ( - - )} {availableShells.length > 0 ? ( handleShellChange(v === AUTO_DETECT_SHELL_VALUE ? '' : v as string)} options={shellOptions} @@ -766,6 +768,7 @@ function TerminalSection() { align="center" > { void handleChange(v as string); }} options={behaviorOptions} diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx index 28be548a4a..f8448245af 100644 --- a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx @@ -246,7 +246,7 @@ const SkillsConfig: React.FC = () => { ]} value={formLevel} onValueChange={(value) => setFormLevel(value as SkillLevel)} - size="md" + size="sm" /> {formLevel === 'project' && hasWorkspace && ( @@ -265,7 +265,7 @@ const SkillsConfig: React.FC = () => { } /> diff --git a/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.presentation.test.ts b/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.presentation.test.ts index ebb6ec16e8..32acbb3c74 100644 --- a/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.presentation.test.ts +++ b/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.presentation.test.ts @@ -10,7 +10,7 @@ function readSource(): string { } describe('WebSearch settings presentation', () => { - it('keeps secrets out of generic config writes and places provider selection in the section header', () => { + it('keeps secrets out of generic config writes and preserves the settings draft lifecycle', () => { const source = readSource(); expect(source).toContain("configAPI.setConfig('ai.web_search', config)"); @@ -41,7 +41,6 @@ describe('WebSearch settings presentation', () => { expect(source).toContain('OPENBITFUN_PROTOCOL_ERROR_EXAMPLE'); expect(source).toContain('OPENBITFUN_PROTOCOL_ERROR_CODES'); expect(source).not.toContain('{OPENBITFUN_PROTOCOL_ERROR_CODES}'); - expect(source).not.toContain("t('fields.provider."); expect(source).not.toContain("t('fields.fallback."); expect(source).not.toContain("t('fields.apply."); expect(source).not.toContain("t('sections.apply."); diff --git a/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.tsx index 076b2d7c45..d763b66d22 100644 --- a/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/WebSearchSettingsPage.tsx @@ -168,11 +168,27 @@ const WebSearchSettingsPage: React.FC = () => { const [draftMessage, setDraftMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); const [operationMessage, setOperationMessage] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); - const providerOptions = useMemo(() => [ - { value: 'exa_mcp_free', label: t('providers.exaMcpFree') }, - { value: 'exa_search_api', label: t('providers.exaSearchApi') }, - { value: 'tavily', label: t('providers.tavily') }, - { value: 'openbitfun_search_http', label: t('providers.openbitfunSearchHttp') }, + const providerOptions = useMemo<(SelectOption & { description: string })[]>(() => [ + { + value: 'exa_mcp_free', + label: t('providers.exaMcpFree'), + description: t('providerDescriptions.exaMcpFree'), + }, + { + value: 'exa_search_api', + label: t('providers.exaSearchApi'), + description: t('providerDescriptions.exaSearchApi'), + }, + { + value: 'tavily', + label: t('providers.tavily'), + description: t('providerDescriptions.tavily'), + }, + { + value: 'openbitfun_search_http', + label: t('providers.openbitfunSearchHttp'), + description: t('providerDescriptions.openbitfunSearchHttp'), + }, ], [t]); const authOptions = useMemo(() => [ { value: 'none', label: t('auth.none') }, @@ -417,20 +433,37 @@ const WebSearchSettingsPage: React.FC = () => { /> + , + )); + const trigger = () => host.querySelector('[data-openbitfun-part="trigger"]')!; + const activeCombobox = () => document.querySelector('button[role="combobox"]')!; + const key = (element: Element, value: string) => act(() => { + element.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: value })); + }); + + beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + change.mockClear(); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.restoreAllMocks(); + }); + + it('opens a grouped portalled listbox with a selected row and check indicator', () => { + render(); + const anchor = trigger(); + const comboboxId = anchor.id; + act(() => anchor.click()); + + const popup = document.querySelector('[data-openbitfun-component="select-popup"]')!; + const header = popup.querySelector('[data-openbitfun-part="header"]')!; + const listbox = document.querySelector('[role="listbox"]')!; + const selected = listbox.querySelector('[role="option"][aria-selected="true"]')!; + expect(popup.parentElement).toBe(document.body); + expect(host.querySelector('[role="listbox"]')).toBeNull(); + expect(popup.contains(header)).toBe(true); + expect(popup.contains(listbox)).toBe(true); + expect(popup.children[0]).toBe(header); + expect(popup.children[1]?.getAttribute('data-openbitfun-part')).toBe('divider'); + expect(popup.children[2]?.getAttribute('data-openbitfun-part')).toBe('options'); + expect(listbox.querySelectorAll('[role="group"]')).toHaveLength(2); + expect(selected.textContent).toContain('Ask'); + expect(selected.querySelector('[data-openbitfun-part="indicator"]')).not.toBeNull(); + expect(anchor.getAttribute('aria-hidden')).toBe('true'); + expect(anchor.getAttribute('role')).toBeNull(); + expect(anchor.id).toBe(''); + expect(header.id).toBe(comboboxId); + expect(activeCombobox()).toBe(header); + expect(header.getAttribute('aria-expanded')).toBe('true'); + expect(document.querySelectorAll('button[role="combobox"]')).toHaveLength(1); + }); + + it('skips disabled options, commits typed values, closes, and restores focus', () => { + render(); + const anchor = trigger(); + key(anchor, 'ArrowDown'); + expect(document.activeElement?.textContent).toContain('Ask'); + key(document.activeElement!, 'ArrowDown'); + expect(document.activeElement?.textContent).toContain('Agent'); + key(document.activeElement!, 'Enter'); + + expect(change).toHaveBeenLastCalledWith(3); + expect(document.querySelector('[role="listbox"]')).toBeNull(); + expect(document.activeElement).toBe(anchor); + expect(anchor.textContent).toContain('Agent'); + }); + + it.each(['sm', 'md', 'lg'] as const)('preserves the %s anchor contents and size while its popup is open', (size) => { + render({ size }); + const anchor = trigger(); + const field = anchor.parentElement!; + const content = [...anchor.childNodes]; + + act(() => anchor.click()); + const popup = document.querySelector('[data-openbitfun-component="select-popup"]')!; + expect(field.dataset.size).toBe(size); + expect(popup.dataset.size).toBe(size); + expect([...anchor.childNodes]).toEqual(content); + expect(anchor.getAttribute('aria-hidden')).toBe('true'); + expect(anchor.tabIndex).toBe(-1); + + key(document.activeElement!, 'Escape'); + expect(document.querySelector('[data-openbitfun-component="select-popup"]')).toBeNull(); + expect([...anchor.childNodes]).toEqual(content); + expect(field.dataset.size).toBe(size); + expect(document.activeElement).toBe(anchor); + }); + + it('keeps controlled values authoritative while reporting the requested option', () => { + render({ value: 'ask' }); + const anchor = trigger(); + act(() => anchor.click()); + const agent = document.querySelector('[role="option"][data-value="3"]')!; + act(() => agent.click()); + + expect(change).toHaveBeenLastCalledWith(3); + expect(anchor.textContent).toContain('Ask'); + }); + + it('keeps the native form control synchronized for existing product integrations', () => { + render(); + const native = host.querySelector('select')!; + expect(native.getAttribute('aria-hidden')).toBe('true'); + expect(native.value).toBe('ask'); + + act(() => { + native.value = '3'; + native.dispatchEvent(new Event('change', { bubbles: true })); + }); + expect(change).toHaveBeenLastCalledWith(3); + expect(trigger().textContent).toContain('Agent'); + }); + + it('moves Field semantics to a unified popup that exactly covers the anchor rectangle', () => { + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.dataset.openbitfunComponent === 'select-popup') return new DOMRect(0, 0, 240, 180); + return new DOMRect(40, 20, 240, 32); + }); + act(() => root.render( + +