Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/few-bears-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Add `Select.Group` and `Select.GroupLabel` compound APIs so consumers can render grouped select options without importing Base UI primitives directly.
5 changes: 5 additions & 0 deletions .changeset/olive-ladybugs-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": minor
---

Add a new `ColorPicker` component with HEX/RGB/HSL/HSB editing, color area + hue slider controls, and optional EyeDropper integration.
38 changes: 38 additions & 0 deletions packages/kumo-docs-astro/src/components/demos/SelectDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,44 @@ export function SelectMultipleDemo() {
);
}

const groupedDevices = [
{
category: "iPhone",
items: ["iPhone 16 Pro", "iPhone 16", "iPhone 15"],
},
{
category: "Android",
items: ["Pixel 9 Pro", "Galaxy S25", "Xiaomi 15"],
},
{
category: "iPad",
items: ["iPad Pro 13", "iPad Air 11"],
},
] as const;

const shouldShowGroupLabel = groupedDevices.length > 1;

export function SelectGroupedDemo() {
const [value, setValue] = useState<string>(groupedDevices[0].items[0]);

return (
<Select className="w-[240px]" value={value} onValueChange={(v) => setValue(v as string)}>
{groupedDevices.map((group) => (
<Select.Group key={group.category}>
{shouldShowGroupLabel && (
<Select.GroupLabel>{group.category}</Select.GroupLabel>
)}
{group.items.map((device) => (
<Select.Option key={device} value={device}>
{device}
</Select.Option>
))}
</Select.Group>
))}
</Select>
);
}

const authors = [
{ id: 1, name: "John Doe", title: "Programmer" },
{ id: 2, name: "Alice Smith", title: "Software Engineer" },
Expand Down
41 changes: 41 additions & 0 deletions packages/kumo-docs-astro/src/pages/components/select.astro
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
SelectLoadingDemo,
SelectLoadingDataDemo,
SelectMultipleDemo,
SelectGroupedDemo,
SelectComplexDemo,
} from "../../components/demos/SelectDemo";
---
Expand Down Expand Up @@ -322,6 +323,46 @@ function App() {
</ComponentExample>
</ComponentSection>

<!-- Grouped Items -->
<ComponentSection>
<Heading level={3}>Grouped Items</Heading>
<p class="mb-4 flex flex-col gap-4">
<span class="text-kumo-strong">
Use <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">Select.Group</code> and
<code class="rounded bg-kumo-control px-1 py-0.5 text-sm">Select.GroupLabel</code> to
organize long option lists by category without importing Base UI primitives directly.
</span>
</p>

<ComponentExample
code={`function App() {
const [value, setValue] = useState("iPhone 16 Pro");
const groups = [
{ category: "iPhone", items: ["iPhone 16 Pro", "iPhone 16", "iPhone 15"] },
{ category: "Android", items: ["Pixel 9 Pro", "Galaxy S25", "Xiaomi 15"] },
{ category: "iPad", items: ["iPad Pro 13", "iPad Air 11"] },
];

return (
<Select className="w-[240px]" value={value} onValueChange={(v) => setValue(v as string)}>
{groups.map((group) => (
<Select.Group key={group.category}>
<Select.GroupLabel>{group.category}</Select.GroupLabel>
{group.items.map((item) => (
<Select.Option key={item} value={item}>
{item}
</Select.Option>
))}
</Select.Group>
))}
</Select>
);
}`}
>
<SelectGroupedDemo client:load />
</ComponentExample>
</ComponentSection>

<!-- More Example -->
<ComponentSection>
<Heading level={3}>More Example</Heading>
Expand Down
32 changes: 29 additions & 3 deletions packages/kumo/ai/component-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -2036,6 +2036,18 @@
"importPath": "@cloudflare/kumo",
"category": "Input",
"props": {
"size": {
"type": "enum",
"optional": true,
"description": "Size of the combobox trigger. Matches Input component sizes.\n- `\"xs\"` — Extra small for compact UIs (h-5 / 20px)\n- `\"sm\"` — Small for secondary fields (h-6.5 / 26px)\n- `\"base\"` — Default size (h-9 / 36px)\n- `\"lg\"` — Large for prominent fields (h-10 / 40px)",
"values": [
"xs",
"sm",
"base",
"lg"
],
"default": "base"
},
"inputSide": {
"type": "enum",
"optional": true,
Expand Down Expand Up @@ -2114,7 +2126,9 @@
"<Combobox\n value={value}\n onValueChange={(v) => setValue(v as ServerLocation | null)}\n items={servers}\n >\n <Combobox.TriggerInput\n className=\"w-[200px]\"\n placeholder=\"Select server\"\n />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(group: ServerLocationGroup) => (\n <Combobox.Group key={group.value} items={group.items}>\n <Combobox.GroupLabel>{group.value}</Combobox.GroupLabel>\n <Combobox.Collection>\n {(item: ServerLocation) => (\n <Combobox.Item key={item.value} value={item}>\n {item.label}\n </Combobox.Item>\n )}\n </Combobox.Collection>\n </Combobox.Group>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>",
"<div className=\"flex gap-2\">\n <Combobox\n value={value}\n onValueChange={setValue}\n items={bots}\n isItemEqualToValue={(bot: BotItem, selected: BotItem) =>\n bot.value === selected.value\n }\n multiple\n >\n <Combobox.TriggerMultipleWithInput\n className=\"w-[400px]\"\n placeholder=\"Select bots\"\n renderItem={(selected: BotItem) => (\n <Combobox.Chip key={selected.value}>{selected.label}</Combobox.Chip>\n )}\n inputSide=\"right\"\n />\n <Combobox.Content className=\"max-h-[200px] min-w-auto overflow-y-auto\">\n <Combobox.Empty />\n <Combobox.List>\n {(item: BotItem) => (\n <Combobox.Item key={item.value} value={item}>\n <div className=\"flex gap-2\">\n <Text>{item.label}</Text>\n <Text variant=\"secondary\">{item.author}</Text>\n </div>\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n <Button variant=\"primary\">Submit</Button>\n </div>",
"<div className=\"w-80\">\n <Combobox\n items={databases}\n value={value}\n onValueChange={setValue}\n label=\"Database\"\n description=\"Select your preferred database\"\n >\n <Combobox.TriggerInput placeholder=\"Select database\" />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(item: DatabaseItem) => (\n <Combobox.Item key={item.value} value={item}>\n {item.label}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n </div>",
"<div className=\"w-80\">\n <Combobox\n items={databases}\n value={value}\n onValueChange={setValue}\n label=\"Database\"\n error={{ message: \"Please select a database\", match: true }}\n >\n <Combobox.TriggerInput placeholder=\"Select database\" />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(item: DatabaseItem) => (\n <Combobox.Item key={item.value} value={item}>\n {item.label}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n </div>"
"<div className=\"w-80\">\n <Combobox\n items={databases}\n value={value}\n onValueChange={setValue}\n label=\"Database\"\n error={{ message: \"Please select a database\", match: true }}\n >\n <Combobox.TriggerInput placeholder=\"Select database\" />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(item: DatabaseItem) => (\n <Combobox.Item key={item.value} value={item}>\n {item.label}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n </div>",
"<div className=\"flex flex-wrap items-center gap-4\">\n <Combobox\n size=\"sm\"\n value={smValue}\n onValueChange={(v) => setSmValue(v as string | null)}\n items={fruits.slice(0, 8)}\n >\n <Combobox.TriggerInput placeholder=\"Small (sm)\" />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(item: string) => (\n <Combobox.Item key={item} value={item}>\n {item}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n <Combobox\n size=\"base\"\n value={baseValue}\n onValueChange={(v) => setBaseValue(v as string | null)}\n items={fruits.slice(0, 8)}\n >\n <Combobox.TriggerInput placeholder=\"Base (default)\" />\n <Combobox.Content>\n <Combobox.Empty />\n <Combobox.List>\n {(item: string) => (\n <Combobox.Item key={item} value={item}>\n {item}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n </div>",
"<div className=\"flex flex-wrap items-center gap-4\">\n <Combobox\n size=\"sm\"\n value={smValue}\n onValueChange={(v) => setSmValue(v as Language)}\n items={languages}\n >\n <Combobox.TriggerValue className=\"w-[160px]\" />\n <Combobox.Content>\n <Combobox.Input placeholder=\"Search\" />\n <Combobox.Empty />\n <Combobox.List>\n {(item: Language) => (\n <Combobox.Item key={item.value} value={item}>\n {item.emoji} {item.label}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n <Combobox\n size=\"base\"\n value={baseValue}\n onValueChange={(v) => setBaseValue(v as Language)}\n items={languages}\n >\n <Combobox.TriggerValue className=\"w-[180px]\" />\n <Combobox.Content>\n <Combobox.Input placeholder=\"Search\" />\n <Combobox.Empty />\n <Combobox.List>\n {(item: Language) => (\n <Combobox.Item key={item.value} value={item}>\n {item.emoji} {item.label}\n </Combobox.Item>\n )}\n </Combobox.List>\n </Combobox.Content>\n </Combobox>\n </div>"
],
"colors": [
"bg-kumo-control",
Expand Down Expand Up @@ -3452,7 +3466,7 @@
"<Meter label=\"Progress\" value={40} showValue={false} />",
"<Meter label=\"Quota reached\" value={100} />",
"<Meter label=\"Memory usage\" value={15} />",
"<Meter\n label=\"Upload progress\"\n value={80}\n indicatorClassName=\"from-green-500 via-green-500 to-green-500\"\n />"
"<Meter\n label=\"Upload progress\"\n value={80}\n indicatorClassName=\"from-kumo-success via-kumo-success to-kumo-success\"\n />"
],
"colors": [
"bg-kumo-fill",
Expand Down Expand Up @@ -3854,20 +3868,32 @@
"<Select className=\"w-[200px]\" loading />",
"<Select\n className=\"w-[200px]\"\n loading={loading}\n value={value}\n onValueChange={(v) => setValue(v as string | null)}\n placeholder=\"Please select\"\n items={items}\n />",
"<Select\n className=\"w-[250px]\"\n multiple\n renderValue={(value) => {\n if (value.length > 3) {\n return (\n <span className=\"line-clamp-1\">\n {value.slice(2).join(\", \") + ` and ${value.length - 2} more`}\n </span>\n );\n }\n return <span>{value.join(\", \")}</span>;\n }}\n value={value}\n onValueChange={(v) => setValue(v as string[])}\n >\n <Select.Option value=\"Name\">Name</Select.Option>\n <Select.Option value=\"Location\">Location</Select.Option>\n <Select.Option value=\"Size\">Size</Select.Option>\n <Select.Option value=\"Read\">Read</Select.Option>\n <Select.Option value=\"Write\">Write</Select.Option>\n <Select.Option value=\"CreatedAt\">Created At</Select.Option>\n </Select>",
"<Select className=\"w-[240px]\" value={value} onValueChange={(v) => setValue(v as string)}>\n {groupedDevices.map((group) => (\n <Select.Group key={group.category}>\n {shouldShowGroupLabel && (\n <Select.GroupLabel>{group.category}</Select.GroupLabel>\n )}\n {group.items.map((device) => (\n <Select.Option key={device} value={device}>\n {device}\n </Select.Option>\n ))}\n </Select.Group>\n ))}\n </Select>",
"<Select\n className=\"w-[200px]\"\n onValueChange={(v) => setValue(v as (typeof authors)[0] | null)}\n value={value}\n isItemEqualToValue={(item, value) => item?.id === value?.id}\n renderValue={(author) => {\n return author?.name ?? \"Please select author\";\n }}\n >\n {authors.map((author) => (\n <Select.Option key={author.id} value={author}>\n <div className=\"flex w-[300px] items-center justify-between gap-2\">\n <Text>{author.name}</Text>\n <Text variant=\"secondary\">{author.title}</Text>\n </div>\n </Select.Option>\n ))}\n </Select>"
],
"colors": [
"bg-kumo-control",
"bg-kumo-overlay",
"ring-kumo-line",
"ring-kumo-ring",
"text-kumo-default"
"text-kumo-default",
"text-kumo-subtle"
],
"subComponents": {
"Option": {
"name": "Option",
"description": "Option sub-component",
"props": {}
},
"Group": {
"name": "Group",
"description": "Group sub-component",
"props": {}
},
"GroupLabel": {
"name": "GroupLabel",
"description": "GroupLabel sub-component",
"props": {}
}
},
"styling": {
Expand Down
119 changes: 117 additions & 2 deletions packages/kumo/ai/component-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,12 @@ Combobox — autocomplete input with filterable dropdown list. Compound compone

**Props:**

- `size`: enum [default: base]
Size of the combobox trigger. Matches Input component sizes.
- `"xs"` — Extra small for compact UIs (h-5 / 20px)
- `"sm"` — Small for secondary fields (h-6.5 / 26px)
- `"base"` — Default size (h-9 / 36px)
- `"lg"` — Large for prominent fields (h-10 / 40px)
- `inputSide`: enum [default: right]
- `"right"`: Input positioned inline to the right of chips
- `"top"`: Input positioned above chips
Expand Down Expand Up @@ -1437,6 +1443,90 @@ Usage:
</div>
```

```tsx
<div className="flex flex-wrap items-center gap-4">
<Combobox
size="sm"
value={smValue}
onValueChange={(v) => setSmValue(v as string | null)}
items={fruits.slice(0, 8)}
>
<Combobox.TriggerInput placeholder="Small (sm)" />
<Combobox.Content>
<Combobox.Empty />
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox>
<Combobox
size="base"
value={baseValue}
onValueChange={(v) => setBaseValue(v as string | null)}
items={fruits.slice(0, 8)}
>
<Combobox.TriggerInput placeholder="Base (default)" />
<Combobox.Content>
<Combobox.Empty />
<Combobox.List>
{(item: string) => (
<Combobox.Item key={item} value={item}>
{item}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox>
</div>
```

```tsx
<div className="flex flex-wrap items-center gap-4">
<Combobox
size="sm"
value={smValue}
onValueChange={(v) => setSmValue(v as Language)}
items={languages}
>
<Combobox.TriggerValue className="w-[160px]" />
<Combobox.Content>
<Combobox.Input placeholder="Search" />
<Combobox.Empty />
<Combobox.List>
{(item: Language) => (
<Combobox.Item key={item.value} value={item}>
{item.emoji} {item.label}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox>
<Combobox
size="base"
value={baseValue}
onValueChange={(v) => setBaseValue(v as Language)}
items={languages}
>
<Combobox.TriggerValue className="w-[180px]" />
<Combobox.Content>
<Combobox.Input placeholder="Search" />
<Combobox.Empty />
<Combobox.List>
{(item: Language) => (
<Combobox.Item key={item.value} value={item}>
{item.emoji} {item.label}
</Combobox.Item>
)}
</Combobox.List>
</Combobox.Content>
</Combobox>
</div>
```


---

Expand Down Expand Up @@ -3292,7 +3382,7 @@ Progress bar showing a measured value within a known range (e.g. quota usage).
<Meter
label="Upload progress"
value={80}
indicatorClassName="from-green-500 via-green-500 to-green-500"
indicatorClassName="from-kumo-success via-kumo-success to-kumo-success"
/>
```

Expand Down Expand Up @@ -3819,7 +3909,7 @@ Select component

**Colors (kumo tokens used):**

`bg-kumo-control`, `bg-kumo-overlay`, `ring-kumo-line`, `ring-kumo-ring`, `text-kumo-default`
`bg-kumo-control`, `bg-kumo-overlay`, `ring-kumo-line`, `ring-kumo-ring`, `text-kumo-default`, `text-kumo-subtle`

**Styling:**

Expand All @@ -3832,6 +3922,14 @@ This is a compound component. Use these sub-components:

Option sub-component

#### Select.Group

Group sub-component

#### Select.GroupLabel

GroupLabel sub-component


**Examples:**

Expand Down Expand Up @@ -3904,6 +4002,23 @@ Option sub-component
</Select>
```

```tsx
<Select className="w-[240px]" value={value} onValueChange={(v) => setValue(v as string)}>
{groupedDevices.map((group) => (
<Select.Group key={group.category}>
{shouldShowGroupLabel && (
<Select.GroupLabel>{group.category}</Select.GroupLabel>
)}
{group.items.map((device) => (
<Select.Option key={device} value={device}>
{device}
</Select.Option>
))}
</Select.Group>
))}
</Select>
```

```tsx
<Select
className="w-[200px]"
Expand Down
1 change: 1 addition & 0 deletions packages/kumo/ai/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,7 @@ export const CollapsiblePropsSchema = z.object({
});

export const ComboboxPropsSchema = z.object({
size: z.enum(["xs", "sm", "base", "lg"]).optional(), // Size of the combobox trigger. Matches Input component sizes. - `"xs"` — Extra small for compact UIs (h-5 / 20px) - `"sm"` — Small for secondary fields (h-6.5 / 26px) - `"base"` — Default size (h-9 / 36px) - `"lg"` — Large for prominent fields (h-10 / 40px)
inputSide: z.enum(["right", "top"]).optional(), // Position of the text input relative to chips in multi-select mode. - `"right"` — Input inline to the right of chips - `"top"` — Input above chips
items: z.array(z.unknown()), // Array of items to display in the dropdown
value: z.array(z.unknown()).optional(), // Currently selected value(s)
Expand Down
Loading