From 3830364f9cb09dcdaed0bc5feb4f43cb1ca4b9bf Mon Sep 17 00:00:00 2001 From: Wlad Meixner Date: Thu, 17 Sep 2026 14:25:32 +0200 Subject: [PATCH] refactor!: rename the tailwind prefix from lib: to fui: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: every utility this package emits is now fui:* instead of lib:*. Consumers who wrote lib:-prefixed classes against @foomo/ui must rename them. "lib" is a plausible prefix for any in-house library, so it risked colliding in apps that consume more than one. 3658 occurrences across 59 files, plus tailwind.config.js and components.json. The ./lib/* export path is untouched — only the prefix token moved. Also drops the ASCII banner comments and the explanatory docblocks I had added to data-table and the dashboard story. Verified: 19 stories x 2 themes, 194 overlays opened, 0 console errors and 0 elements left unstyled. --- .storybook/preview.ts | 2 +- components.json | 2 +- package.json | 6 +- scripts/rewrite-dts-aliases.mjs | 61 ++++ src/components/accordion.tsx | 16 +- src/components/alert-dialog.tsx | 14 +- src/components/alert.tsx | 12 +- src/components/aspect-ratio.tsx | 2 +- src/components/avatar.tsx | 18 +- src/components/badge.tsx | 14 +- src/components/breadcrumb.tsx | 14 +- src/components/button-group.tsx | 10 +- src/components/button.tsx | 30 +- src/components/calendar.tsx | 70 ++--- src/components/card.tsx | 14 +- src/components/carousel.tsx | 28 +- src/components/chart.tsx | 34 +-- src/components/checkbox.tsx | 57 +++- src/components/command.tsx | 28 +- src/components/context-menu.tsx | 28 +- src/components/data-table.tsx | 252 ++++++++++------ src/components/dialog.tsx | 16 +- src/components/drawer.tsx | 14 +- src/components/dropdown-menu.tsx | 26 +- src/components/empty.tsx | 16 +- src/components/field.tsx | 38 +-- src/components/filters.tsx | 427 ++++++++++++++++++++++++++++ src/components/form.tsx | 2 +- src/components/hover-card.tsx | 4 +- src/components/input-group.tsx | 28 +- src/components/input-otp.tsx | 12 +- src/components/input.tsx | 2 +- src/components/item.tsx | 38 +-- src/components/kbd.tsx | 4 +- src/components/label.tsx | 2 +- src/components/menubar.tsx | 26 +- src/components/navigation-menu.tsx | 26 +- src/components/pagination.tsx | 16 +- src/components/popover.tsx | 10 +- src/components/progress.tsx | 10 +- src/components/radio-group.tsx | 8 +- src/components/resizable.tsx | 6 +- src/components/scroll-area.tsx | 8 +- src/components/select.tsx | 28 +- src/components/separator.tsx | 2 +- src/components/sheet.tsx | 16 +- src/components/sidebar.tsx | 96 +++---- src/components/skeleton.tsx | 2 +- src/components/slider.tsx | 10 +- src/components/sonner.tsx | 12 +- src/components/spinner.tsx | 2 +- src/components/switch.tsx | 4 +- src/components/table-cells.tsx | 152 ++++++++++ src/components/table.tsx | 18 +- src/components/tabs.tsx | 18 +- src/components/textarea.tsx | 2 +- src/components/toggle-group.tsx | 4 +- src/components/toggle.tsx | 12 +- src/components/tooltip.tsx | 6 +- src/extra/typography.tsx | 2 +- src/hooks/use-debounced-callback.ts | 35 +++ src/index.css | 7 +- src/stories/Dashboard.stories.tsx | 242 +++++++--------- src/stories/DataTable.stories.tsx | 14 +- tailwind.config.js | 2 +- 65 files changed, 1465 insertions(+), 672 deletions(-) create mode 100644 scripts/rewrite-dts-aliases.mjs create mode 100644 src/components/filters.tsx create mode 100644 src/components/table-cells.tsx create mode 100644 src/hooks/use-debounced-callback.ts diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 90b8976..c4512f5 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -19,7 +19,7 @@ const withTheme: Decorator = (Story, context) => { document.documentElement.style.backgroundColor = "var(--background)"; document.body.style.backgroundColor = "var(--background)"; document.body.style.color = "var(--foreground)"; - document.body.classList.add("lib:bg-background", "lib:text-foreground"); + document.body.classList.add("fui:bg-background", "fui:text-foreground"); return Story(); }; diff --git a/components.json b/components.json index cf19f41..4167be2 100644 --- a/components.json +++ b/components.json @@ -8,7 +8,7 @@ "css": "src/index.css", "baseColor": "mist", "cssVariables": true, - "prefix": "lib" + "prefix": "fui" }, "aliases": { "components": "@/components", diff --git a/package.json b/package.json index 0f179d4..2bbc856 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@foomo/ui", - "version": "0.1.0-alpha.4", + "version": "0.1.0-alpha.5", "type": "module", "files": [ "dist", @@ -45,7 +45,7 @@ }, "scripts": { "dev": "vite", - "build": "rm -rf ./dist && tsc -p tsconfig.build.json && vite build", + "build": "rm -rf ./dist && tsc -p tsconfig.build.json && node scripts/rewrite-dts-aliases.mjs && vite build", "lint": "biome lint --write ./src", "format": "biome format --write ./src && biome check --fix --unsafe ./src", "preview": "vite preview", @@ -123,4 +123,4 @@ "vite-plugin-static-copy": "^3.1.4", "vitest": "3.2.4" } -} \ No newline at end of file +} diff --git a/scripts/rewrite-dts-aliases.mjs b/scripts/rewrite-dts-aliases.mjs new file mode 100644 index 0000000..2162391 --- /dev/null +++ b/scripts/rewrite-dts-aliases.mjs @@ -0,0 +1,61 @@ +/** + * Rewrite `@/…` path aliases in the emitted declaration files to relative + * specifiers. + * + * `tsc` resolves the alias when type-checking but emits it verbatim into the + * `.d.ts` output, and consumers have no `@/*` mapping — so every such import + * silently degrades to `any` on their side. That is not a cosmetic problem: + * `alert-dialog.d.ts` importing `Button` this way turns + * `Pick, "variant" | "size">` into + * `Pick`, which makes both props *required* for callers. + * + * Vite already rewrites the alias in the `.js` output, so this only has to + * catch the declarations. It runs as a build step rather than a source-level + * change because `regenerate-components` re-runs the shadcn CLI, which always + * emits `@/` imports. + */ +import { readdir, readFile, writeFile } from "node:fs/promises"; +import { join, relative, dirname, sep } from "node:path"; + +const DIST = new URL("../dist/", import.meta.url).pathname; +const ALIAS = /(from\s*")@\/([^"]+)(")/g; + +async function* declarations(dir) { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + yield* declarations(path); + } else if (entry.name.endsWith(".d.ts")) { + yield path; + } + } +} + +let rewritten = 0; +let files = 0; + +for await (const file of declarations(DIST)) { + const source = await readFile(file, "utf8"); + let count = 0; + + const output = source.replace(ALIAS, (_match, open, target, close) => { + count += 1; + // `dist/components/x.d.ts` -> `dist/`, so `@/components/button` + // becomes `./button`. + let path = relative(dirname(file), join(DIST, target)).split(sep).join("/"); + if (!path.startsWith(".")) { + path = `./${path}`; + } + return `${open}${path}${close}`; + }); + + if (count > 0) { + await writeFile(file, output); + rewritten += count; + files += 1; + } +} + +console.log( + `rewrite-dts-aliases: ${rewritten} import(s) across ${files} declaration file(s)`, +); diff --git a/src/components/accordion.tsx b/src/components/accordion.tsx index b3e6725..b3c321b 100644 --- a/src/components/accordion.tsx +++ b/src/components/accordion.tsx @@ -7,7 +7,7 @@ function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + @@ -65,12 +65,12 @@ function AccordionContent({ return (
diff --git a/src/components/alert-dialog.tsx b/src/components/alert-dialog.tsx index 22fd2a6..474c459 100644 --- a/src/components/alert-dialog.tsx +++ b/src/components/alert-dialog.tsx @@ -28,7 +28,7 @@ function AlertDialogOverlay({ svg]:grid-cols-[auto_1fr] lib:has-[>svg]:gap-x-2.5 lib:*:[svg]:row-span-2 lib:*:[svg]:translate-y-0.5 lib:*:[svg]:text-current lib:*:[svg:not([class*=size-])]:size-4", + "fui:group/alert fui:relative fui:grid fui:w-full fui:gap-0.5 fui:rounded-lg fui:border fui:px-4 fui:py-3 fui:text-left fui:text-sm fui:has-data-[slot=alert-action]:relative fui:has-data-[slot=alert-action]:pr-18 fui:has-[>svg]:grid-cols-[auto_1fr] fui:has-[>svg]:gap-x-2.5 fui:*:[svg]:row-span-2 fui:*:[svg]:translate-y-0.5 fui:*:[svg]:text-current fui:*:[svg:not([class*=size-])]:size-4", { variants: { variant: { - default: "lib:bg-card lib:text-card-foreground", + default: "fui:bg-card fui:text-card-foreground", destructive: - "lib:bg-card lib:text-destructive lib:*:data-[slot=alert-description]:text-destructive/90 lib:*:[svg]:text-current", + "fui:bg-card fui:text-destructive fui:*:data-[slot=alert-description]:text-destructive/90 fui:*:[svg]:text-current", }, }, defaultVariants: { @@ -38,7 +38,7 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
svg]/alert:col-start-2 lib:[&_a]:underline lib:[&_a]:underline-offset-3 lib:[&_a]:hover:text-foreground", + "fui:font-medium fui:group-has-[>svg]/alert:col-start-2 fui:[&_a]:underline fui:[&_a]:underline-offset-3 fui:[&_a]:hover:text-foreground", className, )} {...props} @@ -54,7 +54,7 @@ function AlertDescription({
) { return (
); diff --git a/src/components/aspect-ratio.tsx b/src/components/aspect-ratio.tsx index 41f57cc..bcb283e 100644 --- a/src/components/aspect-ratio.tsx +++ b/src/components/aspect-ratio.tsx @@ -14,7 +14,7 @@ function AspectRatio({ "--ratio": ratio, } as React.CSSProperties } - className={cn("lib:relative lib:aspect-(--ratio)", className)} + className={cn("fui:relative fui:aspect-(--ratio)", className)} {...props} /> ); diff --git a/src/components/avatar.tsx b/src/components/avatar.tsx index 9dccd98..4337a2a 100644 --- a/src/components/avatar.tsx +++ b/src/components/avatar.tsx @@ -14,7 +14,7 @@ function Avatar({ data-slot="avatar" data-size={size} className={cn( - "lib:group/avatar lib:relative lib:flex lib:size-8 lib:shrink-0 lib:rounded-full lib:select-none lib:after:absolute lib:after:inset-0 lib:after:rounded-full lib:after:border lib:after:border-border lib:after:mix-blend-darken lib:data-[size=lg]:size-10 lib:data-[size=sm]:size-6 lib:dark:after:mix-blend-lighten", + "fui:group/avatar fui:relative fui:flex fui:size-8 fui:shrink-0 fui:rounded-full fui:select-none fui:after:absolute fui:after:inset-0 fui:after:rounded-full fui:after:border fui:after:border-border fui:after:mix-blend-darken fui:data-[size=lg]:size-10 fui:data-[size=sm]:size-6 fui:dark:after:mix-blend-lighten", className, )} {...props} @@ -27,7 +27,7 @@ function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { ) { svg]:hidden", - "lib:group-data-[size=default]/avatar:size-2.5 lib:group-data-[size=default]/avatar:[&>svg]:size-2", - "lib:group-data-[size=lg]/avatar:size-3 lib:group-data-[size=lg]/avatar:[&>svg]:size-2", + "fui:absolute fui:right-0 fui:bottom-0 fui:z-10 fui:inline-flex fui:items-center fui:justify-center fui:rounded-full fui:bg-primary fui:text-primary-foreground fui:bg-blend-color fui:ring-2 fui:ring-background fui:select-none", + "fui:group-data-[size=sm]/avatar:size-2 fui:group-data-[size=sm]/avatar:[&>svg]:hidden", + "fui:group-data-[size=default]/avatar:size-2.5 fui:group-data-[size=default]/avatar:[&>svg]:size-2", + "fui:group-data-[size=lg]/avatar:size-3 fui:group-data-[size=lg]/avatar:[&>svg]:size-2", className, )} {...props} @@ -72,7 +72,7 @@ function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
svg]:size-4 lib:group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 lib:group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + "fui:relative fui:flex fui:size-8 fui:shrink-0 fui:items-center fui:justify-center fui:rounded-full fui:bg-muted fui:text-sm fui:text-muted-foreground fui:ring-2 fui:ring-background fui:group-has-data-[size=lg]/avatar-group:size-10 fui:group-has-data-[size=sm]/avatar-group:size-6 fui:[&>svg]:size-4 fui:group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 fui:group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", className, )} {...props} diff --git a/src/components/badge.tsx b/src/components/badge.tsx index 09a07d6..e83517a 100644 --- a/src/components/badge.tsx +++ b/src/components/badge.tsx @@ -4,21 +4,21 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "cn"; const badgeVariants = cva( - "lib:group/badge lib:inline-flex lib:h-5 lib:w-fit lib:shrink-0 lib:items-center lib:justify-center lib:gap-1 lib:overflow-hidden lib:rounded-4xl lib:border lib:border-transparent lib:px-2 lib:py-0.5 lib:text-xs lib:font-medium lib:whitespace-nowrap lib:transition-all lib:focus-visible:border-ring lib:focus-visible:ring-[3px] lib:focus-visible:ring-ring/50 lib:has-data-[icon=inline-end]:pr-1.5 lib:has-data-[icon=inline-start]:pl-1.5 lib:aria-invalid:border-destructive lib:aria-invalid:ring-destructive/20 lib:dark:aria-invalid:ring-destructive/40 lib:[&>svg]:pointer-events-none lib:[&>svg]:size-3!", + "fui:group/badge fui:inline-flex fui:h-5 fui:w-fit fui:shrink-0 fui:items-center fui:justify-center fui:gap-1 fui:overflow-hidden fui:rounded-4xl fui:border fui:border-transparent fui:px-2 fui:py-0.5 fui:text-xs fui:font-medium fui:whitespace-nowrap fui:transition-all fui:focus-visible:border-ring fui:focus-visible:ring-[3px] fui:focus-visible:ring-ring/50 fui:has-data-[icon=inline-end]:pr-1.5 fui:has-data-[icon=inline-start]:pl-1.5 fui:aria-invalid:border-destructive fui:aria-invalid:ring-destructive/20 fui:dark:aria-invalid:ring-destructive/40 fui:[&>svg]:pointer-events-none fui:[&>svg]:size-3!", { variants: { variant: { default: - "lib:bg-primary lib:text-primary-foreground lib:[a]:hover:bg-primary/80", + "fui:bg-primary fui:text-primary-foreground fui:[a]:hover:bg-primary/80", secondary: - "lib:bg-secondary lib:text-secondary-foreground lib:[a]:hover:bg-secondary/80", + "fui:bg-secondary fui:text-secondary-foreground fui:[a]:hover:bg-secondary/80", destructive: - "lib:bg-destructive/10 lib:text-destructive lib:focus-visible:ring-destructive/20 lib:dark:bg-destructive/20 lib:dark:focus-visible:ring-destructive/40 lib:[a]:hover:bg-destructive/20", + "fui:bg-destructive/10 fui:text-destructive fui:focus-visible:ring-destructive/20 fui:dark:bg-destructive/20 fui:dark:focus-visible:ring-destructive/40 fui:[a]:hover:bg-destructive/20", outline: - "lib:border-border lib:bg-input/30 lib:text-foreground lib:[a]:hover:bg-muted lib:[a]:hover:text-muted-foreground", + "fui:border-border fui:bg-input/30 fui:text-foreground fui:[a]:hover:bg-muted fui:[a]:hover:text-muted-foreground", ghost: - "lib:hover:bg-muted lib:hover:text-muted-foreground lib:dark:hover:bg-muted/50", - link: "lib:text-primary lib:underline-offset-4 lib:hover:underline", + "fui:hover:bg-muted fui:hover:text-muted-foreground fui:dark:hover:bg-muted/50", + link: "fui:text-link fui:underline-offset-4 fui:hover:underline", }, }, defaultVariants: { diff --git a/src/components/breadcrumb.tsx b/src/components/breadcrumb.tsx index feac157..8c01d79 100644 --- a/src/components/breadcrumb.tsx +++ b/src/components/breadcrumb.tsx @@ -20,7 +20,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
    ) { return (
  1. ); @@ -48,7 +48,7 @@ function BreadcrumbLink({ props: mergeProps<"a">( { className: cn( - "lib:transition-colors lib:hover:text-foreground", + "fui:transition-colors fui:hover:text-foreground", className, ), }, @@ -68,7 +68,7 @@ function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { role="link" aria-disabled="true" aria-current="page" - className={cn("lib:font-normal lib:text-foreground", className)} + className={cn("fui:font-normal fui:text-foreground", className)} {...props} /> ); @@ -84,7 +84,7 @@ function BreadcrumbSeparator({ data-slot="breadcrumb-separator" role="presentation" aria-hidden="true" - className={cn("lib:[&>svg]:size-3.5", className)} + className={cn("fui:[&>svg]:size-3.5", className)} {...props} > {children ?? } @@ -102,13 +102,13 @@ function BreadcrumbEllipsis({ role="presentation" aria-hidden="true" className={cn( - "lib:flex lib:size-5 lib:items-center lib:justify-center lib:[&>svg]:size-4", + "fui:flex fui:size-5 fui:items-center fui:justify-center fui:[&>svg]:size-4", className, )} {...props} > - More + More ); } diff --git a/src/components/button-group.tsx b/src/components/button-group.tsx index 64d6612..5c1a4b4 100644 --- a/src/components/button-group.tsx +++ b/src/components/button-group.tsx @@ -6,14 +6,14 @@ import { cn } from "cn"; import { Separator } from "@/components/separator"; const buttonGroupVariants = cva( - "lib:flex lib:w-fit lib:items-stretch lib:*:focus-visible:relative lib:*:focus-visible:z-10 lib:has-[>[data-slot=button-group]]:gap-2 lib:has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl lib:[&>[data-slot=select-trigger]:not([class*=w-])]:w-fit lib:[&>input]:flex-1", + "fui:flex fui:w-fit fui:items-stretch fui:*:focus-visible:relative fui:*:focus-visible:z-10 fui:has-[>[data-slot=button-group]]:gap-2 fui:has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-4xl fui:[&>[data-slot=select-trigger]:not([class*=w-])]:w-fit fui:[&>input]:flex-1", { variants: { orientation: { horizontal: - "lib:*:data-slot:rounded-r-none lib:[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! lib:[&>[data-slot]~[data-slot]]:rounded-l-none lib:[&>[data-slot]~[data-slot]]:border-l-0", + "fui:*:data-slot:rounded-r-none fui:[&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-4xl! fui:[&>[data-slot]~[data-slot]]:rounded-l-none fui:[&>[data-slot]~[data-slot]]:border-l-0", vertical: - "lib:flex-col lib:*:data-slot:rounded-b-none lib:[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! lib:[&>[data-slot]~[data-slot]]:rounded-t-none lib:[&>[data-slot]~[data-slot]]:border-t-0", + "fui:flex-col fui:*:data-slot:rounded-b-none fui:[&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-4xl! fui:[&>[data-slot]~[data-slot]]:rounded-t-none fui:[&>[data-slot]~[data-slot]]:border-t-0", }, }, defaultVariants: { @@ -48,7 +48,7 @@ function ButtonGroupText({ props: mergeProps<"div">( { className: cn( - "flex items-center gap-2 rounded-4xl border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4", + "fui:flex fui:items-center fui:gap-2 fui:rounded-4xl fui:border fui:bg-muted fui:px-2.5 fui:text-sm fui:font-medium fui:[&_svg]:pointer-events-none fui:[&_svg:not([class*='size-'])]:size-4", className, ), }, @@ -71,7 +71,7 @@ function ButtonGroupSeparator({ data-slot="button-group-separator" orientation={orientation} className={cn( - "lib:relative lib:self-stretch lib:bg-input lib:data-horizontal:mx-px lib:data-horizontal:w-auto lib:data-vertical:my-px lib:data-vertical:h-auto", + "fui:relative fui:self-stretch fui:bg-input fui:data-horizontal:mx-px fui:data-horizontal:w-auto fui:data-vertical:my-px fui:data-vertical:h-auto", className, )} {...props} diff --git a/src/components/button.tsx b/src/components/button.tsx index dcd03f0..351c920 100644 --- a/src/components/button.tsx +++ b/src/components/button.tsx @@ -3,32 +3,32 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "cn"; const buttonVariants = cva( - "lib:group/button lib:inline-flex lib:shrink-0 lib:items-center lib:justify-center lib:rounded-4xl lib:border lib:border-transparent lib:bg-clip-padding lib:text-sm lib:font-medium lib:whitespace-nowrap lib:transition-all lib:outline-none lib:select-none lib:focus-visible:border-ring lib:focus-visible:ring-[3px] lib:focus-visible:ring-ring/50 lib:active:not-aria-[haspopup]:translate-y-px lib:disabled:pointer-events-none lib:disabled:opacity-50 lib:aria-invalid:border-destructive lib:aria-invalid:ring-[3px] lib:aria-invalid:ring-destructive/20 lib:dark:aria-invalid:border-destructive/50 lib:dark:aria-invalid:ring-destructive/40 lib:[&_svg]:pointer-events-none lib:[&_svg]:shrink-0 lib:[&_svg:not([class*=size-])]:size-4", + "fui:group/button fui:inline-flex fui:shrink-0 fui:items-center fui:justify-center fui:rounded-4xl fui:border fui:border-transparent fui:bg-clip-padding fui:text-sm fui:font-medium fui:whitespace-nowrap fui:transition-all fui:outline-none fui:select-none fui:focus-visible:border-ring fui:focus-visible:ring-[3px] fui:focus-visible:ring-ring/50 fui:active:not-aria-[haspopup]:translate-y-px fui:disabled:pointer-events-none fui:disabled:opacity-50 fui:aria-invalid:border-destructive fui:aria-invalid:ring-[3px] fui:aria-invalid:ring-destructive/20 fui:dark:aria-invalid:border-destructive/50 fui:dark:aria-invalid:ring-destructive/40 fui:[&_svg]:pointer-events-none fui:[&_svg]:shrink-0 fui:[&_svg:not([class*=size-])]:size-4", { variants: { variant: { default: - "lib:bg-primary lib:text-primary-foreground lib:hover:bg-primary/80", + "fui:bg-primary fui:text-primary-foreground fui:hover:bg-primary/80", outline: - "lib:border-border lib:bg-input/30 lib:hover:bg-input/50 lib:hover:text-foreground lib:aria-expanded:bg-muted lib:aria-expanded:text-foreground", + "fui:border-border fui:bg-input/30 fui:hover:bg-input/50 fui:hover:text-foreground fui:aria-expanded:bg-muted fui:aria-expanded:text-foreground", secondary: - "lib:bg-secondary lib:text-secondary-foreground lib:hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] lib:aria-expanded:bg-secondary lib:aria-expanded:text-secondary-foreground", + "fui:bg-secondary fui:text-secondary-foreground fui:hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] fui:aria-expanded:bg-secondary fui:aria-expanded:text-secondary-foreground", ghost: - "lib:hover:bg-muted lib:hover:text-foreground lib:aria-expanded:bg-muted lib:aria-expanded:text-foreground lib:dark:hover:bg-muted/50", + "fui:hover:bg-muted fui:hover:text-foreground fui:aria-expanded:bg-muted fui:aria-expanded:text-foreground fui:dark:hover:bg-muted/50", destructive: - "lib:bg-destructive/10 lib:text-destructive lib:hover:bg-destructive/20 lib:focus-visible:border-destructive/40 lib:focus-visible:ring-destructive/20 lib:dark:bg-destructive/20 lib:dark:hover:bg-destructive/30 lib:dark:focus-visible:ring-destructive/40", - link: "lib:text-primary lib:underline-offset-4 lib:hover:underline", + "fui:bg-destructive/10 fui:text-destructive fui:hover:bg-destructive/20 fui:focus-visible:border-destructive/40 fui:focus-visible:ring-destructive/20 fui:dark:bg-destructive/20 fui:dark:hover:bg-destructive/30 fui:dark:focus-visible:ring-destructive/40", + link: "fui:text-link fui:underline-offset-4 fui:hover:underline", }, size: { default: - "lib:h-9 lib:gap-1.5 lib:px-3 lib:has-data-[icon=inline-end]:pr-2.5 lib:has-data-[icon=inline-start]:pl-2.5", - xs: "lib:h-6 lib:gap-1 lib:px-2.5 lib:text-xs lib:has-data-[icon=inline-end]:pr-2 lib:has-data-[icon=inline-start]:pl-2 lib:[&_svg:not([class*=size-])]:size-3", - sm: "lib:h-8 lib:gap-1 lib:px-3 lib:has-data-[icon=inline-end]:pr-2 lib:has-data-[icon=inline-start]:pl-2", - lg: "lib:h-10 lib:gap-1.5 lib:px-4 lib:has-data-[icon=inline-end]:pr-3 lib:has-data-[icon=inline-start]:pl-3", - icon: "lib:size-9", - "icon-xs": "lib:size-6 lib:[&_svg:not([class*=size-])]:size-3", - "icon-sm": "lib:size-8", - "icon-lg": "lib:size-10", + "fui:h-9 fui:gap-1.5 fui:px-3 fui:has-data-[icon=inline-end]:pr-2.5 fui:has-data-[icon=inline-start]:pl-2.5", + xs: "fui:h-6 fui:gap-1 fui:px-2.5 fui:text-xs fui:has-data-[icon=inline-end]:pr-2 fui:has-data-[icon=inline-start]:pl-2 fui:[&_svg:not([class*=size-])]:size-3", + sm: "fui:h-8 fui:gap-1 fui:px-3 fui:has-data-[icon=inline-end]:pr-2 fui:has-data-[icon=inline-start]:pl-2", + lg: "fui:h-10 fui:gap-1.5 fui:px-4 fui:has-data-[icon=inline-end]:pr-3 fui:has-data-[icon=inline-start]:pl-3", + icon: "fui:size-9", + "icon-xs": "fui:size-6 fui:[&_svg:not([class*=size-])]:size-3", + "icon-sm": "fui:size-8", + "icon-lg": "fui:size-10", }, }, defaultVariants: { diff --git a/src/components/calendar.tsx b/src/components/calendar.tsx index d827cf7..a520410 100644 --- a/src/components/calendar.tsx +++ b/src/components/calendar.tsx @@ -32,7 +32,7 @@ function Calendar({ svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className, @@ -45,99 +45,99 @@ function Calendar({ ...formatters, }} classNames={{ - root: cn("lib:w-fit", defaultClassNames.root), + root: cn("fui:w-fit", defaultClassNames.root), months: cn( - "lib:relative lib:flex lib:flex-col lib:gap-4 lib:md:flex-row", + "fui:relative fui:flex fui:flex-col fui:gap-4 fui:md:flex-row", defaultClassNames.months, ), month: cn( - "lib:flex lib:w-full lib:flex-col lib:gap-4", + "fui:flex fui:w-full fui:flex-col fui:gap-4", defaultClassNames.month, ), nav: cn( - "lib:absolute lib:inset-x-0 lib:top-0 lib:flex lib:w-full lib:items-center lib:justify-between lib:gap-1", + "fui:absolute fui:inset-x-0 fui:top-0 fui:flex fui:w-full fui:items-center fui:justify-between fui:gap-1", defaultClassNames.nav, ), button_previous: cn( buttonVariants({ variant: buttonVariant }), - "lib:size-(--cell-size) lib:p-0 lib:select-none lib:aria-disabled:opacity-50", + "fui:size-(--cell-size) fui:p-0 fui:select-none fui:aria-disabled:opacity-50", defaultClassNames.button_previous, ), button_next: cn( buttonVariants({ variant: buttonVariant }), - "lib:size-(--cell-size) lib:p-0 lib:select-none lib:aria-disabled:opacity-50", + "fui:size-(--cell-size) fui:p-0 fui:select-none fui:aria-disabled:opacity-50", defaultClassNames.button_next, ), month_caption: cn( - "lib:flex lib:h-(--cell-size) lib:w-full lib:items-center lib:justify-center lib:px-(--cell-size)", + "fui:flex fui:h-(--cell-size) fui:w-full fui:items-center fui:justify-center fui:px-(--cell-size)", defaultClassNames.month_caption, ), dropdowns: cn( - "lib:flex lib:h-(--cell-size) lib:w-full lib:items-center lib:justify-center lib:gap-1.5 lib:text-sm lib:font-medium", + "fui:flex fui:h-(--cell-size) fui:w-full fui:items-center fui:justify-center fui:gap-1.5 fui:text-sm fui:font-medium", defaultClassNames.dropdowns, ), dropdown_root: cn( - "lib:relative lib:rounded-(--cell-radius)", + "fui:relative fui:rounded-(--cell-radius)", defaultClassNames.dropdown_root, ), dropdown: cn( - "lib:absolute lib:inset-0 lib:bg-popover lib:opacity-0", + "fui:absolute fui:inset-0 fui:bg-popover fui:opacity-0", defaultClassNames.dropdown, ), caption_label: cn( - "lib:font-medium lib:select-none", + "fui:font-medium fui:select-none", captionLayout === "label" - ? "lib:text-sm" - : "lib:flex lib:items-center lib:gap-1 lib:rounded-(--cell-radius) lib:text-sm lib:[&>svg]:size-3.5 lib:[&>svg]:text-muted-foreground", + ? "fui:text-sm" + : "fui:flex fui:items-center fui:gap-1 fui:rounded-(--cell-radius) fui:text-sm fui:[&>svg]:size-3.5 fui:[&>svg]:text-muted-foreground", defaultClassNames.caption_label, ), month_grid: cn( - "lib:w-full lib:border-collapse", + "fui:w-full fui:border-collapse", defaultClassNames.month_grid, ), - weekdays: cn("lib:flex", defaultClassNames.weekdays), + weekdays: cn("fui:flex", defaultClassNames.weekdays), weekday: cn( - "lib:flex-1 lib:rounded-(--cell-radius) lib:text-[0.8rem] lib:font-normal lib:text-muted-foreground lib:select-none", + "fui:flex-1 fui:rounded-(--cell-radius) fui:text-[0.8rem] fui:font-normal fui:text-muted-foreground fui:select-none", defaultClassNames.weekday, ), - week: cn("lib:mt-2 lib:flex lib:w-full", defaultClassNames.week), + week: cn("fui:mt-2 fui:flex fui:w-full", defaultClassNames.week), week_number_header: cn( - "lib:w-(--cell-size) lib:select-none", + "fui:w-(--cell-size) fui:select-none", defaultClassNames.week_number_header, ), week_number: cn( - "lib:text-[0.8rem] lib:text-muted-foreground lib:select-none", + "fui:text-[0.8rem] fui:text-muted-foreground fui:select-none", defaultClassNames.week_number, ), day: cn( - "lib:group/day lib:relative lib:aspect-square lib:h-full lib:w-full lib:rounded-(--cell-radius) lib:p-0 lib:text-center lib:select-none lib:[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", + "fui:group/day fui:relative fui:aspect-square fui:h-full fui:w-full fui:rounded-(--cell-radius) fui:p-0 fui:text-center fui:select-none fui:[&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)", props.showWeekNumber - ? "lib:[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" - : "lib:[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", + ? "fui:[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)" + : "fui:[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)", defaultClassNames.day, ), range_start: cn( - "lib:relative lib:isolate lib:z-0 lib:rounded-l-(--cell-radius) lib:bg-muted lib:after:absolute lib:after:inset-y-0 lib:after:right-0 lib:after:w-4 lib:after:bg-muted", + "fui:relative fui:isolate fui:z-0 fui:rounded-l-(--cell-radius) fui:bg-muted fui:after:absolute fui:after:inset-y-0 fui:after:right-0 fui:after:w-4 fui:after:bg-muted", defaultClassNames.range_start, ), - range_middle: cn("lib:rounded-none", defaultClassNames.range_middle), + range_middle: cn("fui:rounded-none", defaultClassNames.range_middle), range_end: cn( - "lib:relative lib:isolate lib:z-0 lib:rounded-r-(--cell-radius) lib:bg-muted lib:after:absolute lib:after:inset-y-0 lib:after:left-0 lib:after:w-4 lib:after:bg-muted", + "fui:relative fui:isolate fui:z-0 fui:rounded-r-(--cell-radius) fui:bg-muted fui:after:absolute fui:after:inset-y-0 fui:after:left-0 fui:after:w-4 fui:after:bg-muted", defaultClassNames.range_end, ), today: cn( - "lib:rounded-(--cell-radius) lib:bg-muted lib:text-foreground lib:data-[selected=true]:rounded-none", + "fui:rounded-(--cell-radius) fui:bg-muted fui:text-foreground fui:data-[selected=true]:rounded-none", defaultClassNames.today, ), outside: cn( - "lib:text-muted-foreground lib:aria-selected:text-muted-foreground", + "fui:text-muted-foreground fui:aria-selected:text-muted-foreground", defaultClassNames.outside, ), disabled: cn( - "lib:text-muted-foreground lib:opacity-50", + "fui:text-muted-foreground fui:opacity-50", defaultClassNames.disabled, ), - hidden: cn("lib:invisible", defaultClassNames.hidden), + hidden: cn("fui:invisible", defaultClassNames.hidden), ...classNames, }} components={{ @@ -155,7 +155,7 @@ function Calendar({ if (orientation === "left") { return ( ); @@ -164,7 +164,7 @@ function Calendar({ if (orientation === "right") { return ( ); @@ -172,7 +172,7 @@ function Calendar({ return ( ); @@ -183,7 +183,7 @@ function Calendar({ WeekNumber: ({ children, ...props }) => { return ( -
    +
    {children}
    @@ -226,7 +226,7 @@ function CalendarDayButton({ data-range-end={modifiers.range_end} data-range-middle={modifiers.range_middle} className={cn( - "lib:relative lib:isolate lib:z-10 lib:flex lib:aspect-square lib:size-auto lib:w-full lib:min-w-(--cell-size) lib:flex-col lib:gap-1 lib:border-0 lib:leading-none lib:font-normal lib:group-data-[focused=true]/day:relative lib:group-data-[focused=true]/day:z-10 lib:group-data-[focused=true]/day:border-ring lib:group-data-[focused=true]/day:ring-[3px] lib:group-data-[focused=true]/day:ring-ring/50 lib:data-[range-end=true]:rounded-(--cell-radius) lib:data-[range-end=true]:rounded-r-(--cell-radius) lib:data-[range-end=true]:bg-primary lib:data-[range-end=true]:text-primary-foreground lib:data-[range-middle=true]:rounded-none lib:data-[range-middle=true]:bg-muted lib:data-[range-middle=true]:text-foreground lib:data-[range-start=true]:rounded-(--cell-radius) lib:data-[range-start=true]:rounded-l-(--cell-radius) lib:data-[range-start=true]:bg-primary lib:data-[range-start=true]:text-primary-foreground lib:data-[selected-single=true]:bg-primary lib:data-[selected-single=true]:text-primary-foreground lib:dark:hover:text-foreground lib:[&>span]:text-xs lib:[&>span]:opacity-70", + "fui:relative fui:isolate fui:z-10 fui:flex fui:aspect-square fui:size-auto fui:w-full fui:min-w-(--cell-size) fui:flex-col fui:gap-1 fui:border-0 fui:leading-none fui:font-normal fui:group-data-[focused=true]/day:relative fui:group-data-[focused=true]/day:z-10 fui:group-data-[focused=true]/day:border-ring fui:group-data-[focused=true]/day:ring-[3px] fui:group-data-[focused=true]/day:ring-ring/50 fui:data-[range-end=true]:rounded-(--cell-radius) fui:data-[range-end=true]:rounded-r-(--cell-radius) fui:data-[range-end=true]:bg-primary fui:data-[range-end=true]:text-primary-foreground fui:data-[range-middle=true]:rounded-none fui:data-[range-middle=true]:bg-muted fui:data-[range-middle=true]:text-foreground fui:data-[range-start=true]:rounded-(--cell-radius) fui:data-[range-start=true]:rounded-l-(--cell-radius) fui:data-[range-start=true]:bg-primary fui:data-[range-start=true]:text-primary-foreground fui:data-[selected-single=true]:bg-primary fui:data-[selected-single=true]:text-primary-foreground fui:dark:hover:text-foreground fui:[&>span]:text-xs fui:[&>span]:opacity-70", defaultClassNames.day, className, )} diff --git a/src/components/card.tsx b/src/components/card.tsx index 3ada117..c62e252 100644 --- a/src/components/card.tsx +++ b/src/components/card.tsx @@ -11,7 +11,7 @@ function Card({ data-slot="card" data-size={size} className={cn( - "lib:group/card lib:flex lib:flex-col lib:gap-(--card-spacing) lib:overflow-hidden lib:rounded-2xl lib:bg-card lib:py-(--card-spacing) lib:text-sm lib:text-card-foreground lib:ring-1 lib:ring-foreground/10 lib:[--card-spacing:--spacing(6)] lib:has-[>img:first-child]:pt-0 lib:data-[size=sm]:[--card-spacing:--spacing(4)] lib:*:[img:first-child]:rounded-t-xl lib:*:[img:last-child]:rounded-b-xl", + "fui:group/card fui:flex fui:flex-col fui:gap-(--card-spacing) fui:overflow-hidden fui:rounded-2xl fui:bg-card fui:py-(--card-spacing) fui:text-sm fui:text-card-foreground fui:ring-1 fui:ring-foreground/10 fui:[--card-spacing:--spacing(6)] fui:has-[>img:first-child]:pt-0 fui:data-[size=sm]:[--card-spacing:--spacing(4)] fui:*:[img:first-child]:rounded-t-xl fui:*:[img:last-child]:rounded-b-xl", className, )} {...props} @@ -24,7 +24,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
    ) {
    ) { return (
    ); @@ -60,7 +60,7 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
    ) { return (
    ); @@ -83,7 +83,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
    ) { return (
    ) { aria-roledescription="slide" data-slot="carousel-item" className={cn( - "lib:min-w-0 lib:shrink-0 lib:grow-0 lib:basis-full", - orientation === "horizontal" ? "lib:pl-4" : "lib:pt-4", + "fui:min-w-0 fui:shrink-0 fui:grow-0 fui:basis-full", + orientation === "horizontal" ? "fui:pl-4" : "fui:pt-4", className, )} {...props} @@ -182,10 +182,10 @@ function CarouselPrevious({ variant={variant} size={size} className={cn( - "lib:absolute lib:touch-manipulation lib:rounded-full", + "fui:absolute fui:touch-manipulation fui:rounded-full", orientation === "horizontal" - ? "lib:inset-y-0 lib:-left-12 lib:my-auto" - : "lib:-top-12 lib:left-1/2 lib:-translate-x-1/2 lib:rotate-90", + ? "fui:inset-y-0 fui:-left-12 fui:my-auto" + : "fui:-top-12 fui:left-1/2 fui:-translate-x-1/2 fui:rotate-90", className, )} disabled={!canScrollPrev} @@ -193,7 +193,7 @@ function CarouselPrevious({ {...props} > - Previous slide + Previous slide ); } @@ -212,10 +212,10 @@ function CarouselNext({ variant={variant} size={size} className={cn( - "lib:absolute lib:touch-manipulation lib:rounded-full", + "fui:absolute fui:touch-manipulation fui:rounded-full", orientation === "horizontal" - ? "lib:inset-y-0 lib:-right-12 lib:my-auto" - : "lib:-bottom-12 lib:left-1/2 lib:-translate-x-1/2 lib:rotate-90", + ? "fui:inset-y-0 fui:-right-12 fui:my-auto" + : "fui:-bottom-12 fui:left-1/2 fui:-translate-x-1/2 fui:rotate-90", className, )} disabled={!canScrollNext} @@ -223,7 +223,7 @@ function CarouselNext({ {...props} > - Next slide + Next slide ); } diff --git a/src/components/chart.tsx b/src/components/chart.tsx index 013a811..2d407b5 100644 --- a/src/components/chart.tsx +++ b/src/components/chart.tsx @@ -64,7 +64,7 @@ function ChartContainer({ data-slot="chart" data-chart={chartId} className={cn( - "lib:flex lib:aspect-video lib:justify-center lib:text-xs lib:[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground lib:[&_.recharts-cartesian-grid_line[stroke=#ccc]]:stroke-border/50 lib:[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border lib:[&_.recharts-dot[stroke=#fff]]:stroke-transparent lib:[&_.recharts-layer]:outline-hidden lib:[&_.recharts-polar-grid_[stroke=#ccc]]:stroke-border lib:[&_.recharts-radial-bar-background-sector]:fill-muted lib:[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted lib:[&_.recharts-reference-line_[stroke=#ccc]]:stroke-border lib:[&_.recharts-sector]:outline-hidden lib:[&_.recharts-sector[stroke=#fff]]:stroke-transparent lib:[&_.recharts-surface]:outline-hidden", + "fui:flex fui:aspect-video fui:justify-center fui:text-xs fui:[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground fui:[&_.recharts-cartesian-grid_line[stroke=#ccc]]:stroke-border/50 fui:[&_.recharts-curve.recharts-tooltip-cursor]:stroke-border fui:[&_.recharts-dot[stroke=#fff]]:stroke-transparent fui:[&_.recharts-layer]:outline-hidden fui:[&_.recharts-polar-grid_[stroke=#ccc]]:stroke-border fui:[&_.recharts-radial-bar-background-sector]:fill-muted fui:[&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted fui:[&_.recharts-reference-line_[stroke=#ccc]]:stroke-border fui:[&_.recharts-sector]:outline-hidden fui:[&_.recharts-sector[stroke=#fff]]:stroke-transparent fui:[&_.recharts-surface]:outline-hidden", className, )} {...props} @@ -160,7 +160,7 @@ function ChartTooltipContent({ if (labelFormatter) { return ( -
    +
    {labelFormatter(value, payload)}
    ); @@ -170,7 +170,7 @@ function ChartTooltipContent({ return null; } - return
    {value}
    ; + return
    {value}
    ; }, [ label, labelFormatter, @@ -190,12 +190,12 @@ function ChartTooltipContent({ return (
    {!nestLabel ? tooltipLabel : null} -
    +
    {payload .filter((item) => item.type !== "none") .map((item, index) => { @@ -207,8 +207,8 @@ function ChartTooltipContent({
    svg]:h-2.5 lib:[&>svg]:w-2.5 lib:[&>svg]:text-muted-foreground", - indicator === "dot" && "lib:items-center", + "fui:flex fui:w-full fui:flex-wrap fui:items-stretch fui:gap-2 fui:[&>svg]:h-2.5 fui:[&>svg]:w-2.5 fui:[&>svg]:text-muted-foreground", + indicator === "dot" && "fui:items-center", )} > {formatter && item?.value !== undefined && item.name ? ( @@ -221,7 +221,7 @@ function ChartTooltipContent({ !hideIndicator && (
    -
    +
    {nestLabel ? tooltipLabel : null} - + {itemConfig?.label ?? item.name}
    {item.value != null && ( - + {typeof item.value === "number" ? item.value.toLocaleString() : String(item.value)} @@ -290,8 +290,8 @@ function ChartLegendContent({ return (
    @@ -305,14 +305,14 @@ function ChartLegendContent({
    svg]:h-3 lib:[&>svg]:w-3 lib:[&>svg]:text-muted-foreground", + "fui:flex fui:items-center fui:gap-1.5 fui:[&>svg]:h-3 fui:[&>svg]:w-3 fui:[&>svg]:text-muted-foreground", )} > {itemConfig?.icon && !hideIcon ? ( ) : (
    @@ -24,4 +26,53 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { ); } -export { Checkbox }; +type CheckboxWithLabelProps = CheckboxPrimitive.Root.Props & { + label: React.ReactNode; + labelClassName?: string; + wrapperClassName?: string; +}; + +/** + * A checkbox and its caption as one labelled control. + * + * Keeps the id wiring — a generated id tying the label to the input — in one + * place instead of at every call site. + * + * Deliberately not built on `Field`: a caller may already be inside one, and + * a `Field` nested in a `Field` picks up the bordered card treatment. + */ +function CheckboxWithLabel({ + id, + label, + disabled, + className, + labelClassName, + wrapperClassName, + ...props +}: CheckboxWithLabelProps) { + const reactId = React.useId(); + const inputId = id ?? reactId; + + return ( +
    + + +
    + ); +} + +export { Checkbox, CheckboxWithLabel, type CheckboxWithLabelProps }; diff --git a/src/components/command.tsx b/src/components/command.tsx index dbd5381..dbb8842 100644 --- a/src/components/command.tsx +++ b/src/components/command.tsx @@ -19,7 +19,7 @@ function Command({ - + {title} {description} ) { return ( -
    - +
    + - +
    @@ -91,7 +91,7 @@ function CommandList({ ); @@ -120,7 +120,7 @@ function CommandGroup({ ); @@ -150,13 +150,13 @@ function CommandItem({ {children} - + ); } @@ -169,7 +169,7 @@ function CommandShortcut({ ); @@ -41,7 +41,7 @@ function ContextMenuContent({ return ( {children} - + ); } @@ -145,7 +145,7 @@ function ContextMenuSubContent({ return ( @@ -166,13 +166,13 @@ function ContextMenuCheckboxItem({ data-slot="context-menu-checkbox-item" data-inset={inset} className={cn( - "lib:relative lib:flex lib:cursor-default lib:items-center lib:gap-2 lib:rounded-xl lib:py-2 lib:pr-8 lib:pl-3 lib:text-sm lib:outline-hidden lib:select-none lib:focus:bg-accent lib:focus:text-accent-foreground lib:data-inset:pl-9.5 lib:data-disabled:pointer-events-none lib:data-disabled:opacity-50 lib:[&_svg]:pointer-events-none lib:[&_svg]:shrink-0 lib:[&_svg:not([class*=size-])]:size-4", + "fui:relative fui:flex fui:cursor-default fui:items-center fui:gap-2 fui:rounded-xl fui:py-2 fui:pr-8 fui:pl-3 fui:text-sm fui:outline-hidden fui:select-none fui:focus:bg-accent fui:focus:text-accent-foreground fui:data-inset:pl-9.5 fui:data-disabled:pointer-events-none fui:data-disabled:opacity-50 fui:[&_svg]:pointer-events-none fui:[&_svg]:shrink-0 fui:[&_svg:not([class*=size-])]:size-4", className, )} checked={checked} {...props} > - + @@ -206,12 +206,12 @@ function ContextMenuRadioItem({ data-slot="context-menu-radio-item" data-inset={inset} className={cn( - "lib:relative lib:flex lib:cursor-default lib:items-center lib:gap-2 lib:rounded-xl lib:py-2 lib:pr-8 lib:pl-3 lib:text-sm lib:outline-hidden lib:select-none lib:focus:bg-accent lib:focus:text-accent-foreground lib:data-inset:pl-9.5 lib:data-disabled:pointer-events-none lib:data-disabled:opacity-50 lib:[&_svg]:pointer-events-none lib:[&_svg]:shrink-0 lib:[&_svg:not([class*=size-])]:size-4", + "fui:relative fui:flex fui:cursor-default fui:items-center fui:gap-2 fui:rounded-xl fui:py-2 fui:pr-8 fui:pl-3 fui:text-sm fui:outline-hidden fui:select-none fui:focus:bg-accent fui:focus:text-accent-foreground fui:data-inset:pl-9.5 fui:data-disabled:pointer-events-none fui:data-disabled:opacity-50 fui:[&_svg]:pointer-events-none fui:[&_svg]:shrink-0 fui:[&_svg:not([class*=size-])]:size-4", className, )} {...props} > - + @@ -228,7 +228,7 @@ function ContextMenuSeparator({ return ( ); @@ -242,7 +242,7 @@ function ContextMenuShortcut({ > = ReactTable< TData >; -/** - * Typed column helper bound to the `DataTable` feature set. - * - * @example - * const column = createDataTableColumnHelper(); - * const columns = column.columns([ - * column.accessor("amount", { header: "Amount", sortFn: "alphanumeric" }), - * ]); - */ // biome-ignore lint/suspicious/noExplicitAny: mirrors TanStack's RowData constraint. function createDataTableColumnHelper>() { return createColumnHelper(); } -/* -------------------------------------------------------------------------- */ -/* column header */ -/* -------------------------------------------------------------------------- */ - type DataTableColumnHeaderProps< // biome-ignore lint/suspicious/noExplicitAny: mirrors TanStack's RowData constraint. TData extends Record, TValue, > = React.ComponentProps<"div"> & { - /** `Column` is invariant in `TData`/`TValue`, so both must be generic here. */ column: Column; title: string; align?: "start" | "end"; }; -/** - * Sortable header cell. Renders plain text when the column cannot be sorted so - * it stays out of the tab order. - */ function DataTableColumnHeader< // biome-ignore lint/suspicious/noExplicitAny: mirrors TanStack's RowData constraint. TData extends Record, @@ -170,7 +146,7 @@ function DataTableColumnHeader< return (
    {title} @@ -189,8 +165,8 @@ function DataTableColumnHeader<
    {title}
    ); } -/* -------------------------------------------------------------------------- */ -/* selection column */ -/* -------------------------------------------------------------------------- */ - -/** - * Ready-made checkbox column. Spread it as the first entry of `columns`. - */ +/** Checkbox column. Spread it as the first entry of `columns`. */ function dataTableSelectColumn< // biome-ignore lint/suspicious/noExplicitAny: mirrors TanStack's RowData constraint. TData extends Record, @@ -257,10 +227,6 @@ function dataTableSelectColumn< }; } -/* -------------------------------------------------------------------------- */ -/* data table */ -/* -------------------------------------------------------------------------- */ - // biome-ignore lint/suspicious/noExplicitAny: mirrors TanStack's RowData constraint. type DataTableProps> = Omit< React.ComponentProps<"div">, @@ -283,6 +249,59 @@ type DataTableProps> = Omit< empty?: React.ReactNode; /** Extra controls rendered in the toolbar, after the search field. */ toolbar?: React.ReactNode; + /** Optional detail row rendered directly below each data row. */ + renderSubComponent?: (row: DataTableRow) => React.ReactNode; + + /* ------------------------------ server-driven ----------------------------- */ + /* + * Each of the three concerns below is controlled when its state prop is + * passed, and the table then stops doing that work itself — TanStack's + * `manualSorting` / `manualPagination` / `manualFiltering`. Leave them out + * and everything happens client-side over `data`, as before. + * + * This is what a table whose rows come back a page at a time needs: sorting + * or filtering the rows already fetched would only reorder the current + * window while presenting it as the whole result. + */ + + /** + * Sort state owned by the caller. Implies the sort is the server's, unless + * `manualSorting` says otherwise. + */ + sorting?: SortingState; + onSortingChange?: (sorting: SortingState) => void; + /** + * Page state owned by the caller. Implies paging is the server's, unless + * `manualPagination` says otherwise. + */ + pagination?: PaginationState; + onPaginationChange?: (pagination: PaginationState) => void; + /** + * Total unpaged rows, as reported by the server. Required alongside + * `pagination` — without it the table cannot know how many pages exist, and + * "Next" would stop working after the first one. + */ + rowCount?: number; + /** + * Search term owned by the caller. Implies filtering is the server's, unless + * `manualFiltering` says otherwise. + */ + globalFilter?: string; + onGlobalFilterChange?: (value: string) => void; + + /* + * Who does the work, when that is not the same question as who holds the + * state. Each defaults to "the server" when the matching state prop is + * passed, which is the common case. + * + * Set one to `false` alongside its state prop for a table that holds the + * complete result but keeps the state elsewhere — a listing that persists + * its search in the URL, say. The table then still sorts, filters and pages + * its rows itself, and the caller only stores the state. + */ + manualSorting?: boolean; + manualPagination?: boolean; + manualFiltering?: boolean; }; function DataTable< @@ -300,7 +319,18 @@ function DataTable< onRowSelectionChange, empty = "No results.", toolbar, + renderSubComponent, className, + sorting: sortingProp, + onSortingChange: onSortingChangeProp, + pagination: paginationProp, + onPaginationChange: onPaginationChangeProp, + rowCount, + globalFilter: globalFilterProp, + onGlobalFilterChange: onGlobalFilterChangeProp, + manualSorting: manualSortingProp, + manualPagination: manualPaginationProp, + manualFiltering: manualFilteringProp, ...props }: DataTableProps) { const [sorting, setSorting] = React.useState([]); @@ -316,26 +346,65 @@ function DataTable< pageSize: pageSize === false ? Infinity : pageSize, }); + // Server-driven by default whenever the caller holds the state, but a + // caller with the complete result can opt back out. + const manualSorting = manualSortingProp ?? sortingProp !== undefined; + const manualPagination = manualPaginationProp ?? paginationProp !== undefined; + const manualFiltering = manualFilteringProp ?? globalFilterProp !== undefined; + + /** + * TanStack hands updaters through as `T | ((old: T) => T)`, so resolve them + * against the current value before calling the caller's plain setter. + */ + const resolve = (updater: T | ((old: T) => T), current: T): T => + typeof updater === "function" + ? (updater as (old: T) => T)(current) + : updater; + const table = useTable({ features: dataTableFeatures, columns, data, getRowId, enableRowSelection, + manualSorting, + manualPagination, + manualFiltering, + // Only meaningful under `manualPagination`; TanStack derives it from the + // row count otherwise. + rowCount: manualPagination ? rowCount : undefined, state: { - sorting, + sorting: sortingProp ?? sorting, columnFilters, - globalFilter, + globalFilter: globalFilterProp ?? globalFilter, columnVisibility, rowSelection, - pagination, + pagination: paginationProp ?? pagination, + }, + onSortingChange: (updater) => { + if (onSortingChangeProp) { + onSortingChangeProp(resolve(updater, sortingProp ?? sorting)); + return; + } + setSorting(updater); }, - onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, - onGlobalFilterChange: setGlobalFilter, + onGlobalFilterChange: (updater) => { + if (onGlobalFilterChangeProp) { + onGlobalFilterChangeProp(resolve(updater, globalFilterProp ?? "")); + return; + } + setGlobalFilter(updater); + }, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: setRowSelection, - onPaginationChange: setPagination, + onPaginationChange: (updater) => { + if (onPaginationChangeProp) { + onPaginationChangeProp(resolve(updater, paginationProp ?? pagination)); + return; + } + setPagination(updater); + }, }); const selectedRows = table.getSelectedRowModel().rows; @@ -349,17 +418,21 @@ function DataTable< const rows = table.getRowModel().rows; const showToolbar = searchable !== false || hideableColumns || !!toolbar; + const totalRows = + manualPagination && rowCount !== undefined + ? rowCount + : table.getFilteredRowModel().rows.length; return (
    {showToolbar && ( -
    +
    {searchable !== false && ( - + @@ -378,13 +451,13 @@ function DataTable< + } /> - + Toggle columns @@ -401,7 +474,7 @@ function DataTable< column.toggleVisibility(!!value) } onSelect={(event) => event.preventDefault()} - className="lib:capitalize" + className="fui:capitalize" > {column.id} @@ -413,7 +486,7 @@ function DataTable<
    )} -
    +
    {table.getHeaderGroups().map((headerGroup) => ( @@ -440,50 +513,67 @@ function DataTable< {rows.length === 0 ? ( - + {empty} ) : ( - rows.map((row: DataTableRow) => ( - - {row.getVisibleCells().map((cell) => ( - - - - ))} - - )) + rows.map((row: DataTableRow) => { + const subComponent = renderSubComponent?.(row); + + return ( + + + {row.getVisibleCells().map((cell) => ( + + + + ))} + + {subComponent && ( + + + {subComponent} + + + )} + + ); + }) )}
    {pageSize !== false && ( -
    -

    +

    +

    + {/* Under `manualPagination` the row model holds only the page that + was fetched, so the server's total is the honest figure. */} {enableRowSelection - ? `${selectedRows.length} of ${table.getFilteredRowModel().rows.length} row(s) selected` - : `${table.getFilteredRowModel().rows.length} row(s)`} + ? `${selectedRows.length} of ${totalRows} row(s) selected` + : `${totalRows} row(s)`}

    -
    -
    - +
    +
    + Rows per page
    - + Page {table.state.pagination.pageIndex + 1} of{" "} {Math.max(table.getPageCount(), 1)} -
    +
    diff --git a/src/components/dialog.tsx b/src/components/dialog.tsx index 59974c7..b6ed84e 100644 --- a/src/components/dialog.tsx +++ b/src/components/dialog.tsx @@ -30,7 +30,7 @@ function DialogOverlay({ } > - Close + Close )} @@ -82,7 +82,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { return (
    ); @@ -100,7 +100,7 @@ function DialogFooter({
    -
    +
    {children} @@ -72,7 +72,7 @@ function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
    ) {
    ); diff --git a/src/components/dropdown-menu.tsx b/src/components/dropdown-menu.tsx index bfa3898..212269e 100644 --- a/src/components/dropdown-menu.tsx +++ b/src/components/dropdown-menu.tsx @@ -30,7 +30,7 @@ function DropdownMenuContent({ return ( {children} - + ); } @@ -136,7 +136,7 @@ function DropdownMenuSubContent({ @@ -203,13 +203,13 @@ function DropdownMenuRadioItem({ data-slot="dropdown-menu-radio-item" data-inset={inset} className={cn( - "lib:relative lib:flex lib:cursor-default lib:items-center lib:gap-2.5 lib:rounded-xl lib:py-2 lib:pr-8 lib:pl-3 lib:text-sm lib:outline-hidden lib:select-none lib:focus:bg-accent lib:focus:text-accent-foreground lib:focus:**:text-accent-foreground lib:data-inset:pl-9.5 lib:data-disabled:pointer-events-none lib:data-disabled:opacity-50 lib:[&_svg]:pointer-events-none lib:[&_svg]:shrink-0 lib:[&_svg:not([class*=size-])]:size-4", + "fui:relative fui:flex fui:cursor-default fui:items-center fui:gap-2.5 fui:rounded-xl fui:py-2 fui:pr-8 fui:pl-3 fui:text-sm fui:outline-hidden fui:select-none fui:focus:bg-accent fui:focus:text-accent-foreground fui:focus:**:text-accent-foreground fui:data-inset:pl-9.5 fui:data-disabled:pointer-events-none fui:data-disabled:opacity-50 fui:[&_svg]:pointer-events-none fui:[&_svg]:shrink-0 fui:[&_svg:not([class*=size-])]:size-4", className, )} {...props} > @@ -228,7 +228,7 @@ function DropdownMenuSeparator({ return ( ); @@ -242,7 +242,7 @@ function DropdownMenuShortcut({ ) {
    ) {
    ) { } const emptyMediaVariants = cva( - "lib:mb-2 lib:flex lib:shrink-0 lib:items-center lib:justify-center lib:[&_svg]:pointer-events-none lib:[&_svg]:shrink-0", + "fui:mb-2 fui:flex fui:shrink-0 fui:items-center fui:justify-center fui:[&_svg]:pointer-events-none fui:[&_svg]:shrink-0", { variants: { variant: { - default: "lib:bg-transparent", - icon: "lib:flex lib:size-10 lib:shrink-0 lib:items-center lib:justify-center lib:rounded-lg lib:bg-muted lib:text-foreground lib:[&_svg:not([class*=size-])]:size-6", + default: "fui:bg-transparent", + icon: "fui:flex fui:size-10 fui:shrink-0 fui:items-center fui:justify-center fui:rounded-lg fui:bg-muted fui:text-foreground fui:[&_svg:not([class*=size-])]:size-6", }, }, defaultVariants: { @@ -62,7 +62,7 @@ function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
    ) {
    a]:underline lib:[&>a]:underline-offset-4 lib:[&>a:hover]:text-primary", + "fui:text-sm/relaxed fui:text-muted-foreground fui:[&>a]:underline fui:[&>a]:underline-offset-4 fui:[&>a:hover]:text-primary", className, )} {...props} @@ -88,7 +88,7 @@ function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
    ) {
    [data-slot=checkbox-group]]:gap-3 lib:has-[>[data-slot=radio-group]]:gap-3", + "fui:flex fui:flex-col fui:gap-6 fui:has-[>[data-slot=checkbox-group]]:gap-3 fui:has-[>[data-slot=radio-group]]:gap-3", className, )} {...props} @@ -30,7 +30,7 @@ function FieldLegend({ data-slot="field-legend" data-variant={variant} className={cn( - "lib:mb-3 lib:font-medium lib:data-[variant=label]:text-sm lib:data-[variant=legend]:text-base", + "fui:mb-3 fui:font-medium fui:data-[variant=label]:text-sm fui:data-[variant=legend]:text-base", className, )} {...props} @@ -43,7 +43,7 @@ function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
    ) { } const fieldVariants = cva( - "lib:group/field lib:flex lib:w-full lib:gap-3 lib:data-[invalid=true]:text-destructive", + "fui:group/field fui:flex fui:w-full fui:gap-3 fui:data-[invalid=true]:text-destructive", { variants: { orientation: { - vertical: "lib:flex-col lib:*:w-full lib:[&>.sr-only]:w-auto", + vertical: "fui:flex-col fui:*:w-full fui:[&>.sr-only]:w-auto", horizontal: - "lib:flex-row lib:items-center lib:has-[>[data-slot=field-content]]:items-start lib:*:data-[slot=field-label]:flex-auto lib:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + "fui:flex-row fui:items-center fui:has-[>[data-slot=field-content]]:items-start fui:*:data-[slot=field-label]:flex-auto fui:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", responsive: - "lib:flex-col lib:*:w-full lib:@md/field-group:flex-row lib:@md/field-group:items-center lib:@md/field-group:*:w-auto lib:@md/field-group:has-[>[data-slot=field-content]]:items-start lib:@md/field-group:*:data-[slot=field-label]:flex-auto lib:[&>.sr-only]:w-auto lib:@md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + "fui:flex-col fui:*:w-full fui:@md/field-group:flex-row fui:@md/field-group:items-center fui:@md/field-group:*:w-auto fui:@md/field-group:has-[>[data-slot=field-content]]:items-start fui:@md/field-group:*:data-[slot=field-label]:flex-auto fui:[&>.sr-only]:w-auto fui:@md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", }, }, defaultVariants: { @@ -90,7 +90,7 @@ function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
    [data-slot=field]]:rounded-xl lib:has-[>[data-slot=field]]:border lib:has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 lib:has-[>[data-slot=field]]:has-[:focus-visible]:border-ring lib:has-[>[data-slot=field]]:has-[:focus-visible]:ring-[3px] lib:has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 lib:*:data-[slot=field]:p-4 lib:dark:has-data-checked:border-primary/20 lib:dark:has-data-checked:bg-primary/10", - "lib:has-[>[data-slot=field]]:w-full lib:has-[>[data-slot=field]]:flex-col", + "fui:group/field-label fui:peer/field-label fui:flex fui:w-fit fui:gap-2 fui:leading-snug fui:group-data-[disabled=true]/field:opacity-50 fui:has-data-checked:border-primary/30 fui:has-data-checked:bg-primary/5 fui:has-[>[data-slot=field]]:rounded-xl fui:has-[>[data-slot=field]]:border fui:has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-input/40 fui:has-[>[data-slot=field]]:has-[:focus-visible]:border-ring fui:has-[>[data-slot=field]]:has-[:focus-visible]:ring-[3px] fui:has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50 fui:*:data-[slot=field]:p-4 fui:dark:has-data-checked:border-primary/20 fui:dark:has-data-checked:bg-primary/10", + "fui:has-[>[data-slot=field]]:w-full fui:has-[>[data-slot=field]]:flex-col", className, )} {...props} @@ -120,7 +120,7 @@ function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
    ) {

    a]:underline lib:[&>a]:underline-offset-4 lib:[&>a:hover]:text-primary", + "fui:text-left fui:text-sm fui:leading-normal fui:font-normal fui:text-muted-foreground fui:group-has-data-horizontal/field:text-balance fui:[[data-variant=legend]+&]:-mt-1.5", + "fui:last:mt-0 fui:nth-last-2:-mt-1", + "fui:[&>a]:underline fui:[&>a]:underline-offset-4 fui:[&>a:hover]:text-primary", className, )} {...props} @@ -155,15 +155,15 @@ function FieldSeparator({ data-slot="field-separator" data-content={!!children} className={cn( - "lib:relative lib:-my-2 lib:h-5 lib:text-sm lib:group-data-[variant=outline]/field-group:-mb-2", + "fui:relative fui:-my-2 fui:h-5 fui:text-sm fui:group-data-[variant=outline]/field-group:-mb-2", className, )} {...props} > - + {children && ( {children} @@ -199,7 +199,7 @@ function FieldError({ } return ( -

      +
        {uniqueErrors.map( (error, index) => error?.message &&
      • {error.message}
      • , @@ -217,7 +217,7 @@ function FieldError({ role="alert" data-slot="field-error" className={cn( - "lib:text-sm lib:font-normal lib:text-destructive", + "fui:text-sm fui:font-normal fui:text-destructive", className, )} {...props} diff --git a/src/components/filters.tsx b/src/components/filters.tsx new file mode 100644 index 0000000..b24b594 --- /dev/null +++ b/src/components/filters.tsx @@ -0,0 +1,427 @@ +import { cn } from "cn"; +import { format } from "date-fns"; +import { CalendarIcon, SearchIcon, XIcon } from "lucide-react"; +import * as React from "react"; +import { Button } from "@/components/button"; +import { Calendar } from "@/components/calendar"; +import { Checkbox } from "@/components/checkbox"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "@/components/input-group"; +import { Label } from "@/components/label"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/select"; + +/** + * Filter controls for a listing, meant for `DataTable`'s `toolbar` slot. + * + * Every control is named by a label above it. A filter's *value* is a poor + * substitute for its name — a row of dropdowns all reading "Any" says nothing + * about what any of them narrow — so the label carries the name and the + * control carries only the value. + * + * The controls are uncontrolled as to *when* they apply: each calls back on + * change, and the caller decides whether that hits a server immediately or is + * debounced. Text input almost always wants debouncing. + */ + +type FilterOption = { + value: T; + label: string; +}; + +/** + * The row of filters. + * + * Belongs in `DataTable`'s `toolbar` so the filters share a line with the + * table's column menu: given a `toolbar`, `DataTable` renders that row whether + * or not anything else is in it, and a row holding only the column button + * reads as a stray gap. + * + * `flex-1` claims the space beside it, pushing the column menu to the far end. + * `items-end` puts every control on a shared bottom edge, so a checkbox with + * no label above it still lines up with the labelled selects. + */ +function FilterBar({ + className, + children, + actions, +}: { + className?: string; + children: React.ReactNode; + actions?: React.ReactNode; +}) { + return ( +
        + {children} + {actions ? ( +
        + {actions} +
        + ) : null} +
        + ); +} + +/** A labelled filter control. The label sits above and is bound to the input. */ +function FilterField({ + label, + htmlFor, + className, + children, +}: { + label: string; + htmlFor?: string; + className?: string; + children: React.ReactNode; +}) { + return ( +
        + + {children} +
        + ); +} + +/** Free-text filter, with the same search affordance `DataTable` uses. */ +function FilterSearch({ + label = "Search", + value, + onValueChange, + placeholder, + className, +}: { + label?: string; + value: string; + onValueChange: (value: string) => void; + placeholder?: string; + className?: string; +}) { + const id = React.useId(); + + return ( + + + + + + onValueChange(event.target.value)} + /> + + + ); +} + +/** + * Single-choice filter. + * + * Derives base-ui's `items` map from `options`, which is what makes the + * trigger show the selected option's *label*. Without it `Select.Value` + * renders the raw value, so an enum filter reads "multibuy" rather than + * "Multibuy". + */ +function FilterSelect({ + label, + value, + onValueChange, + options, + placeholder = "Any", + className, +}: { + label: string; + value: T; + onValueChange: (value: T) => void; + options: ReadonlyArray>; + placeholder?: string; + className?: string; +}) { + const id = React.useId(); + const items = React.useMemo( + () => + Object.fromEntries(options.map((option) => [option.value, option.label])), + [options], + ); + + return ( + + + + ); +} + +/** + * A period, with either bound optional. + * + * Both ends are independent: "since 1 March", "up to 31 March" and "1–31 + * March" are all expressible, and so is neither. + */ +type FilterPeriod = { + from?: Date; + to?: Date; +}; + +function formatBound(date: Date) { + return format(date, "d MMM yyyy"); +} + +function describePeriod(period: FilterPeriod | undefined, placeholder: string) { + if (period?.from && period.to) { + return `${formatBound(period.from)} – ${formatBound(period.to)}`; + } + if (period?.from) return `From ${formatBound(period.from)}`; + if (period?.to) return `Until ${formatBound(period.to)}`; + return placeholder; +} + +/** + * Period filter: one control for a span, in place of a "from" box and a "to" + * box. + * + * Two calendars in single-date mode rather than one in range mode, because a + * range calendar can only be drawn start-then-end: it cannot express "up to + * this date" with no start, and it forces a start-only pick to collapse into a + * single day. Here each bound is set and cleared on its own. + * + * Each calendar disables the days that would invert the period, so an + * unusable span cannot be selected in the first place. + * + * Values are exchanged as `Date`s. A caller storing the period in a URL or a + * query converts at its own boundary, since the format that belongs there is + * the caller's concern. + */ +function FilterDateRange({ + label, + value, + onValueChange, + placeholder = "Any time", + className, +}: { + label: string; + value: FilterPeriod | undefined; + onValueChange: (period: FilterPeriod | undefined) => void; + placeholder?: string; + className?: string; +}) { + const id = React.useId(); + + const update = (next: FilterPeriod) => + onValueChange(next.from || next.to ? next : undefined); + + return ( + + + + + {describePeriod(value, placeholder)} + + } + /> + +
        + update({ from, to: value?.to })} + /> + update({ from: value?.from, to })} + /> +
        +
        +
        +
        + ); +} + +function PeriodBound({ + title, + selected, + defaultMonth, + disabled, + onSelect, +}: { + title: string; + selected: Date | undefined; + defaultMonth: Date | undefined; + disabled: React.ComponentProps["disabled"]; + onSelect: (date: Date | undefined) => void; +}) { + return ( +
        +
        + + {title} + + {selected ? ( + + ) : null} +
        + +
        + ); +} + +/** + * Boolean filter. + * + * Its caption follows the box rather than sitting above it: a checkbox reads + * as "[x] Active only", and a label overhead would detach the words from the + * control they toggle. The height matches the controls beside it so the shared + * bottom edge still holds. + */ +function FilterToggle({ + label, + checked, + onCheckedChange, +}: { + label: string; + checked: boolean; + onCheckedChange: (checked: boolean) => void; +}) { + const id = React.useId(); + + return ( +
        + onCheckedChange(!!next)} + /> + +
        + ); +} + +/** + * Clears every filter at once. + * + * Rendered only when something is actually filtered: where filters apply on + * change it is easy to narrow a result and have no obvious way back, while a + * permanently visible "Clear" is another dead control in the row. + */ +function FilterReset({ + active, + onReset, + label = "Clear filters", +}: { + active: boolean; + onReset: () => void; + label?: string; +}) { + if (!active) return null; + + return ( + + ); +} + +export { + FilterBar, + FilterDateRange, + FilterField, + type FilterOption, + type FilterPeriod, + FilterReset, + FilterSearch, + FilterSelect, + FilterToggle, +}; diff --git a/src/components/form.tsx b/src/components/form.tsx index 71c4fe8..915dcb6 100644 --- a/src/components/form.tsx +++ b/src/components/form.tsx @@ -104,7 +104,7 @@ function FormLabel({ diff --git a/src/components/hover-card.tsx b/src/components/hover-card.tsx index 564b11a..e6a5e4a 100644 --- a/src/components/hover-card.tsx +++ b/src/components/hover-card.tsx @@ -30,12 +30,12 @@ function HoverCardContent({ alignOffset={alignOffset} side={side} sideOffset={sideOffset} - className="lib:isolate lib:z-50" + className="fui:isolate fui:z-50" > ) { data-slot="input-group" role="group" className={cn( - "lib:group/input-group lib:relative lib:flex lib:h-9 lib:w-full lib:min-w-0 lib:items-center lib:rounded-4xl lib:border lib:border-input lib:bg-input/30 lib:transition-colors lib:outline-none lib:in-data-[slot=combobox-content]:focus-within:border-inherit lib:in-data-[slot=combobox-content]:focus-within:ring-0 lib:has-data-[align=block-end]:rounded-2xl lib:has-data-[align=block-start]:rounded-2xl lib:has-[[data-slot=input-group-control]:focus-visible]:border-ring lib:has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] lib:has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 lib:has-[[data-slot][aria-invalid=true]]:border-destructive lib:has-[[data-slot][aria-invalid=true]]:ring-[3px] lib:has-[[data-slot][aria-invalid=true]]:ring-destructive/20 lib:has-[textarea]:rounded-xl lib:has-[>[data-align=block-end]]:h-auto lib:has-[>[data-align=block-end]]:flex-col lib:has-[>[data-align=block-start]]:h-auto lib:has-[>[data-align=block-start]]:flex-col lib:has-[>textarea]:h-auto lib:dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 lib:has-[>[data-align=block-end]]:[&>input]:pt-3 lib:has-[>[data-align=block-start]]:[&>input]:pb-3 lib:has-[>[data-align=inline-end]]:[&>input]:pr-1.5 lib:has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + "fui:group/input-group fui:relative fui:flex fui:h-9 fui:w-full fui:min-w-0 fui:items-center fui:rounded-4xl fui:border fui:border-input fui:bg-input/30 fui:transition-colors fui:outline-none fui:in-data-[slot=combobox-content]:focus-within:border-inherit fui:in-data-[slot=combobox-content]:focus-within:ring-0 fui:has-data-[align=block-end]:rounded-2xl fui:has-data-[align=block-start]:rounded-2xl fui:has-[[data-slot=input-group-control]:focus-visible]:border-ring fui:has-[[data-slot=input-group-control]:focus-visible]:ring-[3px] fui:has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 fui:has-[[data-slot][aria-invalid=true]]:border-destructive fui:has-[[data-slot][aria-invalid=true]]:ring-[3px] fui:has-[[data-slot][aria-invalid=true]]:ring-destructive/20 fui:has-[textarea]:rounded-xl fui:has-[>[data-align=block-end]]:h-auto fui:has-[>[data-align=block-end]]:flex-col fui:has-[>[data-align=block-start]]:h-auto fui:has-[>[data-align=block-start]]:flex-col fui:has-[>textarea]:h-auto fui:dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 fui:has-[>[data-align=block-end]]:[&>input]:pt-3 fui:has-[>[data-align=block-start]]:[&>input]:pb-3 fui:has-[>[data-align=inline-end]]:[&>input]:pr-1.5 fui:has-[>[data-align=inline-start]]:[&>input]:pl-1.5", className, )} {...props} @@ -23,18 +23,18 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { } const inputGroupAddonVariants = cva( - "lib:flex lib:h-auto lib:cursor-text lib:items-center lib:justify-center lib:gap-2 lib:py-2 lib:text-sm lib:font-medium lib:text-muted-foreground lib:select-none lib:group-data-[disabled=true]/input-group:opacity-50 lib:**:data-[slot=kbd]:rounded-4xl lib:**:data-[slot=kbd]:bg-muted-foreground/10 lib:**:data-[slot=kbd]:px-1.5 lib:[&>svg:not([class*=size-])]:size-4", + "fui:flex fui:h-auto fui:cursor-text fui:items-center fui:justify-center fui:gap-2 fui:py-2 fui:text-sm fui:font-medium fui:text-muted-foreground fui:select-none fui:group-data-[disabled=true]/input-group:opacity-50 fui:**:data-[slot=kbd]:rounded-4xl fui:**:data-[slot=kbd]:bg-muted-foreground/10 fui:**:data-[slot=kbd]:px-1.5 fui:[&>svg:not([class*=size-])]:size-4", { variants: { align: { "inline-start": - "lib:order-first lib:pl-3 lib:has-[>button]:-ml-1 lib:has-[>kbd]:ml-[-0.15rem]", + "fui:order-first fui:pl-3 fui:has-[>button]:-ml-1 fui:has-[>kbd]:ml-[-0.15rem]", "inline-end": - "lib:order-last lib:pr-3 lib:has-[>button]:-mr-1 lib:has-[>kbd]:mr-[-0.15rem]", + "fui:order-last fui:pr-3 fui:has-[>button]:-mr-1 fui:has-[>kbd]:mr-[-0.15rem]", "block-start": - "lib:order-first lib:w-full lib:justify-start lib:px-3 lib:pt-3 lib:group-has-[>input]/input-group:pt-3 lib:[.border-b]:pb-3", + "fui:order-first fui:w-full fui:justify-start fui:px-3 fui:pt-3 fui:group-has-[>input]/input-group:pt-3 fui:[.border-b]:pb-3", "block-end": - "lib:order-last lib:w-full lib:justify-start lib:px-3 lib:pb-3 lib:group-has-[>input]/input-group:pb-3 lib:[.border-t]:pt-3", + "fui:order-last fui:w-full fui:justify-start fui:px-3 fui:pb-3 fui:group-has-[>input]/input-group:pb-3 fui:[.border-t]:pt-3", }, }, defaultVariants: { @@ -66,14 +66,14 @@ function InputGroupAddon({ } const inputGroupButtonVariants = cva( - "lib:flex lib:items-center lib:gap-2 lib:rounded-4xl lib:text-sm lib:shadow-none", + "fui:flex fui:items-center fui:gap-2 fui:rounded-4xl fui:text-sm fui:shadow-none", { variants: { size: { - xs: "lib:h-6 lib:gap-1 lib:px-1.5 lib:[&>svg:not([class*=size-])]:size-3.5", - sm: "lib:", - "icon-xs": "lib:size-6 lib:p-0 lib:has-[>svg]:p-0", - "icon-sm": "lib:size-8 lib:p-0 lib:has-[>svg]:p-0", + xs: "fui:h-6 fui:gap-1 fui:px-1.5 fui:[&>svg:not([class*=size-])]:size-3.5", + sm: "fui:", + "icon-xs": "fui:size-6 fui:p-0 fui:has-[>svg]:p-0", + "icon-sm": "fui:size-8 fui:p-0 fui:has-[>svg]:p-0", }, }, defaultVariants: { @@ -105,7 +105,7 @@ function InputGroupText({ className, ...props }: React.ComponentProps<"span">) { return ( ); @@ -29,7 +29,7 @@ function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
        {char} {hasFakeCaret && ( -
        -
        +
        +
        )}
        @@ -71,7 +71,7 @@ function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { return (
        diff --git a/src/components/input.tsx b/src/components/input.tsx index b7e7ad2..5e58b4f 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "lib:h-9 lib:w-full lib:min-w-0 lib:rounded-4xl lib:border lib:border-input lib:bg-input/30 lib:px-3 lib:py-1 lib:text-base lib:transition-colors lib:outline-none lib:file:inline-flex lib:file:h-7 lib:file:border-0 lib:file:bg-transparent lib:file:text-sm lib:file:font-medium lib:file:text-foreground lib:placeholder:text-muted-foreground lib:focus-visible:border-ring lib:focus-visible:ring-[3px] lib:focus-visible:ring-ring/50 lib:disabled:pointer-events-none lib:disabled:cursor-not-allowed lib:disabled:opacity-50 lib:aria-invalid:border-destructive lib:aria-invalid:ring-[3px] lib:aria-invalid:ring-destructive/20 lib:md:text-sm lib:dark:aria-invalid:border-destructive/50 lib:dark:aria-invalid:ring-destructive/40", + "fui:h-9 fui:w-full fui:min-w-0 fui:rounded-4xl fui:border fui:border-input fui:bg-input/30 fui:px-3 fui:py-1 fui:text-base fui:transition-colors fui:outline-none fui:file:inline-flex fui:file:h-7 fui:file:border-0 fui:file:bg-transparent fui:file:text-sm fui:file:font-medium fui:file:text-foreground fui:placeholder:text-muted-foreground fui:focus-visible:border-ring fui:focus-visible:ring-[3px] fui:focus-visible:ring-ring/50 fui:disabled:pointer-events-none fui:disabled:cursor-not-allowed fui:disabled:opacity-50 fui:aria-invalid:border-destructive fui:aria-invalid:ring-[3px] fui:aria-invalid:ring-destructive/20 fui:md:text-sm fui:dark:aria-invalid:border-destructive/50 fui:dark:aria-invalid:ring-destructive/40", className, )} {...props} diff --git a/src/components/item.tsx b/src/components/item.tsx index ffdf49a..332c126 100644 --- a/src/components/item.tsx +++ b/src/components/item.tsx @@ -12,7 +12,7 @@ function ItemGroup({ className, ...props }: React.ComponentProps<"div">) { role="list" data-slot="item-group" className={cn( - "lib:group/item-group lib:flex lib:w-full lib:flex-col lib:gap-4 lib:has-data-[size=sm]:gap-2.5 lib:has-data-[size=xs]:gap-2", + "fui:group/item-group fui:flex fui:w-full fui:flex-col fui:gap-4 fui:has-data-[size=sm]:gap-2.5 fui:has-data-[size=xs]:gap-2", className, )} {...props} @@ -28,25 +28,25 @@ function ItemSeparator({ ); } const itemVariants = cva( - "lib:group/item lib:flex lib:w-full lib:flex-wrap lib:items-center lib:rounded-2xl lib:border lib:text-sm lib:transition-colors lib:duration-100 lib:outline-none lib:focus-visible:border-ring lib:focus-visible:ring-[3px] lib:focus-visible:ring-ring/50 lib:[a]:transition-colors lib:[a]:hover:bg-muted", + "fui:group/item fui:flex fui:w-full fui:flex-wrap fui:items-center fui:rounded-2xl fui:border fui:text-sm fui:transition-colors fui:duration-100 fui:outline-none fui:focus-visible:border-ring fui:focus-visible:ring-[3px] fui:focus-visible:ring-ring/50 fui:[a]:transition-colors fui:[a]:hover:bg-muted", { variants: { variant: { - default: "lib:border-transparent", - outline: "lib:border-border", - muted: "lib:border-transparent lib:bg-muted/50", + default: "fui:border-transparent", + outline: "fui:border-border", + muted: "fui:border-transparent fui:bg-muted/50", }, size: { - default: "lib:gap-3.5 lib:px-4 lib:py-3.5", - sm: "lib:gap-3.5 lib:px-3.5 lib:py-3", - xs: "lib:gap-2.5 lib:px-3 lib:py-2.5 lib:in-data-[slot=dropdown-menu-content]:p-0", + default: "fui:gap-3.5 fui:px-4 fui:py-3.5", + sm: "fui:gap-3.5 fui:px-3.5 fui:py-3", + xs: "fui:gap-2.5 fui:px-3 fui:py-2.5 fui:in-data-[slot=dropdown-menu-content]:p-0", }, }, defaultVariants: { @@ -81,14 +81,14 @@ function Item({ } const itemMediaVariants = cva( - "lib:flex lib:shrink-0 lib:items-center lib:justify-center lib:gap-2 lib:group-has-data-[slot=item-description]/item:translate-y-0.5 lib:group-has-data-[slot=item-description]/item:self-start lib:[&_svg]:pointer-events-none", + "fui:flex fui:shrink-0 fui:items-center fui:justify-center fui:gap-2 fui:group-has-data-[slot=item-description]/item:translate-y-0.5 fui:group-has-data-[slot=item-description]/item:self-start fui:[&_svg]:pointer-events-none", { variants: { variant: { - default: "lib:bg-transparent", - icon: "lib:[&_svg:not([class*=size-])]:size-4", + default: "fui:bg-transparent", + icon: "fui:[&_svg:not([class*=size-])]:size-4", image: - "lib:size-10 lib:overflow-hidden lib:rounded-lg lib:group-data-[size=sm]/item:size-8 lib:group-data-[size=xs]/item:size-6 lib:group-data-[size=xs]/item:rounded-md lib:[&_img]:size-full lib:[&_img]:object-cover", + "fui:size-10 fui:overflow-hidden fui:rounded-lg fui:group-data-[size=sm]/item:size-8 fui:group-data-[size=xs]/item:size-6 fui:group-data-[size=xs]/item:rounded-md fui:[&_img]:size-full fui:[&_img]:object-cover", }, }, defaultVariants: { @@ -117,7 +117,7 @@ function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
        ) {
        ) {

        a]:underline lib:[&>a]:underline-offset-4 lib:[&>a:hover]:text-primary", + "fui:line-clamp-2 fui:text-left fui:text-sm fui:font-normal fui:text-muted-foreground fui:[&>a]:underline fui:[&>a]:underline-offset-4 fui:[&>a:hover]:text-primary", className, )} {...props} @@ -155,7 +155,7 @@ function ItemActions({ className, ...props }: React.ComponentProps<"div">) { return (

        ); @@ -166,7 +166,7 @@ function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
        ) {
        ) { ) { return ( ); diff --git a/src/components/label.tsx b/src/components/label.tsx index 0eda820..ae45e9b 100644 --- a/src/components/label.tsx +++ b/src/components/label.tsx @@ -8,7 +8,7 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {