From 910b266cd1aa0b56babdbbed2b7e58cfacdaf547 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 02:20:05 +0000 Subject: [PATCH 1/3] feat(design-library): add form input and navigation components Add 7 Tier 1 design library components ported from the platform repo: - Input + Textarea (single-line and multi-line text fields with labels, icons, error/helper text) - Toggle (on/off switch with label and helper text) - Checkbox (tri-state Radix checkbox with label support) - Radio (generic RadioGroup + Radio with Radix primitives) - Tabs (compound Radix tabs with themed styling) - SegmentControl (pill-shaped segmented control using Button) - Slider (single-thumb and range mode with Radix primitives) Convention compliance applied during port: - Removed all 'use client' directives (Vite SPA) - Converted forwardRef to ref-as-prop (React 19) - Function declarations for all components - data-slot attributes on all root elements - Relative .js imports for NodeNext resolution - Named exports only (no default exports) - Exported variant functions and helper utilities for testing Part of LUM-1543 Co-Authored-By: ashlee@vellum.ai --- packages/design-library/bun.lock | 24 ++ packages/design-library/package.json | 4 + .../src/components/checkbox.tsx | 139 ++++++++ .../design-library/src/components/input.tsx | 299 ++++++++++++++++++ .../design-library/src/components/radio.tsx | 177 +++++++++++ .../src/components/segment-control.tsx | 96 ++++++ .../design-library/src/components/slider.tsx | 161 ++++++++++ .../design-library/src/components/tabs.tsx | 102 ++++++ .../design-library/src/components/toggle.tsx | 127 ++++++++ packages/design-library/src/index.ts | 50 +++ 10 files changed, 1179 insertions(+) create mode 100644 packages/design-library/src/components/checkbox.tsx create mode 100644 packages/design-library/src/components/input.tsx create mode 100644 packages/design-library/src/components/radio.tsx create mode 100644 packages/design-library/src/components/segment-control.tsx create mode 100644 packages/design-library/src/components/slider.tsx create mode 100644 packages/design-library/src/components/tabs.tsx create mode 100644 packages/design-library/src/components/toggle.tsx diff --git a/packages/design-library/bun.lock b/packages/design-library/bun.lock index 258385ed3f..7b5e52302b 100644 --- a/packages/design-library/bun.lock +++ b/packages/design-library/bun.lock @@ -5,8 +5,12 @@ "": { "name": "@vellum/design-library", "dependencies": { + "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-tabs": "1.1.13", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.16.0", @@ -233,14 +237,22 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], @@ -259,8 +271,16 @@ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], @@ -271,6 +291,8 @@ "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], @@ -675,6 +697,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], diff --git a/packages/design-library/package.json b/packages/design-library/package.json index 89a64943ba..0e314ed371 100644 --- a/packages/design-library/package.json +++ b/packages/design-library/package.json @@ -36,8 +36,12 @@ "vite": "8.0.11" }, "dependencies": { + "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-tabs": "1.1.13", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.16.0", diff --git a/packages/design-library/src/components/checkbox.tsx b/packages/design-library/src/components/checkbox.tsx new file mode 100644 index 0000000000..021fdc8300 --- /dev/null +++ b/packages/design-library/src/components/checkbox.tsx @@ -0,0 +1,139 @@ +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check, Minus } from "lucide-react"; +import { type ComponentProps, type ReactNode, useId } from "react"; + +import { Typography } from "./typography.js"; +import { cn } from "../utils/cn.js"; + +export type CheckboxState = boolean | "indeterminate"; + +export interface CheckboxProps + extends Omit, "checked" | "onCheckedChange"> { + checked: CheckboxState; + onCheckedChange?: (checked: CheckboxState) => void; + label?: ReactNode; + helperText?: ReactNode; + "aria-label"?: string; +} + +/** + * Checkbox wrapping `@radix-ui/react-checkbox`. Inherits keyboard handling, + * focus management, and a11y attributes (`aria-checked`, `data-state`). + * + * - Pass `checked` / `onCheckedChange` for controlled use. + * - Pass `"indeterminate"` as `checked` to render the tri-state dash. + * - Pass `label` for a clickable label, `helperText` for secondary copy. + */ +function Checkbox({ + checked, + onCheckedChange, + label, + helperText, + disabled = false, + id, + name, + className, + ref, + "aria-label": ariaLabel, + ...rest +}: CheckboxProps) { + const reactId = useId(); + const resolvedId = id ?? reactId; + const labelId = label ? `${resolvedId}-label` : undefined; + const helperTextId = helperText ? `${resolvedId}-helper` : undefined; + + const isIndeterminate = checked === "indeterminate"; + + const rootClasses = cn( + "inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[4px]", + "border transition-colors outline-none cursor-pointer", + "focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-0", + "bg-[var(--surface-lift)] border-[var(--border-base)]", + "data-[state=checked]:bg-[var(--system-positive-strong)] data-[state=checked]:border-transparent", + "data-[state=indeterminate]:bg-[var(--system-positive-strong)] data-[state=indeterminate]:border-transparent", + "disabled:cursor-not-allowed disabled:bg-[var(--surface-overlay)]", + "disabled:data-[state=checked]:bg-[var(--surface-overlay)]", + "disabled:data-[state=indeterminate]:bg-[var(--surface-overlay)]", + "disabled:border-[var(--border-base)]", + ); + + const iconClasses = cn( + "h-3 w-3", + disabled + ? "text-[color:var(--content-disabled)]" + : "text-[color:var(--aux-white)]", + ); + + const checkbox = ( + + + {isIndeterminate ? ( + + + ); + + if (!label && !helperText) { + return {checkbox}; + } + + return ( +
+ {checkbox} +
+ {label ? ( + + {label} + + ) : null} + {helperText ? ( + + {helperText} + + ) : null} +
+
+ ); +} + +export { Checkbox }; diff --git a/packages/design-library/src/components/input.tsx b/packages/design-library/src/components/input.tsx new file mode 100644 index 0000000000..633a4f11be --- /dev/null +++ b/packages/design-library/src/components/input.tsx @@ -0,0 +1,299 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { + type ComponentProps, + type ReactNode, + useId, +} from "react"; + +import { Typography } from "./typography.js"; +import { cn } from "../utils/cn.js"; + +/** + * Shared text-input primitive backing both `Input` (single-line) and + * `Textarea` (multi-line). Visual parity with the macOS design system input: + * an `--surface-active` fill, a `--border-base` hairline that shifts to + * `--border-element` on focus (or `--system-negative-strong` on error), and + * `--content-default` text with a `--content-tertiary` placeholder. + * + * All colors resolve via CSS variable tokens, so the field inherits the + * app's light/dark theming automatically. + */ +const fieldVariants = cva( + [ + "block w-full rounded-md border bg-[var(--field-bg)]", + "text-body-medium-lighter text-[var(--content-default)]", + "placeholder:text-[var(--content-tertiary)]", + "transition-[border-color,background-color] duration-150 ease-out", + "outline-none", + "disabled:cursor-not-allowed disabled:opacity-60", + ].join(" "), + { + variants: { + invalid: { + true: [ + "border-[var(--system-negative-strong)]", + "focus-visible:border-[var(--system-negative-strong)]", + ].join(" "), + false: [ + "border-[var(--field-border)]", + "focus-visible:border-[var(--border-active)]", + ].join(" "), + }, + density: { + input: "h-9 px-3 py-1.5", + textarea: "min-h-[72px] px-3 py-2 resize-y", + }, + hasLeftIcon: { true: "", false: "" }, + hasRightIcon: { true: "", false: "" }, + }, + compoundVariants: [ + { density: "input", hasLeftIcon: true, class: "pl-9" }, + { density: "input", hasRightIcon: true, class: "pr-9" }, + ], + defaultVariants: { + invalid: false, + density: "input", + hasLeftIcon: false, + hasRightIcon: false, + }, + }, +); + +type FieldVariantProps = VariantProps; + +interface FieldWrapperProps { + readonly id: string; + readonly label?: ReactNode; + readonly helperText?: ReactNode; + readonly errorText?: ReactNode; + readonly fullWidth: boolean; + readonly disabled: boolean; + readonly className?: string; + readonly children: ReactNode; +} + +function FieldWrapper({ + id, + label, + helperText, + errorText, + fullWidth, + disabled, + className, + children, +}: FieldWrapperProps) { + const descriptionId = errorText + ? `${id}-error` + : helperText + ? `${id}-helper` + : undefined; + + return ( +
+ {label ? ( + + {label} + + ) : null} + {children} + {errorText ? ( + + {errorText} + + ) : helperText ? ( + + {helperText} + + ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Input (single-line) +// --------------------------------------------------------------------------- + +export interface InputProps + extends Omit, "size"> { + label?: ReactNode; + helperText?: ReactNode; + errorText?: ReactNode; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + fullWidth?: boolean; + wrapperClassName?: string; +} + +function Input({ + label, + helperText, + errorText, + leftIcon, + rightIcon, + fullWidth = false, + wrapperClassName, + className, + id, + disabled, + ref, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, + ...rest +}: InputProps) { + const reactId = useId(); + const inputId = id ?? `input-${reactId}`; + const isInvalid = errorText != null || ariaInvalid === true; + const describedBy = errorText + ? `${inputId}-error` + : helperText + ? `${inputId}-helper` + : undefined; + + return ( + +
+ {leftIcon ? ( + + {leftIcon} + + ) : null} + + {rightIcon ? ( + + {rightIcon} + + ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Textarea (multi-line) +// --------------------------------------------------------------------------- + +export interface TextareaProps extends ComponentProps<"textarea"> { + label?: ReactNode; + helperText?: ReactNode; + errorText?: ReactNode; + fullWidth?: boolean; + wrapperClassName?: string; +} + +function Textarea({ + label, + helperText, + errorText, + fullWidth = false, + wrapperClassName, + className, + id, + disabled, + ref, + "aria-invalid": ariaInvalid, + "aria-describedby": ariaDescribedBy, + ...rest +}: TextareaProps) { + const reactId = useId(); + const textareaId = id ?? `textarea-${reactId}`; + const isInvalid = errorText != null || ariaInvalid === true; + const describedBy = errorText + ? `${textareaId}-error` + : helperText + ? `${textareaId}-helper` + : undefined; + + return ( + +