Add v2 Select and TextField UI kit components

The compliance-portal Subprocessors toolbar needs a dropdown filter and a
search input, which the v2 kit did not provide. Add Select (styled over
Base UI's headless select) and TextField (over Base UI's input) following
the v2 conventions: Base UI primitives, tailwind-variants/lite slots,
flat folders, bundle-safe variants, and paired skeletons. Both map the
Figma semantic colors to the Radix sand/gold token scales.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-30 14:24:00 +02:00
parent eb9274f2cd
commit 502fb08054
11 changed files with 515 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { useState } from "react";
import { Select } from "./Select";
import { SelectItem } from "./SelectItem";
import { SelectPopup } from "./SelectPopup";
import { SelectSkeleton } from "./SelectSkeleton";
import { SelectTrigger } from "./SelectTrigger";
export default {
title: "v2/Select",
component: Select,
};
const fruits: Record<string, string> = {
apple: "Apple",
banana: "Banana",
cherry: "Cherry",
};
export function Default() {
return (
<div className="w-40">
<Select>
<SelectTrigger placeholder="Select a fruit">
{(value: string | null) => (value ? fruits[value] : null)}
</SelectTrigger>
<SelectPopup>
{Object.entries(fruits).map(([value, label]) => (
<SelectItem key={value} value={value}>{label}</SelectItem>
))}
</SelectPopup>
</Select>
</div>
);
}
export function Controlled() {
const [value, setValue] = useState<string | null>(null);
return (
<div className="flex flex-col gap-3">
<div className="w-40">
<Select value={value} onValueChange={setValue}>
<SelectTrigger placeholder="All categories">
{(current: string | null) => (current ? fruits[current] : null)}
</SelectTrigger>
<SelectPopup>
{Object.entries(fruits).map(([key, label]) => (
<SelectItem key={key} value={key}>{label}</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<span className="text-2 text-sand-11">Selected: {value ?? "none"}</span>
</div>
);
}
export function Skeleton() {
return <SelectSkeleton />;
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Select as BaseSelect } from "@base-ui/react/select";
// Root of the select (Radix "Select"). Controlled the same way as Base UI's
// Select: `value` / `onValueChange`, or left uncontrolled. Generic over the
// option value type. See contrib/claude/ui.md.
export type SelectProps<Value = string, Multiple extends boolean | undefined = false>
= BaseSelect.Root.Props<Value, Multiple>;
export function Select<Value = string, Multiple extends boolean | undefined = false>(
props: SelectProps<Value, Multiple>,
) {
return <BaseSelect.Root {...props} />;
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Select as BaseSelect } from "@base-ui/react/select";
import { CheckIcon } from "@phosphor-icons/react";
import type { ComponentProps } from "react";
import { selectItem } from "./variants";
export type SelectItemProps
= & Omit<ComponentProps<typeof BaseSelect.Item>, "className">
& { className?: string };
// A single option. Shows a trailing check when selected. Inherits highlight
// styling from the popup surface.
export function SelectItem(props: SelectItemProps) {
const { className, children, ...rest } = props;
const { item, label, indicator } = selectItem();
return (
<BaseSelect.Item className={item({ className })} {...rest}>
<span className={label()}>
<BaseSelect.ItemText>{children}</BaseSelect.ItemText>
</span>
<BaseSelect.ItemIndicator className={indicator()}>
<CheckIcon />
</BaseSelect.ItemIndicator>
</BaseSelect.Item>
);
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Select as BaseSelect } from "@base-ui/react/select";
import type { ComponentProps } from "react";
import { selectPopup } from "./variants";
export type SelectPopupProps
= & Omit<ComponentProps<typeof BaseSelect.Popup>, "className">
& {
className?: string;
// Positioner placement passthrough.
side?: ComponentProps<typeof BaseSelect.Positioner>["side"];
align?: ComponentProps<typeof BaseSelect.Positioner>["align"];
sideOffset?: ComponentProps<typeof BaseSelect.Positioner>["sideOffset"];
};
// Portal + positioner + styled popup holding the select items.
export function SelectPopup(props: SelectPopupProps) {
const {
className, children,
side = "bottom", align = "start", sideOffset = 4,
...popupProps
} = props;
return (
<BaseSelect.Portal>
<BaseSelect.Positioner side={side} align={align} sideOffset={sideOffset}>
<BaseSelect.Popup className={selectPopup({ className })} {...popupProps}>
{children}
</BaseSelect.Popup>
</BaseSelect.Positioner>
</BaseSelect.Portal>
);
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants/lite";
import { selectSkeleton } from "./variants";
export type SelectSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof selectSkeleton>;
// Loading placeholder paired with the Select trigger: a pulse block matching its
// size and radius.
export function SelectSkeleton(props: SelectSkeletonProps) {
const { size, className, ...rest } = props;
return <span className={selectSkeleton({ size, className })} {...rest} aria-hidden />;
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Select as BaseSelect } from "@base-ui/react/select";
import { CaretDownIcon } from "@phosphor-icons/react";
import type { ComponentProps, ReactNode } from "react";
import { selectTrigger } from "./variants";
export type SelectTriggerProps
= & Omit<ComponentProps<typeof BaseSelect.Trigger>, "className" | "children">
& {
className?: string;
size?: 1 | 2;
// Shown when no value is selected.
placeholder?: ReactNode;
// Renders the selected value; pass a function to map a value to its label.
children?: BaseSelect.Value.Props["children"];
};
// The bordered surface button that opens the popup, showing the selected value
// (or placeholder) and a trailing chevron.
export function SelectTrigger(props: SelectTriggerProps) {
const { className, size, placeholder, children, ...rest } = props;
const { trigger, value, icon } = selectTrigger({ size });
return (
<BaseSelect.Trigger className={trigger({ className })} {...rest}>
<BaseSelect.Value className={value()} placeholder={placeholder}>
{children}
</BaseSelect.Value>
<BaseSelect.Icon className={icon()}>
<CaretDownIcon />
</BaseSelect.Icon>
</BaseSelect.Trigger>
);
}

View File

@@ -0,0 +1,72 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Select (Radix "Select" over Base UI's Select). A bordered surface trigger on
// the neutral background, with an accent-highlighted popup of items.
export const selectTrigger = tv({
slots: {
trigger: [
"flex w-full items-center justify-between gap-2 rounded-2 border border-sand-a7 bg-sand-1 text-2 text-sand-12",
"cursor-pointer outline-none transition-colors hover:bg-sand-2",
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-placeholder:text-sand-a10",
],
value: "min-w-0 flex-1 truncate text-left",
icon: "flex size-4 shrink-0 items-center justify-center text-sand-a10 [&_svg]:size-4",
},
variants: {
size: {
1: { trigger: "h-7 px-2" },
2: { trigger: "h-8 px-3" },
},
},
defaultVariants: {
size: 2,
},
});
export const selectPopup = tv({
base: [
"max-h-(--available-height) min-w-(--anchor-width) origin-(--transform-origin) overflow-y-auto rounded-3 bg-sand-1 p-1 shadow-3 outline-none",
"transition-[scale,opacity] duration-150 ease-out data-starting-style:scale-95 data-starting-style:opacity-0",
"data-ending-style:scale-95 data-ending-style:opacity-0",
],
});
export const selectItem = tv({
slots: {
item: [
"flex h-8 cursor-pointer items-center justify-between gap-2 rounded-2 px-3 text-2 text-sand-12 outline-none select-none",
"data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:bg-sand-3",
],
label: "min-w-0 flex-1 truncate",
indicator: "flex size-4 shrink-0 items-center justify-center text-sand-12 [&_svg]:size-4",
},
});
export const selectSkeleton = tv({
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
variants: {
size: {
1: "h-7 w-40",
2: "h-8 w-40",
},
},
defaultVariants: {
size: 2,
},
});

View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { MagnifyingGlassIcon } from "@phosphor-icons/react";
import { useState } from "react";
import { TextField } from "./TextField";
import { TextFieldSkeleton } from "./TextFieldSkeleton";
export default {
title: "v2/form/TextField",
component: TextField,
};
export function Default() {
return (
<div className="w-60">
<TextField placeholder="Search..." />
</div>
);
}
export function WithIcon() {
return (
<div className="w-60">
<TextField icon={<MagnifyingGlassIcon />} placeholder="Search..." />
</div>
);
}
export function Controlled() {
const [value, setValue] = useState("");
return (
<div className="flex flex-col gap-3">
<div className="w-60">
<TextField value={value} onValueChange={setValue} placeholder="Type here" />
</div>
<span className="text-2 text-sand-11">Value: {value || "(empty)"}</span>
</div>
);
}
export function Skeleton() {
return <TextFieldSkeleton />;
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Input } from "@base-ui/react/input";
import type { ComponentProps, ReactNode } from "react";
import { textField } from "./variants";
export type TextFieldProps
= & Omit<ComponentProps<typeof Input>, "className">
& {
// Applied to the bordered container (the top-level element).
className?: string;
size?: 1 | 2;
// Leading icon slot (e.g. a MagnifyingGlassIcon for search).
icon?: ReactNode;
};
// Single-line text input on a bordered surface. The container is the top-level
// node; all native input props spread onto the inner control. Controlled with
// Base UI's `value` / `onValueChange` (or native `value` / `onChange`).
export function TextField(props: TextFieldProps) {
const { className, size, icon, ...inputProps } = props;
const { root, icon: iconSlot, input } = textField({ size });
return (
<div className={root({ className })}>
{icon != null && <span className={iconSlot()}>{icon}</span>}
<Input className={input()} {...inputProps} />
</div>
);
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import type { ComponentProps } from "react";
import type { VariantProps } from "tailwind-variants/lite";
import { textFieldSkeleton } from "./variants";
export type TextFieldSkeletonProps = Omit<ComponentProps<"span">, "children"> & VariantProps<typeof textFieldSkeleton>;
// Loading placeholder paired with TextField: a pulse block matching its size and
// radius.
export function TextFieldSkeleton(props: TextFieldSkeletonProps) {
const { size, className, ...rest } = props;
return <span className={textFieldSkeleton({ size, className })} {...rest} aria-hidden />;
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { tv } from "tailwind-variants/lite";
// Text input (Radix "Text Field" over Base UI's Input). A bordered surface on
// the neutral background with an optional leading icon slot.
export const textField = tv({
slots: {
root: [
"flex items-center gap-2 rounded-2 border border-sand-a5 bg-sand-1 text-2 text-sand-12 transition-colors",
"focus-within:ring-2 focus-within:ring-sand-8 focus-within:ring-offset-1 focus-within:ring-offset-sand-1",
"has-[input:disabled]:pointer-events-none has-[input:disabled]:opacity-50",
],
icon: "flex size-4 shrink-0 items-center justify-center text-sand-a9 [&_svg]:size-4",
input: "min-w-0 flex-1 bg-transparent text-sand-12 outline-none placeholder:text-sand-a9",
},
variants: {
size: {
1: { root: "h-7 px-2" },
2: { root: "h-8 px-2" },
},
},
defaultVariants: {
size: 2,
},
});
export const textFieldSkeleton = tv({
base: "inline-block animate-pulse rounded-2 bg-sand-3 align-middle",
variants: {
size: {
1: "h-7 w-60",
2: "h-8 w-60",
},
},
defaultVariants: {
size: 2,
},
});