Skip to content

Commit 003128b

Browse files
feat(combobox): add size prop with xs, sm, base, lg variants (#165)
1 parent ffd35c6 commit 003128b

File tree

4 files changed

+260
-16
lines changed

4 files changed

+260
-16
lines changed

.changeset/combobox-size-prop.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(combobox): add size prop with xs, sm, base, lg variants matching Input component

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,3 +385,99 @@ export function ComboboxErrorDemo() {
385385
</div>
386386
);
387387
}
388+
389+
/** Demonstrates the different size variants: xs, sm, base, and lg. */
390+
export function ComboboxSizesDemo() {
391+
const [smValue, setSmValue] = useState<string | null>(null);
392+
const [baseValue, setBaseValue] = useState<string | null>(null);
393+
394+
return (
395+
<div className="flex flex-wrap items-center gap-4">
396+
<Combobox
397+
size="sm"
398+
value={smValue}
399+
onValueChange={(v) => setSmValue(v as string | null)}
400+
items={fruits.slice(0, 8)}
401+
>
402+
<Combobox.TriggerInput placeholder="Small (sm)" />
403+
<Combobox.Content>
404+
<Combobox.Empty />
405+
<Combobox.List>
406+
{(item: string) => (
407+
<Combobox.Item key={item} value={item}>
408+
{item}
409+
</Combobox.Item>
410+
)}
411+
</Combobox.List>
412+
</Combobox.Content>
413+
</Combobox>
414+
<Combobox
415+
size="base"
416+
value={baseValue}
417+
onValueChange={(v) => setBaseValue(v as string | null)}
418+
items={fruits.slice(0, 8)}
419+
>
420+
<Combobox.TriggerInput placeholder="Base (default)" />
421+
<Combobox.Content>
422+
<Combobox.Empty />
423+
<Combobox.List>
424+
{(item: string) => (
425+
<Combobox.Item key={item} value={item}>
426+
{item}
427+
</Combobox.Item>
428+
)}
429+
</Combobox.List>
430+
</Combobox.Content>
431+
</Combobox>
432+
</div>
433+
);
434+
}
435+
436+
/** Demonstrates size variants with TriggerValue (searchable inside). */
437+
export function ComboboxSizesSearchableInsideDemo() {
438+
const [smValue, setSmValue] = useState<Language>(languages[0]);
439+
const [baseValue, setBaseValue] = useState<Language>(languages[1]);
440+
441+
return (
442+
<div className="flex flex-wrap items-center gap-4">
443+
<Combobox
444+
size="sm"
445+
value={smValue}
446+
onValueChange={(v) => setSmValue(v as Language)}
447+
items={languages}
448+
>
449+
<Combobox.TriggerValue className="w-[160px]" />
450+
<Combobox.Content>
451+
<Combobox.Input placeholder="Search" />
452+
<Combobox.Empty />
453+
<Combobox.List>
454+
{(item: Language) => (
455+
<Combobox.Item key={item.value} value={item}>
456+
{item.emoji} {item.label}
457+
</Combobox.Item>
458+
)}
459+
</Combobox.List>
460+
</Combobox.Content>
461+
</Combobox>
462+
<Combobox
463+
size="base"
464+
value={baseValue}
465+
onValueChange={(v) => setBaseValue(v as Language)}
466+
items={languages}
467+
>
468+
<Combobox.TriggerValue className="w-[180px]" />
469+
<Combobox.Content>
470+
<Combobox.Input placeholder="Search" />
471+
<Combobox.Empty />
472+
<Combobox.List>
473+
{(item: Language) => (
474+
<Combobox.Item key={item.value} value={item}>
475+
{item.emoji} {item.label}
476+
</Combobox.Item>
477+
)}
478+
</Combobox.List>
479+
</Combobox.Content>
480+
</Combobox>
481+
</div>
482+
);
483+
}

packages/kumo-docs-astro/src/pages/components/combobox.astro

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import ComponentSection from "../../components/docs/ComponentSection.astro";
55
import ComponentExample from "../../components/docs/ComponentExample.astro";
66
import CodeBlock from "../../components/docs/CodeBlock.astro";
77
import PropsTable from "../../components/docs/PropsTable.astro";
8-
import { ComboboxDemo, ComboboxSearchableInsideDemo, ComboboxGroupedDemo, ComboboxMultipleDemo, ComboboxWithFieldDemo, ComboboxErrorDemo } from "../../components/demos/ComboboxDemo";
8+
import { ComboboxDemo, ComboboxSearchableInsideDemo, ComboboxGroupedDemo, ComboboxMultipleDemo, ComboboxWithFieldDemo, ComboboxErrorDemo, ComboboxSizesDemo, ComboboxSizesSearchableInsideDemo } from "../../components/demos/ComboboxDemo";
99
---
1010

1111
<DocLayout
@@ -59,6 +59,43 @@ const fruits = [
5959
<CodeBlock code={`import { Combobox } from "@cloudflare/kumo/components/combobox";`} lang="tsx" />
6060
</ComponentSection>
6161

62+
<ComponentSection>
63+
<Heading level={2}>Sizes</Heading>
64+
<p class="text-kumo-strong mb-4">The Combobox supports four size variants that match the Input component: <code class="text-kumo-default">xs</code>, <code class="text-kumo-default">sm</code>, <code class="text-kumo-default">base</code> (default), and <code class="text-kumo-default">lg</code>.</p>
65+
<ComponentExample code={`<Combobox size="sm" items={fruits} value={value} onValueChange={setValue}>
66+
<Combobox.TriggerInput placeholder="Select fruit" />
67+
<Combobox.Content>
68+
<Combobox.Empty />
69+
<Combobox.List>
70+
{(item) => (
71+
<Combobox.Item key={item} value={item}>
72+
{item}
73+
</Combobox.Item>
74+
)}
75+
</Combobox.List>
76+
</Combobox.Content>
77+
</Combobox>`}>
78+
<ComboboxSizesDemo client:load />
79+
</ComponentExample>
80+
<p class="text-kumo-strong mt-6 mb-4">Size also applies to <code class="text-kumo-default">TriggerValue</code> (searchable inside variant):</p>
81+
<ComponentExample code={`<Combobox size="sm" items={languages} value={value} onValueChange={setValue}>
82+
<Combobox.TriggerValue className="w-[160px]" />
83+
<Combobox.Content>
84+
<Combobox.Input placeholder="Search" />
85+
<Combobox.Empty />
86+
<Combobox.List>
87+
{(item) => (
88+
<Combobox.Item key={item.value} value={item}>
89+
{item.emoji} {item.label}
90+
</Combobox.Item>
91+
)}
92+
</Combobox.List>
93+
</Combobox.Content>
94+
</Combobox>`}>
95+
<ComboboxSizesSearchableInsideDemo client:load />
96+
</ComponentExample>
97+
</ComponentSection>
98+
6299
<ComponentSection>
63100
<Heading level={2}>Searchable Item (Inside)</Heading>
64101
<p class="text-kumo-strong mb-4">A searchable select component inside popup that allows users to filter and select.</p>

packages/kumo/src/components/combobox/combobox.tsx

Lines changed: 121 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,23 @@
11
import { Combobox as ComboboxBase } from "@base-ui/react/combobox";
22
import { CaretDownIcon, CheckIcon, XIcon } from "@phosphor-icons/react";
3-
import { Fragment, type PropsWithChildren, type ReactNode } from "react";
4-
import { inputVariants } from "../input/input";
3+
import {
4+
Fragment,
5+
createContext,
6+
useContext,
7+
type PropsWithChildren,
8+
type ReactNode,
9+
} from "react";
10+
import {
11+
inputVariants,
12+
KUMO_INPUT_VARIANTS,
13+
type KumoInputSize,
14+
} from "../input/input";
515
import { cn } from "../../utils/cn";
616
import { Field, type FieldErrorMatch } from "../field/field";
717

8-
/** Combobox input position variant definitions. */
18+
/** Combobox variant definitions. */
919
export const KUMO_COMBOBOX_VARIANTS = {
20+
size: KUMO_INPUT_VARIANTS.size,
1021
inputSide: {
1122
right: {
1223
classes: "",
@@ -20,14 +31,28 @@ export const KUMO_COMBOBOX_VARIANTS = {
2031
} as const;
2132

2233
export const KUMO_COMBOBOX_DEFAULT_VARIANTS = {
34+
size: "base",
2335
inputSide: "right",
2436
} as const;
2537

38+
// Context to pass size down to sub-components
39+
const ComboboxSizeContext = createContext<KumoInputSize>("base");
40+
2641
// Derived types from KUMO_COMBOBOX_VARIANTS
42+
export type KumoComboboxSize = keyof typeof KUMO_COMBOBOX_VARIANTS.size;
2743
export type KumoComboboxInputSide =
2844
keyof typeof KUMO_COMBOBOX_VARIANTS.inputSide;
2945

3046
export interface KumoComboboxVariantsProps {
47+
/**
48+
* Size of the combobox trigger. Matches Input component sizes.
49+
* - `"xs"` — Extra small for compact UIs (h-5 / 20px)
50+
* - `"sm"` — Small for secondary fields (h-6.5 / 26px)
51+
* - `"base"` — Default size (h-9 / 36px)
52+
* - `"lg"` — Large for prominent fields (h-10 / 40px)
53+
* @default "base"
54+
*/
55+
size?: KumoComboboxSize;
3156
/**
3257
* Position of the text input relative to chips in multi-select mode.
3358
* - `"right"` — Input inline to the right of chips
@@ -45,6 +70,7 @@ export function comboboxVariants({
4570

4671
// Legacy type alias for backwards compatibility
4772
export type ComboboxInputSide = KumoComboboxInputSide;
73+
export type ComboboxSize = KumoComboboxSize;
4874

4975
export type ComboboxRootProps<
5076
Value = unknown,
@@ -116,16 +142,20 @@ function Root<Value, Multiple extends boolean | undefined = false>({
116142
description,
117143
error,
118144
children,
145+
size = "base",
119146
...props
120147
}: ComboboxBase.Root.Props<Value, Multiple> & {
121148
label?: ReactNode;
122149
required?: boolean;
123150
labelTooltip?: ReactNode;
124151
description?: ReactNode;
125152
error?: string | { message: ReactNode; match: FieldErrorMatch };
153+
size?: KumoComboboxSize;
126154
}) {
127155
const comboboxControl = (
128-
<ComboboxBase.Root {...props}>{children}</ComboboxBase.Root>
156+
<ComboboxSizeContext.Provider value={size}>
157+
<ComboboxBase.Root {...props}>{children}</ComboboxBase.Root>
158+
</ComboboxSizeContext.Provider>
129159
);
130160

131161
// Render with Field wrapper if label, description, or error are provided
@@ -192,43 +222,110 @@ function Content({
192222
);
193223
}
194224

225+
// Size-dependent styles for TriggerValue icon
226+
const triggerValueIconStyles: Record<
227+
KumoComboboxSize,
228+
{ padding: string; iconSize: number; iconRight: string }
229+
> = {
230+
xs: { padding: "pr-5", iconSize: 12, iconRight: "right-1" },
231+
sm: { padding: "pr-6", iconSize: 14, iconRight: "right-1.5" },
232+
base: { padding: "pr-8", iconSize: 16, iconRight: "right-2" },
233+
lg: { padding: "pr-10", iconSize: 18, iconRight: "right-3" },
234+
};
235+
195236
function TriggerValue({
196237
className,
197238
...props
198239
}: ComboboxBase.Value.Props & { className?: string }) {
240+
const size = useContext(ComboboxSizeContext);
241+
const iconStyles = triggerValueIconStyles[size];
242+
199243
return (
200244
<ComboboxBase.Trigger
201245
className={cn(
202-
inputVariants(),
203-
"relative flex items-center pr-8",
246+
inputVariants({ size }),
247+
"relative flex items-center",
248+
iconStyles.padding,
204249
className,
205250
)}
206251
>
207252
<ComboboxBase.Value>{props.children}</ComboboxBase.Value>
208-
<ComboboxBase.Icon className="absolute top-1/2 right-2 -translate-y-1/2">
209-
<CaretDownIcon className="fill-kumo-ring" />
253+
<ComboboxBase.Icon
254+
className={cn(
255+
"absolute top-1/2 -translate-y-1/2",
256+
iconStyles.iconRight,
257+
)}
258+
>
259+
<CaretDownIcon size={iconStyles.iconSize} className="fill-kumo-ring" />
210260
</ComboboxBase.Icon>
211261
</ComboboxBase.Trigger>
212262
);
213263
}
214264

265+
// Size-dependent styles for TriggerInput icons
266+
const triggerInputIconStyles: Record<
267+
KumoComboboxSize,
268+
{ padding: string; iconSize: number; clearRight: string; caretRight: string }
269+
> = {
270+
xs: {
271+
padding: "pr-7",
272+
iconSize: 12,
273+
clearRight: "right-5",
274+
caretRight: "right-1",
275+
},
276+
sm: {
277+
padding: "pr-9",
278+
iconSize: 14,
279+
clearRight: "right-6",
280+
caretRight: "right-1.5",
281+
},
282+
base: {
283+
padding: "pr-12",
284+
iconSize: 16,
285+
clearRight: "right-8",
286+
caretRight: "right-2",
287+
},
288+
lg: {
289+
padding: "pr-14",
290+
iconSize: 18,
291+
clearRight: "right-9",
292+
caretRight: "right-3",
293+
},
294+
};
295+
215296
function TriggerInput(props: ComboboxBase.Input.Props) {
297+
const size = useContext(ComboboxSizeContext);
298+
const iconStyles = triggerInputIconStyles[size];
299+
216300
return (
217301
<div
218302
className={cn("relative inline-block w-full max-w-xs", props.className)}
219303
>
220304
<ComboboxBase.Input
221305
{...props}
222-
className={cn(inputVariants(), "w-full pr-12")}
306+
className={cn(inputVariants({ size }), "w-full", iconStyles.padding)}
223307
/>
224308

225-
<ComboboxBase.Clear className="absolute top-1/2 right-8 flex -translate-y-1/2 cursor-pointer bg-transparent p-0">
226-
<XIcon />
309+
<ComboboxBase.Clear
310+
className={cn(
311+
"absolute top-1/2 flex -translate-y-1/2 cursor-pointer bg-transparent p-0",
312+
iconStyles.clearRight,
313+
)}
314+
>
315+
<XIcon size={iconStyles.iconSize} />
227316
</ComboboxBase.Clear>
228317

229318
<ComboboxBase.Trigger className="p-0">
230-
<ComboboxBase.Icon className="absolute top-1/2 right-2 flex -translate-y-1/2 cursor-pointer">
231-
<CaretDownIcon className="fill-kumo-ring" />
319+
<ComboboxBase.Icon
320+
className={cn(
321+
"absolute top-1/2 flex -translate-y-1/2 cursor-pointer",
322+
iconStyles.caretRight,
323+
)}
324+
>
325+
<CaretDownIcon
326+
size={iconStyles.iconSize}
327+
className="fill-kumo-ring"
328+
/>
232329
</ComboboxBase.Icon>
233330
</ComboboxBase.Trigger>
234331
</div>
@@ -324,6 +421,14 @@ function Chip(props: ComboboxBase.Chip.Props) {
324421
);
325422
}
326423

424+
// Map size to min-height class for TriggerMultipleWithInput
425+
const sizeToMinHeight: Record<KumoComboboxSize, string> = {
426+
xs: "min-h-5",
427+
sm: "min-h-6.5",
428+
base: "min-h-9",
429+
lg: "min-h-10",
430+
};
431+
327432
function TriggerMultipleWithInput<ValueType>({
328433
placeholder,
329434
renderItem,
@@ -338,14 +443,15 @@ function TriggerMultipleWithInput<ValueType>({
338443
/** Optional controlled value for rendering chips (use when pre-selecting values) */
339444
value?: ValueType[];
340445
}) {
446+
const size = useContext(ComboboxSizeContext);
341447
// Determine which value to use for rendering chips
342448
const chipsToRender = controlledValue;
343449

344450
return (
345451
<ComboboxBase.Chips
346452
className={cn(
347-
inputVariants(),
348-
cn("flex flex-col", "gap-1 p-1", "min-h-9", "h-auto"),
453+
inputVariants({ size }),
454+
cn("flex flex-col", "gap-1 p-1", sizeToMinHeight[size], "h-auto"),
349455
className,
350456
)}
351457
>

0 commit comments

Comments
 (0)