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
20 changes: 14 additions & 6 deletions design-system/apps/design-lab/src/pages/ComponentDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ export function ComponentDetailPage({
const [iconSize, setIconSize] = useState<IconSize>("lg");
const [iconTone, setIconTone] = useState<IconTone>("inherit");
const [selectValue, setSelectValue] = useState<string>("ask");
const [multiSelectValues, setMultiSelectValues] = useState<Array<string | number>>(["ask", "plan"]);
const [size, setSize] = useState<PreviewSize>("md");
const [fieldOrientation, setFieldOrientation] = useState<FieldOrientation>("horizontal");
const [fieldShowLabelAction, setFieldShowLabelAction] = useState(false);
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -1126,7 +1128,8 @@ export function ComponentDetailPage({
if (component.name === "Combobox") {
return (
<Combobox
defaultOpen={state === "open" || state === "searching"}
defaultOpen={state === "open" || state === "searching" || state === "loading" || state === "empty"}
clearable
disabled={state === "disabled"}
invalid={state === "invalid"}
key={state}
Expand All @@ -1139,6 +1142,7 @@ export function ComponentDetailPage({
]}
onCreateValue={state === "custom" ? value => value : undefined}
aria-label="Mode"
size={size}
value={selectValue}
/>
);
Expand All @@ -1147,19 +1151,22 @@ export function ComponentDetailPage({
if (component.name === "MultiSelect") {
return (
<MultiSelect
defaultOpen={state === "open" || state === "searching"}
defaultOpen={state === "open" || state === "searching" || state === "loading" || state === "empty"}
disabled={state === "disabled"}
invalid={state === "invalid"}
key={state}
loading={state === "loading"}
onCreateValue={state === "custom" ? value => 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}
/>
);
}
Expand Down Expand Up @@ -1192,6 +1199,7 @@ export function ComponentDetailPage({
invalid={state === "invalid"}
leading={<Icon name="unselected" />}
onValueChange={(value) => setSelectValue(String(value))}
open={state === "open" ? true : undefined}
options={[
{ label: "Ask", value: "ask" },
{ label: "Plan", value: "plan" },
Expand Down Expand Up @@ -2056,7 +2064,7 @@ export function ComponentDetailPage({
</span>
{renderPreview(previewState)}
</div>
) : component.name === "Combobox" ? (
) : component.name === "Combobox" || component.name === "MultiSelect" ? (
<div className="component-combobox-preview" data-component="combobox">
<span>{stateLabel(previewState)}</span>
{renderPreview(previewState)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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=\{<Icon name="unselected" \/>\}/);
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 () => {
Expand Down Expand Up @@ -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"/);
Expand Down Expand Up @@ -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, /<NestedMenuPattern/);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
"backgroundHover": { "$type": "color", "$value": "rgba(255, 255, 255, 0.06)" },
"border": { "$type": "color", "$value": "rgba(255, 255, 255, 0.18)" },
"borderHover": { "$type": "color", "$value": "rgba(255, 255, 255, 0.24)" },
"borderFocus": { "$type": "color", "$value": "{ref.color.blue.400}" }
"borderFocus": { "$type": "color", "$value": "{ref.color.neutral.550}" }
},
"control": {
"highlight": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
"backgroundHover": { "$type": "color", "$value": "{color.field.background}" },
"border": { "$type": "color", "$value": "rgba(16, 26, 39, 0.15)" },
"borderHover": { "$type": "color", "$value": "rgba(16, 26, 39, 0.24)" },
"borderFocus": { "$type": "color", "$value": "{ref.color.navy.950}" }
"borderFocus": { "$type": "color", "$value": "{ref.color.neutral.550}" }
},
"control": {
"highlight": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ test("all theme variants expose the same semantic theme contract", async () => {
}
});

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"),
Expand All @@ -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,
Expand Down
38 changes: 37 additions & 1 deletion design-system/packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";

<Icon glyph={Network} size="sm" />
```

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 `<NumberBadge value={18} />` respectively.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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;
Loading
Loading