Skip to content

Commit 0cae077

Browse files
feat(Select): add size prop (xs/sm/base/lg) matching Input/Combobox (#405)
1 parent d05968e commit 0cae077

File tree

5 files changed

+148
-16
lines changed

5 files changed

+148
-16
lines changed

.changeset/select-size-variants.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": minor
3+
---
4+
5+
feat(Select): add size prop (xs/sm/base/lg) matching Input and Combobox heights

packages/kumo-docs-astro/src/components/demos/SelectDemo.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,54 @@ export function SelectBasicDemo() {
1616
);
1717
}
1818

19+
/** Select trigger sizes (xs/sm/base/lg) matching Input and Combobox. */
20+
export function SelectSizesDemo() {
21+
return (
22+
<div className="grid gap-4">
23+
<div className="flex items-center gap-3">
24+
<span className="w-10 text-sm text-kumo-subtle">xs</span>
25+
<Select
26+
aria-label="Select size xs"
27+
size="xs"
28+
className="w-[200px]"
29+
placeholder="Choose..."
30+
items={{ a: "Option A", b: "Option B" }}
31+
/>
32+
</div>
33+
<div className="flex items-center gap-3">
34+
<span className="w-10 text-sm text-kumo-subtle">sm</span>
35+
<Select
36+
aria-label="Select size sm"
37+
size="sm"
38+
className="w-[200px]"
39+
placeholder="Choose..."
40+
items={{ a: "Option A", b: "Option B" }}
41+
/>
42+
</div>
43+
<div className="flex items-center gap-3">
44+
<span className="w-10 text-sm text-kumo-subtle">base</span>
45+
<Select
46+
aria-label="Select size base"
47+
size="base"
48+
className="w-[200px]"
49+
placeholder="Choose..."
50+
items={{ a: "Option A", b: "Option B" }}
51+
/>
52+
</div>
53+
<div className="flex items-center gap-3">
54+
<span className="w-10 text-sm text-kumo-subtle">lg</span>
55+
<Select
56+
aria-label="Select size lg"
57+
size="lg"
58+
className="w-[200px]"
59+
placeholder="Choose..."
60+
items={{ a: "Option A", b: "Option B" }}
61+
/>
62+
</div>
63+
</div>
64+
);
65+
}
66+
1967
/** Select without visible label - use aria-label for accessibility. */
2068
export function SelectWithoutLabelDemo() {
2169
const [value, setValue] = useState("apple");

packages/kumo-docs-astro/src/pages/components/select.mdx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Heading from "~/components/docs/Heading.astro";
1313
import PropsTable from "~/components/docs/PropsTable.astro";
1414
import {
1515
SelectBasicDemo,
16+
SelectSizesDemo,
1617
SelectWithoutLabelDemo,
1718
SelectWithFieldDemo,
1819
SelectPlaceholderDemo,
@@ -92,6 +93,20 @@ export default function Example() {
9293
</ComponentExample>
9394
</ComponentSection>
9495

96+
{/* Sizes */}
97+
98+
<ComponentSection>
99+
<Heading level={3}>Sizes</Heading>
100+
<p>
101+
Use the <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">size</code> prop
102+
to match Input sizing (xs, sm, base, lg).
103+
</p>
104+
105+
<ComponentExample demo="SelectSizesDemo">
106+
<SelectSizesDemo client:load />
107+
</ComponentExample>
108+
</ComponentSection>
109+
95110
{/* Without Visible Label */}
96111

97112
<ComponentSection>
@@ -318,11 +333,23 @@ export default function Example() {
318333
<Heading level={3}>Select.Option</Heading>
319334
<PropsTable component="Select.Option" />
320335

321-
<Heading level={3}>Select.Group</Heading>
322-
<p>Groups related options together with an accessible <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">role="group"</code>. Use with <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">Select.GroupLabel</code> to provide a visible heading.</p>
336+
<Heading level={3}>Select.Group</Heading>
337+
<p>
338+
Groups related options together with an accessible{" "}
339+
<code class="rounded bg-kumo-control px-1 py-0.5 text-sm">role="group"</code>.
340+
Use with{" "}
341+
<code class="rounded bg-kumo-control px-1 py-0.5 text-sm">
342+
Select.GroupLabel
343+
</code>{" "}
344+
to provide a visible heading.
345+
</p>
323346

324-
<Heading level={3}>Select.GroupLabel</Heading>
325-
<p>A visible heading for a <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">Select.Group</code>. Automatically associated with its parent group for accessibility.</p>
347+
<Heading level={3}>Select.GroupLabel</Heading>
348+
<p>
349+
A visible heading for a{" "}
350+
<code class="rounded bg-kumo-control px-1 py-0.5 text-sm">Select.Group</code>.
351+
Automatically associated with its parent group for accessibility.
352+
</p>
326353

327354
<Heading level={3}>Select.Separator</Heading>
328355
<p>A visual divider line between option groups. Renders with <code class="rounded bg-kumo-control px-1 py-0.5 text-sm">role="separator"</code>.</p>

packages/kumo/src/components/select/select.test.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { useState } from "react";
44
import { Select } from "./select";
55

66
describe("Select", () => {
7+
describe("size", () => {
8+
it("applies size classes to the trigger", () => {
9+
const { container } = render(
10+
<Select aria-label="Pick one" size="xs">
11+
<Select.Option value="a">Option A</Select.Option>
12+
</Select>,
13+
);
14+
15+
const trigger = container.querySelector('[role="combobox"]');
16+
expect(trigger).toBeTruthy();
17+
expect(trigger?.className).toContain("h-5");
18+
expect(trigger?.className).toContain("px-1.5");
19+
expect(trigger?.className).toContain("text-xs");
20+
});
21+
});
22+
723
describe("label visibility (new behavior)", () => {
824
it("shows visible label by default when label prop is provided", () => {
925
render(

packages/kumo/src/components/select/select.tsx

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,22 @@ import { forwardRef, useId } from "react";
44
import type { ReactNode } from "react";
55
import { cn } from "../../utils/cn";
66
import { buttonVariants } from "../button";
7+
import { KUMO_INPUT_VARIANTS, type KumoInputSize } from "../input/input";
78
import { SkeletonLine } from "../loader";
89
import { Field, type FieldErrorMatch } from "../field/field";
910
import {
1011
usePortalContainer,
1112
type PortalContainer,
1213
} from "../../utils/portal-provider";
1314

14-
/** Select variant definitions (currently empty, reserved for future additions). */
15+
/** Select variant definitions. */
1516
export const KUMO_SELECT_VARIANTS = {
16-
// Select currently has no variant options but structure is ready for future additions
17+
size: KUMO_INPUT_VARIANTS.size,
1718
} as const;
1819

19-
export const KUMO_SELECT_DEFAULT_VARIANTS = {} as const;
20+
export const KUMO_SELECT_DEFAULT_VARIANTS = {
21+
size: "base",
22+
} as const;
2023

2124
/**
2225
* Select component styling metadata for Figma plugin code generation
@@ -57,16 +60,40 @@ export const KUMO_SELECT_STYLING = {
5760
} as const;
5861

5962
// Derived types from KUMO_SELECT_VARIANTS
60-
export interface KumoSelectVariantsProps {}
63+
export type KumoSelectSize = keyof typeof KUMO_SELECT_VARIANTS.size;
6164

62-
export function selectVariants(_props: KumoSelectVariantsProps = {}) {
65+
export interface KumoSelectVariantsProps {
66+
/**
67+
* Size of the select trigger. Matches Input component sizes.
68+
* - `"xs"` — Extra small for compact UIs (h-5 / 20px)
69+
* - `"sm"` — Small for secondary fields (h-6.5 / 26px)
70+
* - `"base"` — Default size (h-9 / 36px)
71+
* - `"lg"` — Large for prominent fields (h-10 / 40px)
72+
* @default "base"
73+
*/
74+
size?: KumoSelectSize;
75+
}
76+
77+
export function selectVariants({
78+
size = KUMO_SELECT_DEFAULT_VARIANTS.size,
79+
}: KumoSelectVariantsProps = {}) {
6380
return cn(
64-
buttonVariants(),
81+
buttonVariants({ size }),
6582
"justify-between font-normal",
6683
"focus:opacity-100 focus-visible:ring-1 focus-visible:ring-kumo-hairline *:in-focus:opacity-100",
6784
);
6885
}
6986

87+
const triggerIconStyles: Record<
88+
KumoInputSize,
89+
{ iconSize: number; className: string }
90+
> = {
91+
xs: { iconSize: 12, className: "text-kumo-subtle" },
92+
sm: { iconSize: 14, className: "text-kumo-subtle" },
93+
base: { iconSize: 16, className: "text-kumo-subtle" },
94+
lg: { iconSize: 18, className: "text-kumo-subtle" },
95+
};
96+
7097
/**
7198
* Shape for items that carry extra metadata (disabled state, tooltip).
7299
* Plain `ReactNode` values are still supported for backward compatibility.
@@ -239,6 +266,8 @@ type SelectPropsGeneric<T, Multiple extends boolean | undefined = false> = Omit<
239266
export interface SelectProps {
240267
/** Additional CSS classes merged via `cn()`. */
241268
className?: string;
269+
/** Size of the select trigger. Matches Input component sizes. */
270+
size?: KumoSelectSize;
242271
/**
243272
* Label content for the select.
244273
* When provided, enables the Field wrapper with a visible label above the select.
@@ -311,6 +340,7 @@ export function Select<T, Multiple extends boolean | undefined = false>({
311340
hideLabel,
312341
placeholder,
313342
loading,
343+
size = KUMO_SELECT_DEFAULT_VARIANTS.size,
314344
labelTooltip,
315345
description,
316346
error,
@@ -365,9 +395,7 @@ export function Select<T, Multiple extends boolean | undefined = false>({
365395
>
366396
<SelectBase.Trigger
367397
className={cn(
368-
buttonVariants(),
369-
"justify-between font-normal",
370-
"focus:opacity-100 focus-visible:ring-1 focus-visible:ring-kumo-hairline *:in-focus:opacity-100",
398+
selectVariants({ size }),
371399
props.disabled && "cursor-not-allowed opacity-50",
372400
className,
373401
)}
@@ -379,13 +407,21 @@ export function Select<T, Multiple extends boolean | undefined = false>({
379407
) : (
380408
<SelectBase.Value
381409
placeholder={placeholder}
382-
className="min-w-0 truncate"
410+
className="min-w-0 truncate data-[placeholder]:text-kumo-placeholder"
383411
>
384412
{renderValue}
385413
</SelectBase.Value>
386414
)}
387-
<SelectBase.Icon className="flex shrink-0 items-center">
388-
<CaretUpDownIcon />
415+
<SelectBase.Icon
416+
className={cn(
417+
"flex shrink-0 items-center",
418+
triggerIconStyles[size].className,
419+
)}
420+
>
421+
<CaretUpDownIcon
422+
size={triggerIconStyles[size].iconSize}
423+
className="fill-current"
424+
/>
389425
</SelectBase.Icon>
390426
</SelectBase.Trigger>
391427
<SelectBase.Portal container={container}>

0 commit comments

Comments
 (0)