diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 869531152ab..39ad8185993 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -1,6 +1,8 @@ { "$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/config-schema.json", "entry": [ + // promptwise: run by hand to sync BoardUI from a bebraai checkout. + "packages/studio/scripts/sync-boardui.mjs", // IconButton, Tabs and cn are exported before their first consumer exists. "packages/studio/src/components/ui/index.ts", "packages/producer/src/**/*.test.ts", @@ -83,6 +85,8 @@ ], "ignorePatterns": [ "docs/**", + // promptwise: BoardUI, copied in whole by packages/studio/scripts/sync-boardui.mjs. + "packages/studio/src/boardui/**", // Chrome browser binaries downloaded by puppeteer — not project source. "chrome/**", // Standalone spike scripts and benchmark runners used during R&D. @@ -429,6 +433,9 @@ }, ], "ignoreDependencies": [ + // promptwise: used by src/boardui, which is ignored above as synced code. + "react-aria-components", + "tailwind-merge", // Runtime/dynamic deps not visible to static analysis: tsup `external`, // dynamic require() resolution, peer/static-file consumption in tests, // and bun-hoisted workspace devDeps (e.g. happy-dom in root package.json diff --git a/.gitattributes b/.gitattributes index ecc0a80738c..f0f371ced44 100644 --- a/.gitattributes +++ b/.gitattributes @@ -55,3 +55,6 @@ registry/**/*.html linguist-vendored examples/docs-reference-project/**/*.wav filter=lfs diff=lfs merge=lfs -text packages/cli/src/cloud/_gen/** linguist-generated=true + +# promptwise: BoardUI, copied in whole by packages/studio/scripts/sync-boardui.mjs. +packages/studio/src/boardui/** linguist-generated=true diff --git a/.oxlintrc.json b/.oxlintrc.json index ef54d6835bd..8179458adf6 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -16,6 +16,7 @@ "registry/blocks/{glass-shard-title,code-slice-hero,cuboid-carousel,frost-sequence-camera-orbit,orbit-card}/**/*.{js,mjs}", "registry/blocks/frost-sequence-camera-orbit/source/**", "chrome/", + "packages/studio/src/boardui/", "packages/cli/src/media-use/lib/svg-sanitize.mjs" ], "overrides": [ diff --git a/.prettierignore b/.prettierignore index ef105989a14..6fa9db74dbe 100644 --- a/.prettierignore +++ b/.prettierignore @@ -56,3 +56,6 @@ skills/media-use/SKILL.md skills/figma/SKILL.md skills/slideshow/SKILL.md skills/remotion-to-hyperframes/SKILL.md + +# promptwise: BoardUI, copied in whole by packages/studio/scripts/sync-boardui.mjs. +packages/studio/src/boardui/ diff --git a/bun.lock b/bun.lock index 0bb2896b160..5a616f7c5b6 100644 --- a/bun.lock +++ b/bun.lock @@ -382,6 +382,8 @@ "dockview-react": "8.3.1", "gsap": "^3.13.0", "mediabunny": "^1.45.3", + "react-aria-components": "^1.22.1", + "tailwind-merge": "^3.7.0", }, "devDependencies": { "@hyperframes/producer": "workspace:*", @@ -846,6 +848,12 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@internationalized/date": ["@internationalized/date@3.12.4", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ=="], + + "@internationalized/number": ["@internationalized/number@3.6.9", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-GgWy2caO/+hV9sT/oCTWGHvbjmJMPT9kkXzoKRLnkEq7adghhR7UprhvY9sXrKfrp3NcDfhF6QQHXR1KfQbB8g=="], + + "@internationalized/string": ["@internationalized/string@3.2.10", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -1080,6 +1088,8 @@ "@puppeteer/browsers": ["@puppeteer/browsers@3.2.2", "", { "dependencies": { "modern-tar": "^0.8.4", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg=="], + "@react-types/shared": ["@react-types/shared@3.37.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-VNlZuifu4tHdxWPfHvoMfKsHA9kVTCGkduv5vKVTdinm8wHFJNhyVB5RzIMiRFj0q3RKoCbP68u2zaoBSiFueg=="], + "@remixicon/react": ["@remixicon/react@4.9.0", "", { "peerDependencies": { "react": ">=18.2.0" } }, "sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], @@ -1332,6 +1342,8 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "array-ify": ["array-ify@1.0.0", "", {}, "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng=="], "arrify": ["arrify@2.0.1", "", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="], @@ -1420,10 +1432,14 @@ "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="], + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cn": ["cn@0.2.4", "", { "bin": { "cn": "bin/cn.mjs" } }, "sha512-SzQvoh5FwizWtQg9+JueKdjWhlfCZMdu5DqNHm9q1wUlRVmKb9WXlepR9bTTbPwzEOyX1ujJ6lcCppmn3bNUCg=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1966,10 +1982,16 @@ "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react-aria": ["react-aria@3.53.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "aria-hidden": "^1.2.3", "clsx": "^2.0.0", "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-dikisyOjJd3D2fFrIDWmVWzqiLz5B86GzZv1zxs7xI4dVc0JIz/1Wa52I20gYvVL2WXeFXlygYsle/QcOMhtcg=="], + + "react-aria-components": ["react-aria-components@1.22.1", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "client-only": "^0.0.1", "react-aria": "3.53.1", "react-stately": "3.51.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-UTHaYHOaM71bOCjGPF50DcLywlrwAodE63IP64LB4kO+OizatQEL8TesVEOM8+TkBeSgHoflJ45388IlJfa7aw=="], + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "react-stately": ["react-stately@3.51.0", "", { "dependencies": { "@internationalized/date": "^3.12.4", "@internationalized/number": "^3.6.9", "@internationalized/string": "^3.2.10", "@react-types/shared": "^3.37.0", "@swc/helpers": "^0.5.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, "sha512-YJcmkAXu2XOQP97hO/qz/cC3qv9kni3oVhwBlxWr358BHePJUpY15XwL3YSRMKxYt4EkslPqAz11JRirMfANGw=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], @@ -2070,6 +2092,8 @@ "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], + "tailwind-merge": ["tailwind-merge@3.7.0", "", {}, "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index ff8aeb95599..2847dc2e012 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -114,7 +114,9 @@ "cn": "0.2.4", "dockview-react": "8.3.1", "gsap": "^3.13.0", - "mediabunny": "^1.45.3" + "mediabunny": "^1.45.3", + "react-aria-components": "^1.22.1", + "tailwind-merge": "^3.7.0" }, "devDependencies": { "@hyperframes/producer": "workspace:*", diff --git a/packages/studio/scripts/sync-boardui.mjs b/packages/studio/scripts/sync-boardui.mjs new file mode 100644 index 00000000000..6c51e5b9644 --- /dev/null +++ b/packages/studio/scripts/sync-boardui.mjs @@ -0,0 +1,104 @@ +#!/usr/bin/env node +// Copies promptwise's BoardUI components into src/boardui, the only place Studio's UI comes from. +// Usage: node packages/studio/scripts/sync-boardui.mjs +import { + cpSync, + existsSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const web = resolve(process.argv[2] ?? "", "apps/web"); +if (!existsSync(join(web, "components/base"))) { + console.error("usage: sync-boardui.mjs "); + process.exit(1); +} +const out = resolve(dirname(fileURLToPath(import.meta.url)), "../src/boardui"); + +const COMPONENTS = [ + "badges", + "buttons", + "checkbox", + "divider", + "dropdown", + "empty-state", + "input", + "kbd", + "menu", + "modal", + "popover", + "radio", + "segmented-control", + "select", + "slider", + "switch", + "tabs", + "textarea", + "toast", + "tooltip", +]; +const FILES = [ + ...COMPONENTS.map((name) => `components/base/${name}`), + "components/foundations/direction", + "components/foundations/icons", + "components/icons.tsx", + "utils/cx.ts", + "utils/directional-icon.ts", + "utils/use-dismiss-on-outside-press.ts", + "styles/theme.css", + "styles/typography.css", +]; + +rmSync(out, { recursive: true, force: true }); +for (const path of FILES) cpSync(join(web, path), join(out, path), { recursive: true }); + +const files = (dir) => + readdirSync(dir).flatMap((name) => { + const path = join(dir, name); + return statSync(path).isDirectory() ? files(path) : [path]; + }); + +for (const file of files(out).filter((f) => /\.tsx?$/.test(f))) { + const source = readFileSync(file, "utf8") + .replace(/from "next\/link"/g, 'from "@/next-link"') + .replace(/from "@\/([^"]+)"/g, (_, target) => { + const rel = relative(dirname(file), join(out, target)); + return `from "${rel.startsWith(".") ? rel : `./${rel}`}"`; + }); + writeFileSync(file, source); +} +// Studio clears Tailwind's palette and reuses some ramp names for its own tokens, so BoardUI's ramps move to +// --bui-* (filled from Tailwind's defaults, as BoardUI expects), and its dark mode follows Studio's data-theme. +const RAMP = + /--color-((?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+)/g; +const tailwind = new Map( + [ + ...readFileSync(resolve(web, "../../node_modules/tailwindcss/theme.css"), "utf8").matchAll( + /--color-([a-z]+-\d+):\s*([^;]+);/g, + ), + ].map((m) => [m[1], m[2]]), +); +const themePath = join(out, "styles/theme.css"); +const theme = readFileSync(themePath, "utf8") + .replace(RAMP, "--bui-$1") + .replace(/^\.dark\b/gm, ':root:not([data-theme="paper"])'); +const defined = new Set([...theme.matchAll(/(--bui-[a-z]+-\d+):/g)].map((m) => m[1])); +const missing = [...new Set([...theme.matchAll(/var\((--bui-([a-z]+-\d+))\)/g)].map((m) => m[2]))] + .filter((name) => !defined.has(`--bui-${name}`)) + .sort(); +writeFileSync( + themePath, + `${theme}\n:root {\n${missing.map((name) => ` --bui-${name}: ${tailwind.get(name)};`).join("\n")}\n}\n`, +); + +// Studio has no Next router, so BoardUI's links are plain anchors. +writeFileSync( + join(out, "next-link.tsx"), + 'import type { ComponentProps } from "react";\n\nexport default function Link(props: ComponentProps<"a">) {\n return ;\n}\n', +); +console.log(`boardui: synced ${FILES.length} paths from ${web}`); diff --git a/packages/studio/src/boardui/components/base/badges/chip.tsx b/packages/studio/src/boardui/components/base/badges/chip.tsx new file mode 100644 index 00000000000..ba8219bfa7c --- /dev/null +++ b/packages/studio/src/boardui/components/base/badges/chip.tsx @@ -0,0 +1,75 @@ +import type { HTMLAttributes, Ref } from "react"; +import { cx, sortCx } from "../../../utils/cx"; + +/** + * Figma source: Board UI → dashboard 1 "Chip" layers (status chips, delta + * chips, price chips, role chips — e.g. nodes 3731:3273, 3731:3094, 3731:3280, + * 3731:3055). + * + * Colored label chip. Three emphasis levels, all rounded radius/md (6px), + * px 6: + * + * bold py 2, Body 1/Medium (14/20/500) — status + percentage deltas + * subtle py 4, Body 1/Medium (14/20/500) — price chips + * caption py 4, Caption 1/Medium (12/16/500, tracking .15) — role tags + * + * Status pairs are semantic so the same component follows both Figma modes: + * lime light 200/800 · dark 950 @ 60% / 500 + * rose light 200/800 · dark 950 @ 60% / 500 + * yellow light 200/800 · dark 950 @ 60% / 500 + * cyan light 200/800 · dark 950 @ 60% / 400 + * blue light 200/800 · dark 950 @ 60% / 300 (same dark status recipe; + * first needed by the medical template's "In treatment" status) + * purple light 100/600 · dark 900/300 (AI profile delta chips) + * neutral bg color/neutral/200 text color/neutral/500 + * gray bg color/neutral/100 text color/neutral/800 + * soft bg background/secondary/default text text/secondary + * accent bg accent-500/15 text accent-500 (the theme accent, the logo's orange) + */ + +type ChipVariant = "bold" | "subtle" | "caption"; +type ChipColor = "accent" | "orange" | "lime" | "rose" | "yellow" | "cyan" | "blue" | "purple" | "neutral" | "gray" | "soft"; + +export interface ChipProps extends HTMLAttributes { + variant?: ChipVariant; + color?: ChipColor; + ref?: Ref; +} + +const styles = sortCx({ + base: "inline-flex items-center justify-center rounded-md px-1.5 whitespace-nowrap transition-[padding,font-size] duration-200 ease", + variant: { + bold: "py-0.5 text-body-medium", + subtle: "py-1 text-body-medium", + caption: "py-1 text-caption-1-medium", + }, + color: { + accent: "bg-accent-500/15 text-accent-500", + orange: "bg-status-orange-background text-status-orange-text", + lime: "bg-status-lime-background text-status-lime-text", + rose: "bg-status-rose-background text-status-rose-text", + yellow: "bg-status-yellow-background text-status-yellow-text", + cyan: "bg-status-cyan-background text-status-cyan-text", + blue: "bg-status-blue-background text-status-blue-text", + purple: "bg-status-purple-background text-status-purple-text", + neutral: "bg-background-tertiary-default text-text-secondary", + gray: "bg-background-secondary-default text-text-primary", + soft: "bg-background-secondary-default text-text-secondary", + }, +}); + +export function Chip({ + variant = "bold", + color = "neutral", + className, + ref, + ...props +}: ChipProps) { + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/buttons/button-group.tsx b/packages/studio/src/boardui/components/base/buttons/button-group.tsx new file mode 100644 index 00000000000..2fd25c2ad20 --- /dev/null +++ b/packages/studio/src/boardui/components/base/buttons/button-group.tsx @@ -0,0 +1,133 @@ +import type { + ButtonHTMLAttributes, + ComponentType, + ReactNode, + Ref, +} from "react"; +import { cx, sortCx } from "../../../utils/cx"; +import { directionalIconClass } from "../../../utils/directional-icon"; + +/** + * Button group — a row of secondary-style buttons fused into one control: + * shared 1px border/button/default container with radius/2lg and shadow/xs, + * items divided by 1px hairlines, only the outer corners rounded. The classic + * toolbar / filter pattern ("Left | Center | Right"). + * + * Sizing follows Button's secondary sizes: + * medium h=36, px=12, Body 1/Medium, 20px icons + * small h=32, px=10, Body 1/Medium, 18px icons + * + * Items can be text, icon + text, or icon-only (pass `iconOnly` + + * `aria-label`). A pressed/selected item (`selected`) keeps the hover fill — + * drive it from state for single or multi select toolbars, or leave every + * item unselected for pure action rows. For a full segmented switcher with + * animated thumb, see SegmentedControl instead. + */ + +type ButtonGroupSize = "medium" | "small"; + +type IconComponent = ComponentType<{ + className?: string; + "aria-hidden"?: boolean | "true" | "false"; +}>; + +const styles = sortCx({ + group: [ + "inline-flex items-stretch isolate", + "rounded-2lg border border-border-button-group bg-background-primary-default shadow-xs", + // Hairlines between items, outer corners rounded on the first/last item + "divide-x divide-border-button-default overflow-hidden", + ].join(" "), + + item: [ + "inline-flex items-center justify-center gap-1 whitespace-nowrap", + "bg-background-primary-default text-text-primary font-sans select-none cursor-pointer", + "transition-[background-color,color] duration-150 ease", + "outline-none focus-visible:relative focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring", + "hover:bg-background-primary-hover active:bg-background-primary-active", + "disabled:cursor-not-allowed disabled:bg-background-primary-disabled disabled:text-text-tertiary", + ].join(" "), + + size: { + medium: "h-[34px] px-3 text-body-medium", + small: "h-[30px] px-2.5 text-body-medium", + }, + + iconOnlySize: { + medium: "w-[34px] px-0", + small: "w-[30px] px-0", + }, + + icon: { + medium: "size-5 shrink-0", + small: "size-[18px] shrink-0", + }, + + selected: "bg-background-primary-hover", +}); + +/* ------------------------------------------------------------------- group */ + +export interface ButtonGroupProps { + size?: ButtonGroupSize; + /** ButtonGroupItem children. */ + children: ReactNode; + className?: string; + "aria-label"?: string; + ref?: Ref; +} + +export function ButtonGroup({ size = "medium", children, className, ref, ...props }: ButtonGroupProps) { + return ( +
+ {children} +
+ ); +} + +/* -------------------------------------------------------------------- item */ + +export interface ButtonGroupItemProps + extends Omit, "children"> { + size?: ButtonGroupSize; + /** Highlights the item like its hover state; sets aria-pressed. */ + selected?: boolean; + iconOnly?: boolean; + leadingIcon?: IconComponent; + trailingIcon?: IconComponent; + children?: ReactNode; + ref?: Ref; +} + +export function ButtonGroupItem({ + size = "medium", + selected = false, + iconOnly = false, + leadingIcon: Leading, + trailingIcon: Trailing, + children, + className, + type = "button", + ref, + ...props +}: ButtonGroupItemProps) { + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/buttons/button.tsx b/packages/studio/src/boardui/components/base/buttons/button.tsx new file mode 100644 index 00000000000..5ca87d7a4a3 --- /dev/null +++ b/packages/studio/src/boardui/components/base/buttons/button.tsx @@ -0,0 +1,219 @@ +import type { + AnchorHTMLAttributes, + ButtonHTMLAttributes, + ComponentType, + ReactNode, + Ref, +} from "react"; +import Link from "../../../next-link"; +import { cx, sortCx } from "../../../utils/cx"; +import { directionalIconClass } from "../../../utils/directional-icon"; + +/** + * Figma source: Board UI → Buttons (node 3656:13819). + * + * Variant matrix from Figma: + * Type = Primary | Secondary | Ghost | Danger + * Size = Medium | Small | Xs + * State = Default | Hover | Active | Disabled (CSS pseudo) + * OnlyIcon = false | true + * + * Sizing (1:1 with Figma): + * + * Medium Small Xs + * container h=36, p=8, r=10 h=32, px=8 py=6, r=8 h=24, px=8, r=4 + * gap 2px 2px 1.33px→1 + * icon 20×20 18×18 14×14 + * label wrapper px=4 px=2 px=2 + * text style Body 1/Medium Body 1/Medium Caption 1/Semibold + * icon-only square 36×36 (content-derived) 32×32 (forced size) 24×24 (forced size) + * + * `xs` is the smallest tier — first needed for the calendar template's + * event-details modal ("Join" / edit-icon buttons, node 3920:10954), which + * scales every dimension down by the same ~0.667 factor from Figma; the + * table above rounds those to clean pixel values rather than reproducing + * the fractional source numbers. + * + * Icons are rendered by the component itself via the `leadingIcon` / + * `trailingIcon` props so the consumer can't pass the wrong size. Pass + * an icon component reference (`IconPlus`, not ``). + * + * For icon-only buttons: + * + ); +} + +/** Anchor counterpart to Button for navigational actions (a Next.js Link, so app routes navigate client-side). */ +export function ButtonLink({ + variant = "primary", + size = "medium", + iconOnly = false, + leadingIcon: Leading, + trailingIcon: Trailing, + children, + className, + ref, + ...props +}: ButtonLinkProps) { + return ( + + {Leading ? : null} + {!iconOnly && children !== undefined && children !== null && ( + {children} + )} + {!iconOnly && Trailing ? ( + + ) : null} + + ); +} + +/** Style maps, exported for advanced composition and the dev Design Tuner. */ +export const buttonStyles = styles; diff --git a/packages/studio/src/boardui/components/base/buttons/close-button.tsx b/packages/studio/src/boardui/components/base/buttons/close-button.tsx new file mode 100644 index 00000000000..7ea01c25232 --- /dev/null +++ b/packages/studio/src/boardui/components/base/buttons/close-button.tsx @@ -0,0 +1,114 @@ +import type { ButtonHTMLAttributes, Ref } from "react"; +import { cx, sortCx } from "../../../utils/cx"; + +/** + * Compact circular dismiss control — the "X" used to close announcements, + * toasts, modals, and drawers. Companion to `IconButton`, but ships its own + * glyph (rather than taking an `icon` prop) so every consumer gets an + * identical X with a stroke that's a true CSS pixel value per size, not a + * scaled approximation. + * + * The glyph is a hand-drawn SVG per size (not a shared icon scaled up/down) + * because scaling a single glyph across sizes scales its stroke too — a 2px + * line at 20px becomes ~3.2px at 32px. Each size below renders its own X in a + * viewBox equal to its own pixel dimensions, so `strokeWidth` is always a + * literal CSS pixel value, at every size (not a scaled approximation). + * + * Sizes (px): + * 2xs = 16 container, 6.8px glyph, 1.6px stroke (compact floating surfaces) + * xs = 20 container, 10.8px glyph, 2px stroke (Announcement dismiss corner) + * sm = 24 container, 12.6px glyph, 2px stroke + * md = 32 container, 16.2px glyph, 2.5px stroke (modal header close — a touch + * bolder to hold its own at + * the larger size) + * + * Glyph sizes are 90% of the container's natural proportion (12/14/18 → the + * above), tuned down 10% across the board so the X reads a touch smaller + * inside its circle. + */ + +type CloseButtonSize = "2xs" | "xs" | "sm" | "md"; + +export interface CloseButtonProps + extends Omit, "children"> { + size?: CloseButtonSize; + /** Accessible name — required since there is no visible label. */ + "aria-label": string; + ref?: Ref; +} + +const GLYPH_SIZE: Record = { + "2xs": 6.8, + xs: 10.8, + sm: 12.6, + md: 16.2, +}; + +const STROKE_WIDTH: Record = { + "2xs": 1.6, + xs: 2, + sm: 2, + md: 2.5, +}; + +const GLYPH_INSET: Record = { + // Figma's 8px bars rotate inside a 6.8px square, leaving ~0.57px at + // each corner once projected onto the diagonal. + "2xs": 0.57, + xs: 2, + sm: 2, + md: 2, +}; + +const styles = sortCx({ + base: [ + "inline-flex shrink-0 items-center justify-center rounded-full", + "bg-background-tertiary-default text-foreground-icon-secondary", + "select-none cursor-pointer", + "transition-colors duration-150 ease", + "hover:text-text-primary", + "outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring", + ].join(" "), + container: { + "2xs": "size-4", + xs: "size-5", + sm: "size-6", + md: "size-8", + }, +}); + +export function CloseButton({ + size = "xs", + className, + type = "button", + ref, + ...props +}: CloseButtonProps) { + const glyph = GLYPH_SIZE[size]; + const strokeWidth = STROKE_WIDTH[size]; + const inset = GLYPH_INSET[size]; + + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/buttons/icon-button.tsx b/packages/studio/src/boardui/components/base/buttons/icon-button.tsx new file mode 100644 index 00000000000..695ff772840 --- /dev/null +++ b/packages/studio/src/boardui/components/base/buttons/icon-button.tsx @@ -0,0 +1,109 @@ +import type { + AnchorHTMLAttributes, + ButtonHTMLAttributes, + ComponentType, + Ref, +} from "react"; +import { cx, sortCx } from "../../../utils/cx"; +import { directionalIconClass } from "../../../utils/directional-icon"; + +/** + * Figma source: Board UI → dashboard 1 icon-only buttons (notification button + * node 3731:3035, table row actions nodes 3731:3284/3287/3290). + * + * Square icon-only button. Unlike `Button` (whose small size is radius/lg with + * an 18px icon), the icon button keeps radius/2lg (10px) at both sizes: + * + * container padding icon + * medium 36×36 8 20×20 + * small 32×32 8 16×16 + * + * One variant in the designs: secondary (white fill, border/button/default, + * shadow/xs). Hover/active/disabled states reuse the secondary Button tokens. + */ + +type IconButtonSize = "medium" | "small"; + +type IconComponent = ComponentType<{ + className?: string; + "aria-hidden"?: boolean | "true" | "false"; +}>; + +export interface IconButtonProps + extends Omit, "children"> { + icon: IconComponent; + size?: IconButtonSize; + /** Accessible name — required since there is no visible label. */ + "aria-label": string; + ref?: Ref; +} + +export interface IconLinkButtonProps + extends Omit, "children"> { + icon: IconComponent; + size?: IconButtonSize; + /** Accessible name — required since there is no visible label. */ + "aria-label": string; + ref?: Ref; +} + +const styles = sortCx({ + base: [ + "relative inline-flex shrink-0 items-center justify-center overflow-visible rounded-2lg", + "bg-background-primary-default text-foreground-icon-primary", + "border border-border-button-default shadow-xs", + "select-none cursor-pointer", + "transition-[background-color,border-color,box-shadow,color] duration-150 ease", + "outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring", + "hover:bg-background-primary-hover hover:border-border-button-hover", + "active:bg-background-primary-active active:border-border-button-active", + "disabled:cursor-not-allowed disabled:bg-background-primary-disabled disabled:border-border-button-default disabled:text-icon-button-disabled-foreground disabled:opacity-60 disabled:shadow-none", + ].join(" "), + size: { + medium: "size-9", + small: "size-8", + }, + icon: { + medium: "size-5 shrink-0", + small: "size-4 shrink-0", + }, +}); + +export function IconButton({ + icon: Icon, + size = "medium", + className, + type = "button", + ref, + ...props +}: IconButtonProps) { + return ( + + ); +} + +/** Anchor counterpart to IconButton for external and navigational actions. */ +export function IconLinkButton({ + icon: Icon, + size = "medium", + className, + ref, + ...props +}: IconLinkButtonProps) { + return ( +
+ + + ); +} diff --git a/packages/studio/src/boardui/components/base/checkbox/checkbox-glyph.tsx b/packages/studio/src/boardui/components/base/checkbox/checkbox-glyph.tsx new file mode 100644 index 00000000000..1210c2698dd --- /dev/null +++ b/packages/studio/src/boardui/components/base/checkbox/checkbox-glyph.tsx @@ -0,0 +1,74 @@ +import { cx } from "../../../utils/cx"; + +export type CheckboxSize = "sm" | "md"; + +export const checkboxSizes: Record< + CheckboxSize, + { box: string; glyph: string; label: string; gap: string } +> = { + md: { box: "size-4", glyph: "size-4", label: "text-body-medium", gap: "gap-2" }, + sm: { box: "size-3.5", glyph: "size-3.5", label: "text-body-2-medium", gap: "gap-1.5" }, +}; + +export interface CheckboxGlyphState { + isSelected: boolean; + isIndeterminate: boolean; + isFocusVisible: boolean; + isDisabled: boolean; + isHovered: boolean; +} + +/** + * The 16px (or 14px) checkbox box + tick/indeterminate glyph. Shared by the + * standalone Checkbox and the CheckboxCard so their visuals stay identical. + */ +export function CheckboxGlyph({ + state, + size = "md", +}: { + state: CheckboxGlyphState; + size?: CheckboxSize; +}) { + const { isSelected, isIndeterminate, isFocusVisible, isDisabled, isHovered } = state; + const s = checkboxSizes[size]; + const isMarked = isSelected || isIndeterminate; + const hover = isHovered && !isDisabled; + + return ( + + + {isIndeterminate ? ( + + ) : isSelected ? ( + + ) : null} + + + ); +} diff --git a/packages/studio/src/boardui/components/base/checkbox/checkbox.tsx b/packages/studio/src/boardui/components/base/checkbox/checkbox.tsx new file mode 100644 index 00000000000..9bcd94582e6 --- /dev/null +++ b/packages/studio/src/boardui/components/base/checkbox/checkbox.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { ReactNode, Ref } from "react"; +import { Checkbox as AriaCheckbox } from "react-aria-components"; +import type { CheckboxProps as AriaCheckboxProps } from "react-aria-components"; +import { cx } from "../../../utils/cx"; +import { CheckboxGlyph, checkboxSizes } from "./checkbox-glyph"; +import type { CheckboxSize } from "./checkbox-glyph"; + +/** + * Figma source: Board UI → Checkbox (node 3699:2557 family; used in + * dashboard 1 table, nodes 3731:3258 etc.). + * + * radius/sm (4px). Two sizes: + * md 16×16, label Body 1/Medium (14) + * sm 14×14, label Body 2/Medium (13) + * + * States (per Figma): + * default bg background/primary/default, 1px border/checkbox/default, + * shadow/xs + * hover border darkens to neutral/400 + * checked / Button/Primary gradient (blue/500 → blue/600) with the + * indeterminate checkbox inner highlight (see --shadow-checkbox-selected), + * white 2px rounded stroke glyph; on hover the gradient + * lightens to blue/400 → blue/500 + * + * Built on react-aria Checkbox: keyboard toggling, aria-checked="mixed" for + * indeterminate, label association when `children` is passed. + */ + +export interface CheckboxProps extends Omit { + children?: ReactNode; + size?: CheckboxSize; + ref?: Ref; +} + +export function Checkbox({ className, children, size = "md", ref, ...props }: CheckboxProps) { + const s = checkboxSizes[size]; + + return ( + + cx( + "group inline-flex items-center select-none", + s.gap, + state.isDisabled ? "cursor-not-allowed" : "cursor-pointer", + typeof className === "function" ? className(state) : className, + ) + } + > + {(state) => ( + <> + + {children !== undefined && children !== null && ( + {children} + )} + + )} + + ); +} diff --git a/packages/studio/src/boardui/components/base/divider/divider.tsx b/packages/studio/src/boardui/components/base/divider/divider.tsx new file mode 100644 index 00000000000..d424c596db7 --- /dev/null +++ b/packages/studio/src/boardui/components/base/divider/divider.tsx @@ -0,0 +1,94 @@ +import type { HTMLAttributes, ReactNode, Ref } from "react"; +import { cx, sortCx } from "../../../utils/cx"; + +export type DividerVariant = "single" | "double" | "fill"; +export type DividerAlign = "start" | "center" | "end"; + +export interface DividerProps + extends Omit, "children"> { + /** Single hairline, two framing hairlines, or a soft filled strip. */ + variant?: DividerVariant; + /** Positions the divider content while keeping the remaining line flexible. */ + align?: DividerAlign; + /** Text, a button, or any compact control placed in the divider. */ + children?: ReactNode; + /** Extra classes for the content wrapper. */ + contentClassName?: string; + ref?: Ref; +} + +const styles = sortCx({ + root: "w-full", + line: "h-px min-w-0 flex-1 bg-separator-border", + content: "shrink-0 text-body-medium text-text-secondary", + align: { + start: "justify-start", + center: "justify-center", + end: "justify-end", + }, + withContent: { + single: "flex items-center gap-3", + double: "flex items-center border-y border-separator-border py-2.5", + fill: "flex items-center rounded-2lg bg-background-secondary-default px-4 py-2.5", + }, + empty: { + single: "h-px bg-separator-border", + double: "h-2 border-y border-separator-border", + fill: "h-2 rounded-full bg-background-secondary-default", + }, +}); + +/** + * Horizontal content divider with three BoardUI surface treatments. + * + * `single` places content within one continuous hairline, `double` frames it + * with a line above and below, and `fill` uses the secondary surface color. + * Children stay completely composable, so labels, buttons, and small controls + * can share the same primitive. + */ +export function Divider({ + variant = "single", + align = "center", + children, + contentClassName, + className, + ref, + ...props +}: DividerProps) { + const hasContent = children !== undefined && children !== null; + + if (!hasContent) { + return ( +
+ ); + } + + const showLeadingLine = variant === "single" && align !== "start"; + const showTrailingLine = variant === "single" && align !== "end"; + + return ( +
+ {showLeadingLine ? : null} +
{children}
+ {showTrailingLine ? : null} +
+ ); +} diff --git a/packages/studio/src/boardui/components/base/dropdown/menu-styles.ts b/packages/studio/src/boardui/components/base/dropdown/menu-styles.ts new file mode 100644 index 00000000000..4c2326bb1f7 --- /dev/null +++ b/packages/studio/src/boardui/components/base/dropdown/menu-styles.ts @@ -0,0 +1,30 @@ +/** + * Shared visual recipe for BoardUI menu surfaces. + * + * Select and Dropdown intentionally keep their distinct React Aria semantics + * (single-value listbox vs. action menu), while consuming the same panel, + * motion, and row treatments from here. + */ +export const MENU_POPOVER_SURFACE = [ + "max-w-[calc(100vw-32px)] overflow-y-auto", + "rounded-2xl border border-border-button-default bg-background-primary-default p-2.5 shadow-dropdown", + "transition duration-150 ease-out", + "data-[entering]:opacity-0 data-[entering]:scale-95 data-[entering]:blur-[2px]", + "data-[exiting]:opacity-0 data-[exiting]:scale-95 data-[exiting]:blur-[2px]", + "data-[placement=bottom]:origin-top-left rtl:data-[placement=bottom]:origin-top-right data-[placement=top]:origin-bottom-left rtl:data-[placement=top]:origin-bottom-right", + "data-[placement=left]:origin-right data-[placement=right]:origin-left", +].join(" "); + +export const MENU_POPOVER_WIDTH = "w-[266px]"; + +export const MENU_ITEMS_CONTAINER = "flex w-full flex-col gap-1 outline-none"; + +export const MENU_ITEM = [ + "flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-start", + "text-text-primary outline-none transition-colors", +].join(" "); + +export const MENU_ITEM_ACTIVE = "bg-dropdown-item-hover-background"; + +export const MENU_ITEM_INTERACTIVE = + "hover:bg-dropdown-item-hover-background focus-visible:bg-dropdown-item-hover-background"; diff --git a/packages/studio/src/boardui/components/base/empty-state/empty-state.tsx b/packages/studio/src/boardui/components/base/empty-state/empty-state.tsx new file mode 100644 index 00000000000..ecf1788a4e3 --- /dev/null +++ b/packages/studio/src/boardui/components/base/empty-state/empty-state.tsx @@ -0,0 +1,32 @@ +import type { ComponentType, ReactNode } from "react"; +import { cx } from "../../../utils/cx"; + +/** A centered "nothing here yet" block: icon, title, description and optional actions. */ +export function EmptyState({ + icon: Icon, + title, + description, + className, + children, +}: { + icon?: ComponentType<{ className?: string }>; + title: ReactNode; + description?: ReactNode; + className?: string; + children?: ReactNode; +}) { + return ( +
+ {Icon && ( + + + + )} +
+

{title}

+ {description &&

{description}

} +
+ {children &&
{children}
} +
+ ); +} diff --git a/packages/studio/src/boardui/components/base/input/hint-text.tsx b/packages/studio/src/boardui/components/base/input/hint-text.tsx new file mode 100644 index 00000000000..0c6d8204a0d --- /dev/null +++ b/packages/studio/src/boardui/components/base/input/hint-text.tsx @@ -0,0 +1,41 @@ +"use client"; + +import type { ReactNode, Ref } from "react"; +import type { TextProps as AriaTextProps } from "react-aria-components"; +import { Text as AriaText } from "react-aria-components"; +import { cx } from "../../../utils/cx"; + +/** + * Caption text rendered below a field. Wraps `react-aria-components`' Text + * so the parent `TextField` automatically wires `aria-describedby` (when + * `slot="description"`) or `aria-errormessage` (when `slot="errorMessage"`). + * + * Visuals come from Figma: + * - Inter Medium 12/16, letter-spacing 0.15px + * - color text/secondary (default) or text/error-primary (invalid) + * - 1px top padding to align with the field's bottom edge + */ + +export interface HintTextProps extends AriaTextProps { + children: ReactNode; + isInvalid?: boolean; + ref?: Ref; +} + +export function HintText({ + isInvalid = false, + className, + ...props +}: HintTextProps) { + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/input/input.tsx b/packages/studio/src/boardui/components/base/input/input.tsx new file mode 100644 index 00000000000..dab4d4084f6 --- /dev/null +++ b/packages/studio/src/boardui/components/base/input/input.tsx @@ -0,0 +1,313 @@ +"use client"; + +import { + createContext, + useContext, + type ComponentType, + type ReactNode, + type Ref, +} from "react"; +import { + Group as AriaGroup, + Input as AriaInput, + TextField as AriaTextField, + useLocale, +} from "react-aria-components"; +import type { + InputProps as AriaInputProps, + TextFieldProps as AriaTextFieldProps, +} from "react-aria-components"; +import { Label } from "./label"; +import { HintText } from "./hint-text"; +import { cx, sortCx } from "../../../utils/cx"; + +/** + * Figma source: Board UI → Input (node 3665:1849). + * + * Architecture (mirrors the Untitled UI / React Aria pattern): + * + * InputBase — the styled field shell. Renders + adornments + * + . State (hover / focus / disabled / invalid) + * arrives as render-prop booleans from Aria. + * TextField — wraps and a TextFieldContext so size / + * classes can flow from the composer down into InputBase + * without prop drilling. + * Input — opinionated composition: TextField → Label → InputBase + * → HintText. The component most consumers use. + * + * Why React Aria: + * - Label ↔ input ↔ hint association via aria-labelledby / aria-describedby + * - aria-required, aria-invalid auto-applied + * - Form library / native validation integration + * - i18n, RTL, autofill edge cases handled + * - Render-prop state surfaces `isFocusWithin`, `isHovered`, `isDisabled`, + * `isInvalid`, `isRequired` — no CSS pseudo gymnastics + * + * Visuals stay 1:1 with Figma — see `styles/theme.css` for the tokens. + */ + +export type InputSize = "medium" | "small"; + +type IconComponent = ComponentType<{ + className?: string; + "aria-hidden"?: boolean | "true" | "false"; +}>; + +/* -------------------------------------------------------------------------- */ +/* TextFieldContext */ +/* -------------------------------------------------------------------------- */ + +export interface TextFieldContextValue { + size?: InputSize; + fieldClassName?: string; + inputClassName?: string; +} + +/** + * Shared by every field that wants Input's shell — `Textarea` reads the same + * size and class overrides out of it, so a composed form stays consistent. + */ +export const TextFieldContext = createContext({}); + +/* -------------------------------------------------------------------------- */ +/* TextField */ +/* -------------------------------------------------------------------------- */ + +export interface TextFieldProps + extends Omit, + TextFieldContextValue { + className?: string; + children?: ReactNode | ((state: { isRequired: boolean; isInvalid: boolean; isDisabled: boolean; isReadOnly: boolean }) => ReactNode); +} + +export function TextField({ + size = "medium", + fieldClassName, + inputClassName, + className, + children, + ...props +}: TextFieldProps) { + return ( + + + {children as never /* RAC accepts render-prop children */} + + + ); +} + +TextField.displayName = "TextField"; + +/* -------------------------------------------------------------------------- */ +/* InputBase */ +/* -------------------------------------------------------------------------- */ + +export interface InputBaseProps extends Omit { + size?: InputSize; + className?: string; + leadingIcon?: IconComponent; + trailingIcon?: IconComponent; + /** Custom element rendered in the leading slot (Phone basic uses this). */ + leadingAddon?: ReactNode; + /** Class for the field shell. */ + fieldClassName?: string; + /** Ref to the element. */ + ref?: Ref; + /** Ref to the field shell wrapper. */ + groupRef?: Ref; +} + +const inputStyles = sortCx({ + field: [ + "relative flex w-full items-center", + "rounded-2lg", + "bg-background-tertiary-default text-foreground-icon-tertiary", + "ring-2 ring-inset ring-transparent", + "transition-[background-color,box-shadow,color] duration-[var(--input-transition-ms)] ease", + ].join(" "), + + fieldSize: { + medium: "p-2", // 8px all sides → h auto = 36 + small: "h-8 px-1.5 py-2", // 32 / 6 / 8 + }, + + // When a leadingAddon is present (Phone basic): tighten left padding. + fieldWithAddonSize: { + medium: "h-9 ps-1 pe-2 py-2", // 36 / 4 / 8 / 8 + small: "h-8 ps-1 pe-1.5 py-2", + }, + + content: "flex w-full items-center gap-2 min-w-0", + leftSection: "flex flex-1 items-center gap-0.5 min-w-0", + + input: [ + "min-w-0 flex-1 bg-transparent border-0 outline-none p-0 m-0", + "font-sans text-body-regular text-text-primary", + "placeholder:text-text-tertiary", + "focus:placeholder:text-text-primary", + "disabled:text-input-disabled-text disabled:placeholder:text-input-disabled-text", + "disabled:cursor-not-allowed", + "aria-invalid:placeholder:text-text-error-placeholder", + ].join(" "), + + icon: "size-5 shrink-0", +}); + +export function InputBase({ + size: sizeProp, + leadingIcon: Leading, + trailingIcon: Trailing, + leadingAddon, + fieldClassName, + className, + ref, + groupRef, + ...inputProps +}: InputBaseProps) { + const { direction } = useLocale(); + const ctx = useContext(TextFieldContext); + const size: InputSize = sizeProp ?? ctx.size ?? "medium"; + const hasAddon = leadingAddon !== undefined && leadingAddon !== null; + + return ( + + cx( + inputStyles.field, + hasAddon + ? inputStyles.fieldWithAddonSize[size] + : inputStyles.fieldSize[size], + // Hover: idle, no focus, no disabled, no invalid + isHovered && + !isFocusWithin && + !isDisabled && + !isInvalid && + "ring-border-button-hover", + // Focus wins over hover + isFocusWithin && + !isDisabled && + !isInvalid && + "ring-border-button-active", + // Disabled + isDisabled && + "bg-input-disabled-background text-input-disabled-foreground", + // Invalid + isInvalid && "bg-background-tertiary-error text-foreground-icon-error", + ctx.fieldClassName, + fieldClassName, + ) + } + > +
+
+ {hasAddon ? ( + leadingAddon + ) : Leading ? ( + + ) : null} + +
+ {Trailing ? ( + + ) : null} +
+
+ ); +} + +InputBase.displayName = "InputBase"; + +/* -------------------------------------------------------------------------- */ +/* Input (composed) */ +/* -------------------------------------------------------------------------- */ + +export interface InputProps + extends Omit, + Pick< + InputBaseProps, + | "leadingIcon" + | "trailingIcon" + | "leadingAddon" + | "fieldClassName" + | "groupRef" + | "ref" + > { + label?: ReactNode; + hint?: ReactNode; + /** Show an info icon next to the label. Replace with tooltip when Tooltip lands. */ + tooltip?: boolean | string; + placeholder?: string; + /** Direction of the editable value, independent of the label and adornments. + * Email, URL and telephone inputs default to LTR inside RTL forms. */ + inputDir?: "ltr" | "rtl" | "auto"; +} + +export function Input({ + label, + hint, + tooltip, + placeholder, + inputDir, + leadingIcon, + trailingIcon, + leadingAddon, + fieldClassName, + ref, + groupRef, + className, + ...textFieldProps +}: InputProps) { + return ( + + {({ isRequired, isInvalid }) => ( + <> + {label && ( + + )} + + {hint && {hint}} + + )} + + ); +} + +Input.displayName = "Input"; diff --git a/packages/studio/src/boardui/components/base/input/label.tsx b/packages/studio/src/boardui/components/base/input/label.tsx new file mode 100644 index 00000000000..726ac24f84c --- /dev/null +++ b/packages/studio/src/boardui/components/base/input/label.tsx @@ -0,0 +1,70 @@ +"use client"; + +import type { ReactNode, Ref } from "react"; +import { IconInfoCircleFilled } from "../../icons"; +import type { LabelProps as AriaLabelProps } from "react-aria-components"; +import { Label as AriaLabel } from "react-aria-components"; +import { cx } from "../../../utils/cx"; + +/** + * Field label. Wraps `react-aria-components`' Label so the parent `TextField` + * automatically wires `htmlFor` / `aria-labelledby`. + * + * Visuals come from Figma: + * - Inter Medium 14/20, color text/primary + * - 2px gap between label + required asterisk + info icon + * - asterisk is text/error-primary (red/500) + * - info icon is 16×16 in foreground/icon/quaternary + * + * Pass `isRequired` to show the asterisk. Pass `tooltip` to show the info + * icon (currently a static glyph — when the Tooltip component lands it'll + * wire up here). + */ + +export interface LabelProps extends AriaLabelProps { + children: ReactNode; + isRequired?: boolean; + /** Reserved for invalid-aware styling (asterisk color, etc.) once we need it. */ + isInvalid?: boolean; + /** Show the info icon next to the label. */ + tooltip?: boolean | string; + ref?: Ref; +} + +export function Label({ + isRequired = false, + isInvalid: _isInvalid, + tooltip, + className, + children, + ...props +}: LabelProps) { + void _isInvalid; + return ( + + {children} + {isRequired && ( + + )} + {tooltip && ( + + )} + + ); +} diff --git a/packages/studio/src/boardui/components/base/kbd/kbd.tsx b/packages/studio/src/boardui/components/base/kbd/kbd.tsx new file mode 100644 index 00000000000..187d4d2b046 --- /dev/null +++ b/packages/studio/src/boardui/components/base/kbd/kbd.tsx @@ -0,0 +1,27 @@ +import type { HTMLAttributes, Ref } from "react"; +import { cx } from "../../../utils/cx"; + +/** + * Figma source: Board UI → dashboard 1 quick-search shortcut (node 3731:2955). + * + * Keyboard shortcut hint: 12/16 semibold on a fully-rounded neutral pill. + * - semantic neutral background and foreground for theme-safe contrast + * - px 4, py 2, radius full + */ + +export interface KbdProps extends HTMLAttributes { + ref?: Ref; +} + +export function Kbd({ className, ref, ...props }: KbdProps) { + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/menu/menu.tsx b/packages/studio/src/boardui/components/base/menu/menu.tsx new file mode 100644 index 00000000000..69d635fbb0a --- /dev/null +++ b/packages/studio/src/boardui/components/base/menu/menu.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { IconChevronRight, IconCheck } from "../../icons"; +import type { ComponentProps, ReactElement, ReactNode } from "react"; +import { + Header, + Menu as AriaMenu, + MenuItem as AriaMenuItem, + MenuSection as AriaMenuSection, + MenuTrigger, + Popover, + Pressable, + Separator, + SubmenuTrigger, + type MenuItemProps as AriaMenuItemProps, + type MenuSectionProps as AriaMenuSectionProps, + type PopoverProps, +} from "react-aria-components"; +import { MENU_ITEM, MENU_POPOVER_SURFACE, MENU_POPOVER_WIDTH } from "../dropdown/menu-styles"; +import { cx } from "../../../utils/cx"; + +/** + * An action menu: React Aria's Menu (arrow keys, type-ahead, radio sections, submenus) + * dressed in BoardUI's dropdown recipe. `trigger` is any focusable element, e.g. a Button. + * + * Sort} aria-label="Sort"> + * + * Recent + * + * + * Delete + * + */ +type PressableChild = ComponentProps["children"]; + +export function Menu({ + trigger, + isOpen, + onOpenChange, + placement = "bottom start", + offset, + crossOffset, + className, + children, + "aria-label": label, +}: { + trigger: ReactElement; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: PopoverProps["placement"]; + /** Gap from the trigger (default 4px); more when the trigger sits inside a padded card. */ + offset?: number; + /** Shift along the trigger, e.g. to line the panel up with that card's edge. */ + crossOffset?: number; + /** Classes on the panel, e.g. a width (default w-[266px]). */ + className?: string; + children: ReactNode; + "aria-label": string; +}) { + return ( + + {/* Any element that takes a ref and DOM props, e.g. a BoardUI Button. */} + {trigger as PressableChild} + + {children} + + + ); +} + +function MenuPanel({ + placement, + offset = 4, + crossOffset, + className, + label, + children, +}: { + placement?: PopoverProps["placement"]; + offset?: number; + crossOffset?: number; + className?: string; + label: string; + children: ReactNode; +}) { + return ( + + + {children} + + + ); +} + +/** A row. Inside a section with a selectionMode it shows a check when selected. */ +export function MenuItem({ className, children, ...props }: Omit & { children: ReactNode }) { + return ( + + {({ isSelected, selectionMode, hasSubmenu }) => ( + <> + {children} + {selectionMode !== "none" && } + {hasSubmenu && } + + )} + + ); +} + +/** A group of rows with an optional muted heading; give it a selectionMode for radio rows. */ +export function MenuSection({ + label, + className, + children, + ...props +}: Omit, "children"> & { label?: ReactNode; children: ReactNode }) { + return ( + + {label &&
{label}
} + {children} +
+ ); +} + +export function MenuSeparator() { + return ; +} + +/** A row that opens a nested menu to its side. */ +export function Submenu({ item, label, children }: { item: ReactNode; label: string; children: ReactNode }) { + return ( + + {item} + {/* Measured from the row, so it clears the parent's padding and border (11px) plus a 4px gap, and its + first row lines up with this one. */} + + {children} + + + ); +} diff --git a/packages/studio/src/boardui/components/base/modal/modal.tsx b/packages/studio/src/boardui/components/base/modal/modal.tsx new file mode 100644 index 00000000000..fedc68cb9ea --- /dev/null +++ b/packages/studio/src/boardui/components/base/modal/modal.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useState, type ReactNode } from "react"; +import { Dialog, Heading, Modal as AriaModal, ModalOverlay } from "react-aria-components"; +import { CloseButton } from "../buttons/close-button"; +import { cx } from "../../../utils/cx"; + +const PANEL = { + center: "w-full max-w-sm rounded-3xl p-5 data-[entering]:scale-95 data-[exiting]:scale-95", + // Drawers run the full height; the centered card keeps its 1rem margin. + start: "fixed inset-y-0 start-0 h-dvh max-h-dvh w-3/4 max-w-sm border-e data-[entering]:-translate-x-6 data-[exiting]:-translate-x-6", + end: "fixed inset-y-0 end-0 h-dvh max-h-dvh w-3/4 max-w-sm border-s data-[entering]:translate-x-6 data-[exiting]:translate-x-6", +}; + +/** + * A modal dialog on React Aria (focus trap, Escape, outside click), in the BoardUI card look. + * `side` turns it into a drawer from that edge. Content scrolls inside the panel. + * Control it with isOpen/onOpenChange, or give it a `trigger` that opens it on click. + */ +export function Modal({ + trigger, + isOpen, + onOpenChange, + title, + description, + hideTitle, + side = "center", + className, + children, +}: { + /** A button that opens it. It may come from a server component. */ + trigger?: ReactNode; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; + title: string; + description?: ReactNode; + /** Keep the title for screen readers only. */ + hideTitle?: boolean; + side?: keyof typeof PANEL; + className?: string; + /** Content, or a function of `close` for Cancel buttons. */ + children: ReactNode | ((close: () => void) => ReactNode); +}) { + const [ownOpen, setOwnOpen] = useState(false); + const open = isOpen ?? ownOpen; + const setOpen = (next: boolean) => { + setOwnOpen(next); + onOpenChange?.(next); + }; + const modal = ( + + + + {({ close }) => ( + <> +
+ + {title} + + {description &&

{description}

} +
+ {typeof children === "function" ? children(close) : children} + + + )} +
+
+
+ ); + if (!trigger) return modal; + // A click wrapper rather than React Aria's Pressable, which can't take a server-rendered element. + return ( + <> + setOpen(true)}> + {trigger} + + {modal} + + ); +} diff --git a/packages/studio/src/boardui/components/base/popover/popover.tsx b/packages/studio/src/boardui/components/base/popover/popover.tsx new file mode 100644 index 00000000000..f0812c9019c --- /dev/null +++ b/packages/studio/src/boardui/components/base/popover/popover.tsx @@ -0,0 +1,42 @@ +"use client"; + +import type { ComponentProps, ReactElement, ReactNode } from "react"; +import { Dialog, DialogTrigger, Popover as AriaPopover, Pressable, type PopoverProps } from "react-aria-components"; +import { MENU_POPOVER_SURFACE } from "../dropdown/menu-styles"; +import { cx } from "../../../utils/cx"; + +/** A floating panel opened by `trigger` (any focusable element), in BoardUI's dropdown surface. */ +export function Popover({ + trigger, + isOpen, + onOpenChange, + placement = "bottom start", + offset = 6, + className, + children, + "aria-label": label, +}: { + trigger: ReactElement; + isOpen?: boolean; + onOpenChange?: (open: boolean) => void; + placement?: PopoverProps["placement"]; + /** Gap from the trigger; more when the trigger sits inside a padded bar. */ + offset?: number; + className?: string; + children: ReactNode; + "aria-label": string; +}) { + return ( + + {/* Any element that takes a ref and DOM props, e.g. a BoardUI Button. */} + {trigger as PressableChild} + + + {children} + + + + ); +} + +type PressableChild = ComponentProps["children"]; diff --git a/packages/studio/src/boardui/components/base/radio/radio.tsx b/packages/studio/src/boardui/components/base/radio/radio.tsx new file mode 100644 index 00000000000..9ada7539580 --- /dev/null +++ b/packages/studio/src/boardui/components/base/radio/radio.tsx @@ -0,0 +1,177 @@ +"use client"; + +import type { ReactNode, Ref } from "react"; +import { + Radio as AriaRadio, + RadioGroup as AriaRadioGroup, +} from "react-aria-components"; +import type { + RadioGroupProps as AriaRadioGroupProps, + RadioProps as AriaRadioProps, +} from "react-aria-components"; +import { cx } from "../../../utils/cx"; + +/** + * Figma source: Board UI → radio_default_sm / radio_selected_sm (used in the + * AI chat model picker, node 4035:6925) and the checkbox family tokens. + * + * Two sizes: + * md 16×16 dot, 6px inner, label Body 1/Medium (14) + * sm 14×14 dot, 5px inner, label Body 2/Medium (13) + * + * States: + * default bg background/primary/default, 1px border/checkbox/default, + * shadow/xs + * selected Button/Primary gradient (blue/500 → blue/600) with an inset + * blue ring + top highlight, white inner dot + * disabled 50% opacity, not-allowed cursor + * + * `RadioGroup` + `Radio` are built on React Aria (keyboard arrows, form + * semantics, label association). The bare `RadioDot` glyph is also exported + * for menu rows that manage selection themselves (e.g. Dropdown items acting + * as a radio group), where a nested focusable radio would be invalid. + */ + +export type RadioSize = "sm" | "md"; + +const dotStyles = { + sm: { + dot: "size-3.5", + inner: "size-[5px]", + selected: + "bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_0.875px_var(--color-accent-500),inset_0px_1.75px_0px_0px_rgba(255,255,255,0.25)]", + border: "border-[0.875px]", + gap: "gap-2", + label: "text-body-2-medium", + }, + md: { + dot: "size-4", + inner: "size-1.5", + selected: + "bg-gradient-to-b from-accent-500 to-accent-600 shadow-[inset_0px_0px_0px_1px_var(--color-accent-500),inset_0px_2px_0px_0px_rgba(255,255,255,0.25)]", + border: "border", + gap: "gap-2", + label: "text-body-medium", + }, +}; + +export interface RadioDotProps { + selected?: boolean; + size?: RadioSize; + /** Draws the keyboard focus ring around the glyph. */ + focusVisible?: boolean; + className?: string; +} + +/** + * The bare radio glyph — presentation only, no interaction or semantics. + * + * Select/deselect animates: the default bordered surface and the selected + * gradient are stacked layers cross-fading over 200ms (a gradient background + * can't transition directly), while the white inner dot scales in and out. + */ +export function RadioDot({ selected = false, size = "sm", focusVisible = false, className }: RadioDotProps) { + const s = dotStyles[size]; + return ( + + {/* Default surface */} + + {/* Selected gradient surface */} + + {/* Inner dot: absolutely centered (flex centering of fractional sizes can + land on subpixels) and no drop shadow — a downward shadow makes the + dot read as sitting below center. */} + + + ); +} + +/* ------------------------------------------------------------------- group */ + +export interface RadioGroupProps extends Omit { + children?: ReactNode; + ref?: Ref; +} + +/** Wraps a set of Radios: arrow-key navigation, single selection, form value. */ +export function RadioGroup({ className, children, ref, ...props }: RadioGroupProps) { + return ( + + cx( + "flex flex-col gap-2", + typeof className === "function" ? className(state) : className, + ) + } + > + {children} + + ); +} + +/* ------------------------------------------------------------------- radio */ + +export interface RadioProps extends Omit { + children?: ReactNode; + size?: RadioSize; + ref?: Ref; +} + +export function Radio({ className, children, size = "md", ref, ...props }: RadioProps) { + const s = dotStyles[size]; + + return ( + + cx( + "group inline-flex items-center select-none", + s.gap, + state.isDisabled ? "cursor-not-allowed opacity-50" : "cursor-pointer", + typeof className === "function" ? className(state) : className, + ) + } + > + {(state) => ( + <> + + {children !== undefined && children !== null && ( + {children} + )} + + )} + + ); +} diff --git a/packages/studio/src/boardui/components/base/segmented-control/segmented-control.tsx b/packages/studio/src/boardui/components/base/segmented-control/segmented-control.tsx new file mode 100644 index 00000000000..20d421c9e55 --- /dev/null +++ b/packages/studio/src/boardui/components/base/segmented-control/segmented-control.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { useDirection } from "../../foundations/direction/direction"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { ReactNode, Ref } from "react"; +import { + ToggleButton as AriaToggleButton, + ToggleButtonGroup as AriaToggleButtonGroup, +} from "react-aria-components"; +import type { + ToggleButtonGroupProps as AriaToggleButtonGroupProps, + ToggleButtonProps as AriaToggleButtonProps, +} from "react-aria-components"; +import { cx } from "../../../utils/cx"; + +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * Figma source: Board UI → dashboard 1 Weekly/Monthly/Yearly switch + * (node 3731:3096). + * + * Single-select segmented control. + * track bg background/tertiary/default, p 4, radius 10 (radius/2lg), gap 2 + * segment px 10, py 4, radius/md (6px) + * selected bg segmented-control/selected (white in light), shadow/2xs, Body 1/Medium, + * text/primary + * unselected transparent, Body 1/Regular, text/secondary + * + * Built on react-aria ToggleButtonGroup with single selection that can't be + * emptied — arrow keys move focus, Space/Enter selects. + */ + +/** + * `solid` (default) is the Figma design — tertiary track, white thumb behind + * the selected segment. `plain` drops the track background and the thumb; the + * selected segment reads only through its text weight/color. + */ +export type SegmentedControlVariant = "solid" | "plain"; + +export interface SegmentedControlProps + extends Omit { + children?: ReactNode; + variant?: SegmentedControlVariant; + ref?: Ref; +} + +type Thumb = { left: number; top: number; width: number; height: number }; + +export function SegmentedControl({ className, children, variant = "solid", ref, ...props }: SegmentedControlProps) { + const direction = useDirection(); + const innerRef = useRef(null); + const [thumb, setThumb] = useState(null); + + useIsomorphicLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + const measure = () => { + const selected = el.querySelector("[data-selected]"); + if (selected) { + setThumb({ + left: selected.offsetLeft, + top: selected.offsetTop, + width: selected.offsetWidth, + height: selected.offsetHeight, + }); + } + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + const observeItems = () => el.querySelectorAll("button").forEach(item => ro.observe(item)); + observeItems(); + const mo = new MutationObserver(() => { observeItems(); measure(); }); + mo.observe(el, { attributes: true, childList: true, characterData: true, subtree: true, attributeFilter: ["data-selected"] }); + return () => { + mo.disconnect(); + ro.disconnect(); + }; + }, [direction]); + + const setRefs = (node: HTMLDivElement | null) => { + innerRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as { current: HTMLDivElement | null }).current = node; + }; + + return ( + + cx( + "relative inline-flex items-start gap-0.5 rounded-2lg", + variant === "solid" && "bg-segmented-control-background p-1", + typeof className === "function" ? className(state) : className, + ) + } + > + {variant === "solid" && thumb && ( + + )} + {children} + + ); +} + +export interface SegmentedControlItemProps extends Omit { + children?: ReactNode; + ref?: Ref; +} + +export function SegmentedControlItem({ + className, + children, + ref, + ...props +}: SegmentedControlItemProps) { + return ( + + cx( + "relative z-10 inline-flex cursor-pointer items-center justify-center rounded-md px-2.5 py-1 text-center whitespace-nowrap", + "transition-colors duration-200 ease", + "outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring", + state.isSelected + ? "text-body-medium text-text-primary" + : "text-body-regular text-text-secondary hover:text-text-primary", + state.isDisabled && "cursor-not-allowed opacity-50", + typeof className === "function" ? className(state) : className, + ) + } + > + {children} + + ); +} diff --git a/packages/studio/src/boardui/components/base/select/select.tsx b/packages/studio/src/boardui/components/base/select/select.tsx new file mode 100644 index 00000000000..2fbad1e5caa --- /dev/null +++ b/packages/studio/src/boardui/components/base/select/select.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { createContext, useContext, useRef, useState } from "react"; +import type { ReactNode, Ref } from "react"; +import { + Button as AriaButton, + ListBox as AriaListBox, + ListBoxItem as AriaListBoxItem, + Popover as AriaPopover, + Select as AriaSelect, + SelectValue as AriaSelectValue, +} from "react-aria-components"; +import type { + ListBoxItemProps as AriaListBoxItemProps, + SelectProps as AriaSelectProps, + SelectValueRenderProps as AriaSelectValueRenderProps, +} from "react-aria-components"; +import { + MENU_ITEM, + MENU_ITEM_ACTIVE, + MENU_ITEMS_CONTAINER, + MENU_POPOVER_SURFACE, + MENU_POPOVER_WIDTH, +} from "../dropdown/menu-styles"; +import { ChevronDownSmall } from "../../foundations/icons/chevrons"; +import { cx } from "../../../utils/cx"; +import { useDismissOnOutsidePress, useTriggerToggle } from "../../../utils/use-dismiss-on-outside-press"; + +/** + * Figma source: Board UI → dashboard 1 dropdown triggers ("All prices" filter + * node 3731:3208, table status dropdown node 3731:3264). + * + * Select with a button trigger styled 1:1 with Figma: + * trigger bg color/white, 1px border/button/default, shadow/xs, + * px 10 py 8, radius/2lg (10px), gap 6, + * Body 1/Medium text color/neutral/950, + * 16px chevron (custom stroke glyph) in text/secondary + * + * The open popover/listbox shares the BoardUI menu recipe with Dropdown: + * radius/2xl panel, p 10, shadow/dropdown, 150ms fade/scale/blur motion, + * and radius/2lg rows with the same hover/selected surface. + * + * Item content is free-form: pass a `StatusDot` + text for status selects. + */ + +export type SelectSize = "sm" | "md"; + +const SelectSizeContext = createContext("md"); + +export interface SelectProps + extends Omit, "children"> { + /** Trigger width. Defaults to hug content; the menu uses the shared 266px width. */ + className?: string; + triggerClassName?: string; + /** Classes for the open popover (e.g. constrain its width). */ + popoverClassName?: string; + /** `md` (default) or `sm` for compact/dense contexts (e.g. compact tables). */ + size?: SelectSize; + children: ReactNode; + items?: Iterable; + /** Customise the trigger's rendered value (e.g. a compact flag + dial code). + * Falls back to the selected item's own content when omitted. */ + renderValue?: ReactNode | ((values: AriaSelectValueRenderProps) => ReactNode); + ref?: Ref; +} + +export function Select({ + className, + triggerClassName, + popoverClassName, + size = "md", + children, + items, + renderValue, + ref, + ...props +}: SelectProps) { + // The popover is `isNonModal`: react-aria's modal scroll lock puts + // `overflow: hidden` on , which — with this app's `h-full` root — + // collapses the page scroll position and visibly yanks the sticky + // sidebar upward every time a select opens. Non-modal skips the lock, + // but also disables react-aria's outside-press dismissal (`isDismissable` + // is hard-coupled to `!isNonModal` in usePopover), so open state is + // controlled here and dismissal restored manually — the same fix as the + // date-picker family (see utils/use-dismiss-on-outside-press.ts). + const triggerRef = useRef(null); + const popoverRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + useDismissOnOutsidePress(isOpen, () => setIsOpen(false), [triggerRef, popoverRef]); + // Pressing the trigger while open closes the popover instead of reopening + const allowOpenChange = useTriggerToggle(isOpen, triggerRef); + + return ( + allowOpenChange(o) && setIsOpen(o)} + className={cx("group flex flex-col", className)} + > + {({ isOpen }) => ( + <> + + + {renderValue as never /* RAC accepts node or render-prop */} + + + + + + {children} + + + + )} + + ); +} + +export interface SelectItemProps extends Omit { + children?: ReactNode; +} + +export function SelectItem({ className, children, ...props }: SelectItemProps) { + const size = useContext(SelectSizeContext); + return ( + + cx( + MENU_ITEM, + size === "sm" ? "px-2 py-1.5 text-body-2-medium" : "text-body-medium", + (state.isFocused || state.isSelected) && MENU_ITEM_ACTIVE, + state.isDisabled && "cursor-not-allowed text-text-disabled", + typeof className === "function" ? className(state) : className, + ) + } + > + {children} + + ); +} diff --git a/packages/studio/src/boardui/components/base/slider/slider.tsx b/packages/studio/src/boardui/components/base/slider/slider.tsx new file mode 100644 index 00000000000..e358dd48ac3 --- /dev/null +++ b/packages/studio/src/boardui/components/base/slider/slider.tsx @@ -0,0 +1,225 @@ +"use client"; + +import type { ReactNode } from "react"; +import { + Label as AriaLabel, + Slider as AriaSlider, + SliderThumb as AriaSliderThumb, + SliderTrack as AriaSliderTrack, +} from "react-aria-components"; +import type { SliderProps as AriaSliderProps } from "react-aria-components"; +import { cx } from "../../../utils/cx"; + +type SliderValue = number | number[]; + +type SliderBaseProps = Omit< + AriaSliderProps, + "children" | "className" | "orientation" +> & { + /** Optional visible label associated with every thumb. */ + label?: ReactNode; + /** Show the exact value in a persistent bubble above each thumb. Default `true`. */ + showTooltip?: boolean; + /** Custom visual formatter for the value bubbles. */ + formatValue?: (value: number, index: number) => ReactNode; + /** Replace the line track with a media timeline; range thumbs become trim handles. */ + trackContent?: ReactNode; + /** Image inside the single media thumb, used for selecting a cover frame. */ + thumbContent?: ReactNode; + className?: string; +}; + +export type SliderProps = SliderBaseProps & { + /** Accessible name for the single thumb. */ + thumbLabel?: string; +}; + +export type RangeSliderProps = SliderBaseProps & { + /** Accessible names for the lower and upper thumbs. */ + thumbLabels?: [string, string]; +}; + +type SliderBaseInternalProps = SliderBaseProps & { + thumbLabels: string[]; +}; + +/** + * Shared single and multi-thumb slider presentation. + * + * React Aria owns the value model, drag behavior, hidden range inputs, + * keyboard controls, RTL handling, and collision rules. BoardUI owns the + * visual track, selected range, thumbs, and exact-value bubbles. + */ +function SliderBase({ + className, + label, + showTooltip = true, + formatValue, + trackContent, + thumbContent, + thumbLabels, + ...props +}: SliderBaseInternalProps) { + return ( + + cx( + "group flex w-full min-w-0 flex-col gap-2", + trackContent && (Array.isArray(props.value ?? props.defaultValue) ? "px-2" : "px-10"), + isDisabled && "cursor-not-allowed opacity-50", + className, + ) + } + > + {({ state }) => { + const isRange = state.values.length > 1; + const startPercent = isRange ? state.getThumbPercent(0) : 0; + const endPercent = state.getThumbPercent(state.values.length - 1); + + return ( + <> + {label != null && ( + + {label} + + )} + + + {({ isHovered }) => { + if (trackContent) return <> +
{trackContent}
+ {isRange && <> + + + + } + {state.values.map((_, index) => cx( + "top-1/2 z-20 flex cursor-ew-resize items-center justify-center outline-none", + isRange ? "h-full w-4 rounded-lg bg-accent-500" : "h-[calc(100%+0.5rem)] w-20 overflow-hidden rounded-xl border-2 border-accent-500 bg-background-primary-default shadow-card", + isDragging && "z-30", + isFocusVisible && "z-30 ring-2 ring-border-focus-ring ring-offset-2 ring-offset-background-primary-default", + isDisabled && "cursor-not-allowed", + )}> + {isRange ? : {thumbContent}} + )} + ; + return ( + <> + + + + {state.values.map((value, index) => { + const valueLabel = + formatValue?.(value, index) ?? + state.getThumbValueLabel(index); + + return ( + + cx( + "top-1/2 size-5 rounded-full border border-border-button-default outline-none", + "flex items-center justify-center bg-control-indicator-background", + "shadow-[0_2px_4px_rgb(0_0_0/0.10),inset_0_1px_0_rgb(255_255_255/0.8)]", + "transition-[scale,box-shadow,border-color] duration-150 ease", + isThumbHovered && !isDragging && "scale-105 border-border-button-hover", + isDragging && "z-20 scale-110 border-accent-500 shadow-[0_3px_8px_color-mix(in_srgb,var(--color-accent-600)_22%,transparent)]", + isFocusVisible && "ring-2 ring-border-focus-ring ring-offset-2", + isDisabled ? "cursor-not-allowed" : "cursor-grab active:cursor-grabbing", + ) + } + > + {({ isDragging, isFocused }) => ( + <> + {showTooltip && ( + + {valueLabel} + + + )} + + + )} + + ); + })} + + ); + }} +
+ + ); + }} +
+ ); +} + +/** A single-value slider with an exact-value bubble above the thumb. */ +export function Slider({ thumbLabel = "Value", ...props }: SliderProps) { + return ; +} + +/** A two-thumb slider for selecting a minimum and maximum value. */ +export function RangeSlider({ + thumbLabels = ["Minimum", "Maximum"], + ...props +}: RangeSliderProps) { + return ; +} diff --git a/packages/studio/src/boardui/components/base/switch/switch.tsx b/packages/studio/src/boardui/components/base/switch/switch.tsx new file mode 100644 index 00000000000..f2343940b22 --- /dev/null +++ b/packages/studio/src/boardui/components/base/switch/switch.tsx @@ -0,0 +1,172 @@ +"use client"; + +import type { ReactNode, Ref } from "react"; +import { Switch as AriaSwitch } from "react-aria-components"; +import type { SwitchProps as AriaSwitchProps } from "react-aria-components"; +import { cx, sortCx } from "../../../utils/cx"; + +/** + * Figma source: Board UI → "Toogles" (node 3856:3807). + * + * Two shapes × three sizes, plus off / on: + * shape pill (Figma "fully rounded") — track & thumb rounded-full + * rectangle (Figma "rectangle") — track radius/md-scale, thumb radius/xs-scale + * size sm 28×16 · md 42×24 · lg 56×32 (track w×h; thumb = h − 2·pad) + * + * Anatomy (all values 1:1 with Figma): + * track off → bg background/tertiary/default (#ebebeb, no border) + * on → Button/Primary gradient (blue/500 → blue/600) + an inset + * ring + top highlight that scales with size. lg is exactly + * the shared `--shadow-checkbox-selected` token; md/sm scale + * the ring (0.75px / 0.5px) and highlight (1.5px / 1px). + * thumb theme-stable white gradient + contact shadow, + * slides by (w − thumb − 2·pad) between states. + * chip small embossed inset in the thumb centre: + * off → white→neutral-100 in light mode; in dark mode it matches + * the track so the mark reads like a hole through the thumb; + * on → switch-on-chip-start→end (accent mixes matching Figma's + * raw #2473fe→#0450e2 for blue), accent/600 border. + * + * The track visual lives in `SwitchTrack` so `Switch` and `SwitchCard` share it + * (mirrors how Checkbox / CheckboxCard share `CheckboxGlyph`). Built on + * react-aria Switch: native toggle semantics, keyboard support, focus-visible + * ring, optional label via `children`. + */ + +export type SwitchSize = "sm" | "md" | "lg"; +export type SwitchShape = "pill" | "rectangle"; + +export const switchSizes = sortCx({ + sm: { + track: "h-4 w-7", + trackRadius: { pill: "rounded-full", rectangle: "rounded-[3px]" }, + onShadow: "shadow-[inset_0_1px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.5px_var(--color-accent-500)]", + thumb: "size-3", + thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" }, + offset: "start-0.5 top-0.5", + travel: "translate-x-3 rtl:-translate-x-3", + chip: "size-[5px] border-[0.25px] shadow-[0_2px_2px_0_rgb(0_0_0/0.03)]", + chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.5px]" }, + }, + md: { + track: "h-6 w-[42px]", + trackRadius: { pill: "rounded-full", rectangle: "rounded-[4.5px]" }, + onShadow: "shadow-[inset_0_1.5px_0_0_rgb(255_255_255/0.25),inset_0_0_0_0.75px_var(--color-accent-500)]", + thumb: "size-[18px]", + thumbRadius: { pill: "rounded-full", rectangle: "rounded-[1.5px]" }, + offset: "start-[3px] top-[3px]", + travel: "translate-x-[18px] rtl:-translate-x-[18px]", + chip: "size-[7.5px] border-[0.375px] shadow-[0_3px_3px_0_rgb(0_0_0/0.03)]", + chipRadius: { pill: "rounded-full", rectangle: "rounded-[0.75px]" }, + }, + lg: { + track: "h-8 w-14", + trackRadius: { pill: "rounded-full", rectangle: "rounded-md" }, + onShadow: "shadow-checkbox-selected", + thumb: "size-6", + thumbRadius: { pill: "rounded-full", rectangle: "rounded-xs" }, + offset: "start-1 top-1", + travel: "translate-x-6 rtl:-translate-x-6", + chip: "size-[10px] border-[0.5px] shadow-[0_4px_4px_0_rgb(0_0_0/0.03)]", + chipRadius: { pill: "rounded-full", rectangle: "rounded-[1px]" }, + }, +}); + +export interface SwitchVisualState { + isSelected: boolean; + isDisabled: boolean; + isFocusVisible: boolean; +} + +/** + * The track + thumb + chip visual. Pure presentation — the react-aria Switch + * (in `Switch` or `SwitchCard`) owns the input and passes its render state in. + */ +export function SwitchTrack({ + state, + size = "md", + shape = "pill", +}: { + state: SwitchVisualState; + size?: SwitchSize; + shape?: SwitchShape; +}) { + const s = switchSizes[size]; + + return ( + + {/* Thumb */} + + {/* Embossed inner chip. Gradient runs bottom→top (`to-t`) — equivalent + to the Figma layer's vertical flip — but WITHOUT a transform, so the + chip stays pixel-snapped/centred (no blurry 1px shift at sub-pixel + sizes) and its drop shadow points down instead of up. */} + + + + ); +} + +export interface SwitchProps extends Omit { + children?: ReactNode; + size?: SwitchSize; + shape?: SwitchShape; + ref?: Ref; +} + +export function Switch({ className, children, size = "md", shape = "pill", ref, ...props }: SwitchProps) { + return ( + + cx( + "group inline-flex items-center gap-2 select-none", + state.isDisabled ? "cursor-not-allowed" : "cursor-pointer", + typeof className === "function" ? className(state) : className, + ) + } + > + {(state) => ( + <> + + {children != null && children !== false && ( + {children} + )} + + )} + + ); +} diff --git a/packages/studio/src/boardui/components/base/tabs/pill-tab.tsx b/packages/studio/src/boardui/components/base/tabs/pill-tab.tsx new file mode 100644 index 00000000000..151c189aae1 --- /dev/null +++ b/packages/studio/src/boardui/components/base/tabs/pill-tab.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useDirection } from "../../foundations/direction/direction"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { ComponentType, HTMLAttributes, ReactNode, Ref } from "react"; +import { cx, sortCx } from "../../../utils/cx"; + +const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; + +/** + * Figma source: Board UI → "ai_chat" → Changes / Browser panel switcher + * (node 4030:5993 region) — the fully-rounded pill tab, in two styles: + * + * pill px 8, py 5, gap 4 + * blue fully rounded (40px); selected bg and icon + label use the + * pill-tab-blue-selected tokens, set to the logo's orange (the AI + * chat panel switcher) + * gray radius/2lg (10px); selected bg background/tertiary, label + * text/primary (quieter — scope/filter rows like the settings + * Tools page) + * idle label text/secondary, icon foreground/icon/secondary, + * background/primary/hover on hover (both styles) + * icon optional 20px leading glyph + * + * `PillTabList` owns the animated selection background so it can slide + * between pills with a subtle spring-like settle. `PillTab` remains a plain + * button (not a React Aria Tabs collection): these switchers drive local view + * state, not routed tab panels. + */ + +type IconComponent = ComponentType<{ + className?: string; + "aria-hidden"?: boolean | "true" | "false"; +}>; + +export type PillTabVariant = "blue" | "gray"; + +const styles = sortCx({ + radius: { + blue: "rounded-full", + gray: "rounded-2lg", + }, + thumb: { + blue: "bg-pill-tab-blue-selected-background", + gray: "bg-background-tertiary-default", + }, + hover: { + blue: "bg-pill-tab-blue-hover-background", + gray: "bg-background-primary-hover", + }, + selectedIcon: { + blue: "text-pill-tab-blue-selected-foreground", + gray: "text-foreground-icon-primary", + }, + selectedLabel: { + blue: "text-pill-tab-blue-selected-foreground", + gray: "text-text-primary", + }, +}); + +type Thumb = { + left: number; + top: number; + width: number; + height: number; + variant: PillTabVariant; +}; + +export interface PillTabListProps extends HTMLAttributes { + ref?: Ref; +} + +export function PillTabList({ children, className, ref, ...props }: PillTabListProps) { + const direction = useDirection(); + const innerRef = useRef(null); + const [thumb, setThumb] = useState(null); + + useIsomorphicLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + const measure = () => { + const selected = el.querySelector("[data-pill-selected]"); + if (!selected) return; + setThumb({ + left: selected.offsetLeft, + top: selected.offsetTop, + width: selected.offsetWidth, + height: selected.offsetHeight, + variant: selected.dataset.pillVariant === "gray" ? "gray" : "blue", + }); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + const observeTabs = () => el.querySelectorAll("button").forEach(tab => ro.observe(tab)); + observeTabs(); + const mo = new MutationObserver(() => { observeTabs(); measure(); }); + mo.observe(el, { + attributes: true, + childList: true, + characterData: true, + subtree: true, + attributeFilter: ["data-pill-selected"], + }); + return () => { + mo.disconnect(); + ro.disconnect(); + }; + }, [direction]); + + const setRefs = (node: HTMLDivElement | null) => { + innerRef.current = node; + if (typeof ref === "function") ref(node); + else if (ref) (ref as { current: HTMLDivElement | null }).current = node; + }; + + return ( +
+ {thumb && ( + + )} + {children} +
+ ); +} + +export function PillTab({ + variant = "blue", + icon: Icon, + isSelected, + onSelect, + children, + className, +}: { + variant?: PillTabVariant; + icon?: IconComponent; + isSelected: boolean; + onSelect: () => void; + children?: ReactNode; + className?: string; +}) { + return ( + + ); +} diff --git a/packages/studio/src/boardui/components/base/textarea/textarea.tsx b/packages/studio/src/boardui/components/base/textarea/textarea.tsx new file mode 100644 index 00000000000..5f320ea00b1 --- /dev/null +++ b/packages/studio/src/boardui/components/base/textarea/textarea.tsx @@ -0,0 +1,323 @@ +"use client"; + +import { + useContext, + useLayoutEffect, + useRef, + useState, + type ReactNode, + type Ref, +} from "react"; +import { Group as AriaGroup, TextArea as AriaTextArea } from "react-aria-components"; +import type { TextAreaProps as AriaTextAreaProps } from "react-aria-components"; +import { HintText } from "../input/hint-text"; +import { Label } from "../input/label"; +import { + TextField, + TextFieldContext, + type InputSize, + type TextFieldProps, +} from "../input/input"; +import { cx, sortCx } from "../../../utils/cx"; + +/** + * Multiline sibling of `Input` — same React Aria plumbing, same field shell, + * same tokens. It reuses `Input`'s `TextField`, `Label` and `HintText`, so a + * textarea sitting next to an input in a form lines up to the pixel. + * + * Architecture (mirrors input.tsx): + * + * TextareaBase — the styled shell. Renders + . + * State (hover / focus / disabled / invalid) arrives as + * render-prop booleans from Aria. + * Textarea — opinionated composition: TextField → Label → TextareaBase + * → HintText (+ optional character counter). The component + * most consumers use. + * + * What a textarea adds over the single-line field: + * - `rows` sets the resting height (3 lines by default) + * - `autoResize` grows the field a line at a time up to `maxRows`, then + * scrolls — the pattern the composer prompt uses + * - `resize` exposes the native grab handle, off when `autoResize` is on + * - `showCount` pairs with `maxLength` for the counter settings pages want + */ + +type TextareaSize = InputSize; + +export type TextareaResize = "none" | "vertical"; + +/* -------------------------------------------------------------------------- */ +/* TextareaBase */ +/* -------------------------------------------------------------------------- */ + +export interface TextareaBaseProps + extends Omit { + size?: TextareaSize; + className?: string; + /** Resting height, in lines. Also the floor when `autoResize` is on. */ + rows?: number; + /** Grow with the content instead of scrolling at `rows`. */ + autoResize?: boolean; + /** Ceiling for `autoResize`, in lines. Past it the field scrolls. */ + maxRows?: number; + /** Native resize handle. Ignored (forced `none`) while `autoResize` is on. */ + resize?: TextareaResize; + /** Class for the field shell. */ + fieldClassName?: string; + /** Ref to the