refactor(frontend): drop dead UI components and phantom deps, add knip (MUL-6353) (#7147)

* refactor(frontend): drop dead UI components and phantom deps, add knip

`.npmrc` sets shamefully-hoist=true, which hides both halves of dependency
drift: an undeclared import still resolves, and an obsolete declaration still
installs. Neither build, typecheck, nor lint reads the import graph, so
package.json stopped being the source of truth for what is actually used.

Removed 16 zero-reference components from packages/ui (1,907 lines, all
`pnpm ui:add` output that never got wired up) plus mention-hover-card.tsx,
which was written but never imported — member and agent mentions render as a
plain `.mention` span. The comment in issue-hover-card.tsx claiming otherwise
was stale; corrected to match rich-content.tsx.

Deleting pagination.tsx orphaned its two i18n keys, dropped from all four
locales and from the `ui` namespace type.

Dependency declarations removed where nothing imports them:
- vaul, embla-carousel-react — only the deleted drawer/carousel used them
- date-fns, @tanstack/react-query-devtools — zero imports repo-wide
- motion, tw-animate-css from packages/ui — real users are views/desktop/web
- 44 of apps/web's 58 declarations, including all 15 @tiptap/* entries.
  One had already drifted: apps/web pinned tailwind-merge ^3.5.0 against the
  catalog's ^3.4.0, a second source of truth for the same package.

Added @types/hast to packages/views, which imports it directly.

knip enforces this going forward, scoped to files/dependencies/unlisted —
`exports` is unusable here because ui and views are consumed through per-file
exports maps. The CI step runs in warn mode (continue-on-error) and reports 9
pre-existing findings left out of this cleanup; clear those before promoting
it to blocking.

Verified: build, typecheck, lint green across web/desktop/ui/core/views.
6,503 tests pass; the 9 failures are pre-existing on a clean tree (a local
jsdom localStorage issue). Web dev server boots and serves /login 200.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): cover the packages/ui blind spot knip cannot see

Review caught that knip provided no regression protection for the exact
thing the preceding cleanup was about. knip derives entry points from a
workspace's package.json `exports` — graph/build.js calls
`getEntrySpecifiersFromManifest` unconditionally, with no config flag to opt
out — and packages/ui exports four wildcards. Each expands to a glob over its
whole directory, so every file under components/ui, components/common,
markdown, and hooks registers as an entry point and can never be reported
unused. A zero-reference probe component confirmed it: knip's output was
byte-identical with and without it.

`entry: []`, negated `entry` patterns, `--production`, and `--strict` were all
tried. Manifest entries come from a separate code path that none of them
subtract from. packages/views is unaffected because 44 of its 45 exports name
a specific file, which is why knip does flag dead files there.

Added scripts/check-ui-wildcard-exports.mjs to cover those four directories,
blocking from the start since it reports nothing once the one file it found is
gone. It reads the wildcards from the manifest, so narrowing an export hands
that directory back to knip without the two overlapping. Its test asserts all
three states: clean tree passes, a stranded component fails and is named, and
importing that component clears it again.

The check found packages/ui/hooks/use-auto-scroll.ts — 80 lines whose only
occurrence repo-wide was its own definition. Same category as the 16
components already removed, and invisible to knip for the same reason.

Also from review:
- The path filter missed knip.jsonc and apps/docs/**, so config-only and
  docs-only PRs skipped knip even though it analyzes docs.
- The knip step paired `continue-on-error` with `|| true`, which zeroed the
  exit code before Actions saw it — no warning surfaced, only log text.
  Dropping `|| true` makes the warn phase visible and the later promotion to
  blocking a one-line change.
- Corrected the knip.jsonc comment that claimed apps were the only entry
  points for all three shared packages. True for views and core, never true
  for packages/ui.

Verified: build, typecheck, lint green (12/12, 0 errors). 6,503 tests pass;
the same 9 pre-existing failures as before, reproduced on a clean tree.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): resolve import paths in the ui export check instead of matching names

Review found the check passed a dead component whenever any file elsewhere in
the repo imported a same-named module. The relative-import branch was a
whole-repo text regex on the basename, so `../components/option-card` in
packages/views vouched for an untouched packages/ui/components/ui/option-card.

That is not a corner case: 17 of the 59 guarded files already share a basename
with another file in the repo — `button`, `card`, `input`, `label`, `avatar`,
`tabs`, `switch`, `skeleton`, `separator`, `theme-provider` among them. shadcn
names are generic by construction, so the hole covered the most likely names a
future `pnpm ui:add` would produce.

Specifiers are now resolved against each importing file's own directory and
compared against real paths, with `@multica/ui/...` mapped through the same
manifest wildcards the candidate list is built from.

Liveness is now reachability from outside the guarded directories rather than
a reference count, so two dead components importing only each other no longer
vouch for one another.

Also implements the exemption the failure message promises: a file named by a
non-wildcard export is an entry point in its own right and is skipped. The
candidate loop previously read the wildcard directories unconditionally, so
following that advice would not have cleared the check.

Tests cover all five states: clean tree, stranded component named, cleared by a
real import, the option-card basename collision, and a mutually-importing dead
pair reported as two files.

Verified: check and its tests pass, knip unchanged at 8 files + 1 dependency,
build/typecheck/lint 12/12 green.

Co-authored-by: multica-agent <[email protected]>

* fix(ci): parse imports with the TypeScript parser, not a source regex

Review found the scanner counted commented-out imports as real edges. It ran
a regex over raw source, so a component kept its liveness from a line that
does not execute — and "delete the last real import, leave the comment
behind" is a normal step in a refactor, which makes the false edge appear at
exactly the moment the component stops being used.

Edges now come from real syntax nodes: static import/export declarations,
`import()`, `require()`, and `import("x")` type nodes. `vi.mock("...")` is
excluded on purpose — it names a module without importing it, so a component
whose only remaining mention is a test mock is dead, and counting it would let
one outlive its last real use.

Scope, measured rather than assumed: 52 specifiers in this repo appear only in
comments or prose, spread over 43 files, so the mechanism is live. None of
them currently resolve into packages/ui, so no dead component is being missed
today — this closes a latent hole, not an active miss. (An earlier count of 3
was wrong: those were `lazy(() => import(...))` calls that my throwaway
measurement script failed to treat as real.)

Test gains a sixth state: a component referenced only by a commented-out
import and a string containing one must still be reported.

Verified: six-state test passes, clean tree passes in 0.9s over 2,089 files,
knip unchanged at 8 files + 1 dependency, build/typecheck/lint 12/12 green.
`typescript` is already a root devDependency, so no new declaration.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Jiayuan Zhang
2026-08-18 22:29:33 +08:00
committed by GitHub
co-authored by multica-agent Lambda
parent 30ef4aa758
commit 41ddf6c0a1
34 changed files with 1112 additions and 2414 deletions
+38 -5
View File
@@ -34,24 +34,28 @@ jobs:
id: filter
uses: dorny/paths-filter@v3
with:
# apps/docs is excluded from the frontend turbo run, so a
# docs-only change does not need this job. apps/mobile has its
# own mobile-verify workflow. Everything else the frontend job
# touches is listed here; bias toward over-matching since a
# missed path silently skips validation.
# apps/docs is excluded from the frontend turbo run, but knip does
# analyze it, so a docs-only change still needs this job.
# apps/mobile has its own mobile-verify workflow. Everything else
# the frontend job touches is listed here; bias toward
# over-matching since a missed path silently skips validation.
filters: |
frontend:
- 'apps/web/**'
- 'apps/desktop/**'
- 'apps/docs/**'
- 'packages/**'
- 'package.json'
- '.npmrc'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'knip.jsonc'
- '.github/workflows/ci.yml'
- 'scripts/generate-reserved-slugs.mjs'
- 'server/internal/handler/reserved_slugs.json'
- 'scripts/check-ui-wildcard-exports.mjs'
- 'scripts/check-ui-wildcard-exports.test.sh'
- 'scripts/selfhost-config.test.sh'
- 'scripts/drop-database.sh'
- 'scripts/remove-worktree.sh'
@@ -213,6 +217,35 @@ jobs:
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint --filter='!@multica/docs' --filter='!@multica/mobile'
# Blocking from the start: it reports nothing today, and it covers the
# one regression knip structurally cannot see. packages/ui exports four
# wildcards, which knip expands into entry-point globs over every file
# in those directories — so a newly stranded component there always
# reads as "used". See the script header for the options ruled out.
- name: Check packages/ui wildcard exports
if: ${{ needs.changes.outputs.frontend == 'true' }}
run: |
bash scripts/check-ui-wildcard-exports.test.sh
node scripts/check-ui-wildcard-exports.mjs
# WARN PHASE — reports, never blocks. `shamefully-hoist=true` in .npmrc
# hides both halves of the drift this catches: an undeclared import still
# resolves, and an obsolete declaration still installs. Neither shows up
# in build, typecheck, or lint.
#
# `continue-on-error` alone, with no `|| true`: the real exit code has to
# reach Actions for the step to show as a warning rather than a silent
# pass, and it is what makes the promotion below a one-line change.
#
# TO PROMOTE TO BLOCKING: once this step has run clean for a full cycle
# on main, drop `continue-on-error`. The findings it reports today are
# pre-existing dead code left out of the cleanup that introduced it
# (MUL-6353) — clear those first, or the flip turns them into a red main.
- name: Check for dead code and phantom dependencies (knip)
if: ${{ needs.changes.outputs.frontend == 'true' }}
continue-on-error: true
run: pnpm knip
frontend-test:
needs: changes
runs-on: ubuntu-latest
+1 -45
View File
@@ -13,64 +13,20 @@
"postinstall": "fumadocs-mdx"
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/dom": "^1.7.6",
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "^5.96.2",
"@tiptap/extension-code-block-lowlight": "3.27.1",
"@tiptap/extension-image": "3.27.1",
"@tiptap/extension-link": "3.27.1",
"@tiptap/extension-mention": "3.27.1",
"@tiptap/extension-placeholder": "3.27.1",
"@tiptap/extension-table": "3.27.1",
"@tiptap/extension-table-cell": "3.27.1",
"@tiptap/extension-table-header": "3.27.1",
"@tiptap/extension-table-row": "3.27.1",
"@tiptap/extension-typography": "3.27.1",
"@tiptap/markdown": "3.27.1",
"@tiptap/pm": "3.27.1",
"@tiptap/react": "3.27.1",
"@tiptap/starter-kit": "3.27.1",
"@tiptap/suggestion": "3.27.1",
"@types/linkify-it": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.4.1",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"fumadocs-core": "^15.5.2",
"fumadocs-mdx": "^12.0.3",
"input-otp": "^1.4.2",
"linkify-it": "^5.0.0",
"lowlight": "^3.3.0",
"lucide-react": "catalog:",
"next": "^16.2.5",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-dom": "catalog:",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.5",
"recharts": "3.8.0",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"shadcn": "^4.1.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"zod": "catalog:",
"zustand": "catalog:"
"zod": "catalog:"
},
"devDependencies": {
"@multica/eslint-config": "workspace:*",
+79
View File
@@ -0,0 +1,79 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
// Guards the invariant in CLAUDE.md "Package Boundaries": every workspace
// declares the external packages it actually imports, and nothing else.
// `.npmrc` sets `shamefully-hoist=true`, so an undeclared import still
// resolves at runtime and an obsolete declaration still installs — neither
// failure is visible without a tool that reads the import graph.
//
// Scoped to `files`, `dependencies`, and `unlisted` on purpose. `exports` is
// the noisy one here: packages/ui and packages/views are consumed through
// per-file `exports` maps, so a named export used by exactly one app still
// reads as "unused" from inside its own workspace.
"include": ["files", "dependencies", "unlisted"],
// Go, and a workspace with its own Expo toolchain, lint pipeline, and React
// version (see apps/mobile/CLAUDE.md). Neither is covered by this config.
"ignoreWorkspaces": ["apps/mobile"],
"workspaces": {
".": {
"entry": ["scripts/*.mjs"],
"project": ["scripts/*.mjs"]
},
"apps/web": {
"entry": ["eslint.config.mjs"],
// Referenced only by the `@type {import('postcss-load-config').Config}`
// JSDoc annotation in postcss.config.mjs. Declaring it would add a
// runtime dependency to satisfy a type comment.
"ignoreDependencies": ["postcss-load-config"]
},
"apps/desktop": {
// window-state.node-test.mjs is run by hand (`node --test`, see its
// header) rather than by a package script, so nothing imports it.
"entry": ["eslint.config.mjs", "src/main/*.node-test.mjs"],
// Consumed by `extends` in tsconfig.node.json / tsconfig.web.json.
// knip follows `extends` from the default tsconfig.json only.
"ignoreDependencies": ["@multica/tsconfig"]
},
"apps/docs": {},
// The three shared packages export raw .ts/.tsx compiled by their
// consumers (CLAUDE.md "Project Shape"), so `entry` names only the config
// files knip would otherwise miss and the apps supply the real entry
// points — a file is live when an app reaches it.
//
// That holds for views and core, whose `exports` name specific files
// (44 of views' 45). It does NOT hold for the four wildcard exports in
// packages/ui: knip expands each into a glob over its whole directory and
// registers every match as an entry point, so nothing under
// components/ui, components/common, markdown, or hooks can ever be
// reported unused here. `entry: []`, negated `entry` patterns, and
// `--production` were all tried — manifest entries come from a separate
// code path that none of them subtract from.
//
// scripts/check-ui-wildcard-exports.mjs covers exactly those four
// directories and runs as its own blocking CI step. Narrow a wildcard
// export and knip picks that directory back up; the script reads the same
// manifest, so the two stay in step without overlapping.
"packages/ui": {
"entry": ["eslint.config.mjs"],
"project": ["**/*.{ts,tsx}"]
},
"packages/views": {
"entry": ["eslint.config.mjs"],
"project": ["**/*.{ts,tsx}"]
},
"packages/core": {
"entry": ["eslint.config.mjs"],
"project": ["**/*.{ts,tsx}"]
},
"packages/eslint-config": {},
"packages/tsconfig": {}
}
}
+3
View File
@@ -23,6 +23,8 @@
"typecheck": "turbo typecheck --filter=!@multica/mobile",
"test": "turbo test --filter=!@multica/mobile",
"lint": "turbo lint --filter=!@multica/mobile",
"knip": "knip",
"check:ui-exports": "node scripts/check-ui-wildcard-exports.mjs",
"clean": "turbo clean && rm -rf node_modules",
"ui:add": "cd packages/ui && npx shadcn@latest add",
"generate:reserved-slugs": "node scripts/generate-reserved-slugs.mjs"
@@ -48,6 +50,7 @@
"@playwright/test": "^1.58.2",
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"knip": "^6.32.2",
"pg": "^8.20.0",
"turbo": "^2.5.4",
"typescript": "catalog:"
-1
View File
@@ -159,7 +159,6 @@
"dependencies": {
"@formatjs/intl-localematcher": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "^5.96.2",
"i18next": "catalog:",
"posthog-js": "catalog:",
"react-i18next": "catalog:",
@@ -1,74 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { Users } from "lucide-react";
import { HoverCard, HoverCardTrigger, HoverCardContent } from "@multica/ui/components/ui/hover-card";
import { ActorAvatar } from "./actor-avatar";
interface MentionHoverCardProps {
type: string;
id: string;
name: string;
initials: string;
avatarUrl?: string | null;
role?: string;
children: ReactNode;
}
function MentionHoverCard({
type,
id: _id,
name,
initials,
avatarUrl,
role,
children,
}: MentionHoverCardProps) {
if (type === "all") {
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10">
<Users className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<p className="text-body font-medium">All members</p>
<p className="text-caption text-muted-foreground">Notifies all workspace members</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
return (
<HoverCard>
<HoverCardTrigger render={<span />} className="cursor-default">
{children}
</HoverCardTrigger>
<HoverCardContent align="start" className="w-auto min-w-48 max-w-72">
<div className="flex items-center gap-2.5">
<ActorAvatar
name={name}
initials={initials}
avatarUrl={avatarUrl}
isAgent={type === "agent"}
size="lg"
/>
<div className="min-w-0">
<p className="text-body font-medium truncate">{name}</p>
{role && (
<p className="text-caption text-muted-foreground truncate">{role}</p>
)}
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
export { MentionHoverCard, type MentionHoverCardProps };
-74
View File
@@ -1,74 +0,0 @@
"use client"
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { cn } from "@multica/ui/lib/utils"
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
return (
<AccordionPrimitive.Root
data-slot="accordion"
className={cn("flex w-full flex-col", className)}
{...props}
/>
)
}
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("not-last:border-b", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: AccordionPrimitive.Trigger.Props) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-body font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: AccordionPrimitive.Panel.Props) {
return (
<AccordionPrimitive.Panel
data-slot="accordion-content"
className="overflow-hidden text-body data-open:animate-accordion-down data-closed:animate-accordion-up"
{...props}
>
<div
className={cn(
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
>
{children}
</div>
</AccordionPrimitive.Panel>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
@@ -1,22 +0,0 @@
import { cn } from "@multica/ui/lib/utils"
function AspectRatio({
ratio,
className,
...props
}: React.ComponentProps<"div"> & { ratio: number }) {
return (
<div
data-slot="aspect-ratio"
style={
{
"--ratio": ratio,
} as React.CSSProperties
}
className={cn("relative aspect-(--ratio)", className)}
{...props}
/>
)
}
export { AspectRatio }
-125
View File
@@ -1,125 +0,0 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@multica/ui/lib/utils"
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-body wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<ChevronRightIcon />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -1,87 +0,0 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { Separator } from "@multica/ui/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
vertical:
"flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
render,
...props
}: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-body font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
),
},
props
),
render,
state: {
slot: "button-group-text",
},
})
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
-242
View File
@@ -1,242 +0,0 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.use(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ChevronLeftIcon />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ChevronRightIcon />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
useCarousel,
}
-297
View File
@@ -1,297 +0,0 @@
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@multica/ui/components/ui/input-group"
import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({
className,
children,
...props
}: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input
render={<InputGroupInput disabled={disabled} />}
{...props}
/>
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn("group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({
className,
children,
...props
}: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-body outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return (
<ComboboxPrimitive.Group
data-slot="combobox-group"
className={cn(className)}
{...props}
/>
)
}
function ComboboxLabel({
className,
...props
}: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn("px-2 py-1.5 text-caption text-muted-foreground", className)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return (
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
)
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center py-2 text-center text-body text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({
className,
...props
}: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-body transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-caption font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({
className,
...props
}: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("min-w-16 flex-1 outline-none", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}
@@ -1,106 +0,0 @@
"use client";
import type { Column } from "@tanstack/react-table";
import {
ChevronDown,
ChevronsUpDown,
ChevronUp,
EyeOff,
X,
} from "lucide-react";
import type * as React from "react";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { cn } from "@multica/ui/lib/utils";
interface DataTableColumnHeaderProps<TData, TValue>
extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<TData, TValue>;
label: string;
}
// Sort/hide-aware column header, adapted from Dice UI
// (https://diceui.com/r/data-table). Renders the label as plain text when
// the column has neither sorting nor hiding enabled (so non-interactive
// columns don't expose a useless dropdown). Otherwise wraps the label in
// a dropdown-menu trigger that toggles sort direction and hides the
// column on demand.
export function DataTableColumnHeader<TData, TValue>({
column,
label,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{label}</div>;
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className,
)}
{...props}
>
{label}
{column.getCanSort() &&
(column.getIsSorted() === "desc" ? (
<ChevronDown />
) : column.getIsSorted() === "asc" ? (
<ChevronUp />
) : (
<ChevronsUpDown />
))}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-28">
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "asc"}
onClick={() => column.toggleSorting(false)}
>
<ChevronUp />
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={column.getIsSorted() === "desc"}
onClick={() => column.toggleSorting(true)}
>
<ChevronDown />
Desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem
className="pl-2 [&_svg]:text-muted-foreground"
onClick={() => column.clearSorting()}
>
<X />
Reset
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
checked={!column.getIsVisible()}
onClick={() => column.toggleVisibility(false)}
>
<EyeOff />
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
-6
View File
@@ -1,6 +0,0 @@
"use client"
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider"
-134
View File
@@ -1,134 +0,0 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@multica/ui/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-body text-popover-foreground data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className
)}
{...props}
>
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-0.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn(
"font-heading text-title-sm font-medium text-foreground",
className
)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-body text-muted-foreground", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
-280
View File
@@ -1,280 +0,0 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
import { cn } from "@multica/ui/lib/utils"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu"
import { CheckIcon } from "lucide-react"
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
return (
<MenubarPrimitive
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof DropdownMenuGroup>) {
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPortal>) {
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
return (
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-body font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuItem>) {
return (
<DropdownMenuItem
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-body focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-body outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-body outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuLabel> & {
inset?: boolean
}) {
return (
<DropdownMenuLabel
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-body font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
return (
<DropdownMenuSeparator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
return (
<DropdownMenuShortcut
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-caption tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof DropdownMenuSub>) {
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuSubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"gap-1.5 rounded-md px-1.5 py-1 text-body focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
return (
<DropdownMenuSubContent
data-slot="menubar-sub-content"
className={cn("min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
@@ -1,52 +0,0 @@
import * as React from "react"
import { cn } from "@multica/ui/lib/utils"
import { ChevronDownIcon } from "lucide-react"
type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
size?: "sm" | "default"
}
function NativeSelect({
className,
size = "default",
...props
}: NativeSelectProps) {
return (
<div
className={cn(
"group/native-select relative w-fit has-[select:disabled]:opacity-50",
className
)}
data-slot="native-select-wrapper"
data-size={size}
>
<select
data-slot="native-select"
data-size={size}
className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-body transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none" aria-hidden="true" data-slot="native-select-icon" />
</div>
)
}
function NativeSelectOption({ ...props }: React.ComponentProps<"option">) {
return <option data-slot="native-select-option" {...props} />
}
function NativeSelectOptGroup({
className,
...props
}: React.ComponentProps<"optgroup">) {
return (
<optgroup
data-slot="native-select-optgroup"
className={cn(className)}
{...props}
/>
)
}
export { NativeSelect, NativeSelectOptGroup, NativeSelectOption }
@@ -1,168 +0,0 @@
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
import { cva } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { ChevronDownIcon } from "lucide-react"
function NavigationMenu({
align = "start",
className,
children,
...props
}: NavigationMenuPrimitive.Root.Props &
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
<NavigationMenuPositioner align={align} />
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-0",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg bg-background px-2.5 py-1.5 text-body font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
)
function NavigationMenuTrigger({
className,
children,
...props
}: NavigationMenuPrimitive.Trigger.Props) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: NavigationMenuPrimitive.Content.Props) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
className
)}
{...props}
/>
)
}
function NavigationMenuPositioner({
className,
side = "bottom",
sideOffset = 8,
align = "start",
alignOffset = 0,
...props
}: NavigationMenuPrimitive.Positioner.Props) {
return (
<NavigationMenuPrimitive.Portal>
<NavigationMenuPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
className={cn(
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
className
)}
{...props}
>
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
</NavigationMenuPrimitive.Popup>
</NavigationMenuPrimitive.Positioner>
</NavigationMenuPrimitive.Portal>
)
}
function NavigationMenuLink({
className,
...props
}: NavigationMenuPrimitive.Link.Props) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"flex items-center gap-2 rounded-lg p-2 text-body transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
return (
<NavigationMenuPrimitive.Icon
data-slot="navigation-menu-indicator"
className={cn(
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
className
)}
{...props}
>
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
</NavigationMenuPrimitive.Icon>
)
}
export {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
NavigationMenuPositioner,
}
-133
View File
@@ -1,133 +0,0 @@
import * as React from "react"
import { useTranslation } from "react-i18next"
import { cn } from "@multica/ui/lib/utils"
import { Button } from "@multica/ui/components/ui/button"
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex items-center gap-0.5", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<Button
variant={isActive ? "outline" : "ghost"}
size={size}
className={cn(className)}
nativeButton={false}
render={
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
{...props}
/>
}
/>
)
}
function PaginationPrevious({
className,
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label={t(($) => $.pagination_previous)}
size="default"
className={cn("pl-1.5!", className)}
{...props}
>
<ChevronLeftIcon data-icon="inline-start" />
<span className="hidden sm:block">{text}</span>
</PaginationLink>
)
}
function PaginationNext({
className,
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label={t(($) => $.pagination_next)}
size="default"
className={cn("pr-1.5!", className)}
{...props}
>
<span className="hidden sm:block">{text}</span>
<ChevronRightIcon data-icon="inline-end" />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn(
"flex size-8 items-center justify-center [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<MoreHorizontalIcon
/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}
-38
View File
@@ -1,38 +0,0 @@
"use client"
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
import { cn } from "@multica/ui/lib/utils"
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
return (
<RadioGroupPrimitive
data-slot="radio-group"
className={cn("grid w-full gap-2", className)}
{...props}
/>
)
}
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
return (
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<RadioPrimitive.Indicator
data-slot="radio-group-indicator"
className="flex size-4 items-center justify-center"
>
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
</RadioPrimitive.Indicator>
</RadioPrimitive.Root>
)
}
export { RadioGroup, RadioGroupItem }
-54
View File
@@ -1,54 +0,0 @@
"use client"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@multica/ui/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
@@ -1,89 +0,0 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@multica/ui/lib/utils"
import { toggleVariants } from "@multica/ui/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 0,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.use(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
-80
View File
@@ -1,80 +0,0 @@
import { type RefObject, useEffect, useRef, useCallback } from "react"
/**
* Auto-scrolls a scroll container to the bottom when its inner content grows,
* as long as the user hasn't scrolled up to read older content.
*
* Returns a `lockRef` that can be set to `true` to temporarily suppress
* auto-scroll (e.g. during history prepend operations).
*/
export function useAutoScroll(ref: RefObject<HTMLElement | null>) {
const stickRef = useRef(true)
const lockRef = useRef(false)
// Re-running the initial scroll-to-bottom on every effect mount would
// overwrite the scroll position any time React tears the effect down and
// brings it back — e.g. when the host tab cycles through `<Activity
// mode="hidden">` and back. We want the jump only on a real first mount.
const didInitialScrollRef = useRef(false)
useEffect(() => {
const el = ref.current
if (!el) return
const scrollToBottom = () => {
el.scrollTo({ top: el.scrollHeight })
}
const onScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = el
stickRef.current = scrollHeight - scrollTop - clientHeight < 50
}
const onContentChange = () => {
if (lockRef.current) return
if (stickRef.current) {
scrollToBottom()
}
}
// Watch child element resizes (content growth, image loads, streaming)
const ro = new ResizeObserver(onContentChange)
for (const child of el.children) {
ro.observe(child)
}
// Watch for added/removed child nodes (new messages rendered)
const mo = new MutationObserver((mutations) => {
// Also observe newly added elements
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node instanceof Element) {
ro.observe(node)
}
}
}
onContentChange()
})
mo.observe(el, { childList: true, subtree: true })
el.addEventListener("scroll", onScroll, { passive: true })
if (!didInitialScrollRef.current) {
didInitialScrollRef.current = true
scrollToBottom()
}
return () => {
el.removeEventListener("scroll", onScroll)
ro.disconnect()
mo.disconnect()
}
}, [ref])
/** Temporarily suppress auto-scroll during prepend operations */
const suppressAutoScroll = useCallback(() => {
lockRef.current = true
return () => { lockRef.current = false }
}, [])
return { suppressAutoScroll }
}
+1 -6
View File
@@ -35,14 +35,11 @@
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"input-otp": "^1.4.2",
"linkify-it": "^5.0.0",
"katex": "catalog:",
"lucide-react": "catalog:",
"motion": "^12.38.0",
"next-themes": "^0.4.6",
"react-day-picker": "^9.14.0",
"react-markdown": "^10.1.0",
@@ -57,9 +54,7 @@
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0",
"unicode-animations": "catalog:",
"vaul": "^1.1.2"
"unicode-animations": "catalog:"
},
"peerDependencies": {
"i18next": "catalog:",
-2
View File
@@ -19,8 +19,6 @@ declare global {
ui: {
attach_file: string;
toggle_sidebar: string;
pagination_previous: string;
pagination_next: string;
copy_code: string;
plain_text: string;
};
@@ -43,11 +43,10 @@ interface IssueHoverCardProps {
* The inline chip shows status, identifier, and as much title as fits inside
* its `min(18rem, 100%)` cap — so a long title arrives truncated. The card
* carries what the chip cannot: the full untruncated title, priority, a
* description snippet, the assignee, and sub-issue progress. Member and agent
* mentions already preview this way via MentionHoverCard
* (packages/ui/components/common/mention-hover-card.tsx); this is the issue
* equivalent, and lives here rather than in packages/ui because it reads
* workspace queries.
* description snippet, the assignee, and sub-issue progress. Issue mentions are
* the only kind with a hover preview — member and agent mentions render as a
* plain `.mention` span in rich-content.tsx. This lives here rather than in
* packages/ui because it reads workspace queries.
*
* Every query lives in IssueHoverCardBody rather than here on purpose: Base UI
* mounts the popup only while the card is open, so this component adds no
-2
View File
@@ -1,8 +1,6 @@
{
"attach_file": "Attach file",
"toggle_sidebar": "Toggle Sidebar",
"pagination_previous": "Go to previous page",
"pagination_next": "Go to next page",
"copy_code": "Copy code",
"plain_text": "plain text"
}
-2
View File
@@ -1,8 +1,6 @@
{
"attach_file": "ファイルを添付",
"toggle_sidebar": "サイドバーの開閉",
"pagination_previous": "前のページへ移動",
"pagination_next": "次のページへ移動",
"copy_code": "コードをコピー",
"plain_text": "プレーンテキスト"
}
-2
View File
@@ -1,8 +1,6 @@
{
"attach_file": "파일 첨부",
"toggle_sidebar": "사이드바 열기/닫기",
"pagination_previous": "이전 페이지로 이동",
"pagination_next": "다음 페이지로 이동",
"copy_code": "코드 복사",
"plain_text": "일반 텍스트"
}
-2
View File
@@ -1,8 +1,6 @@
{
"attach_file": "添加附件",
"toggle_sidebar": "切换侧边栏",
"pagination_previous": "上一页",
"pagination_next": "下一页",
"copy_code": "复制代码",
"plain_text": "纯文本"
}
+1
View File
@@ -126,6 +126,7 @@
"@testing-library/jest-dom": "catalog:",
"@testing-library/react": "catalog:",
"@testing-library/user-event": "catalog:",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+632 -281
View File
File diff suppressed because it is too large Load Diff
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env node
/**
* Reports files in packages/ui that nothing reaches.
*
* This exists because knip structurally cannot see them. knip derives entry
* points from a workspace's package.json `exports` (graph/build.js calls
* `getEntrySpecifiersFromManifest` unconditionally — there is no config flag to
* opt out), and packages/ui exports four wildcards:
*
* "./components/ui/*" -> "./components/ui/*.tsx"
* "./components/common/*" -> "./components/common/*.tsx"
* "./markdown/*" -> "./markdown/*.tsx"
* "./hooks/*" -> "./hooks/*.ts"
*
* Each expands to a glob covering every file in the directory, so all of them
* register as entry points and none can ever be reported unused. Setting
* `entry: []`, negating the paths in `entry`, and `--production` were all
* tried; manifest entries are a separate code path from configured ones and
* none of them subtract from it. packages/views is unaffected — 44 of its 45
* exports name a specific file — which is why knip does flag dead files there.
*
* That blind spot is exactly where the 16 dead components removed in MUL-6353
* lived, so without this check the cleanup has no regression guard at all.
*
* Specifiers are resolved against the importing file's own directory, never
* matched by basename. 17 of the 59 files guarded here share a basename with
* some other file in the repo (`button`, `card`, `input`, `label`, …), so
* basename matching would silently pass a dead file whenever any unrelated
* module imported a same-named sibling.
*
* Liveness is reachability, not a reference count: the walk starts from every
* file outside the guarded directories and follows imports inward, so a pair
* of dead components importing only each other stays dead.
*
* Edges come from real syntax nodes via the TypeScript parser, not from a
* regex over source text. Text matching counts a commented-out import as a
* live edge, which is precisely the state a file passes through when its last
* real importer is removed. 52 specifiers in this repo appear only in comments
* or prose today; none currently point into packages/ui, but that is a
* coincidence rather than a property.
*
* Run: node scripts/check-ui-wildcard-exports.mjs
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { dirname, join, relative, resolve, sep } from "node:path";
import ts from "typescript";
const repoRoot = resolve(import.meta.dirname, "..");
const uiRoot = join(repoRoot, "packages", "ui");
const pkgName = "@multica/ui";
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", ".turbo", "out", "dist", "build"]);
/**
* Module specifiers this file really depends on: static import/export,
* `import()`, `require()`, and `import("x")` type nodes. A bare string that
* happens to sit next to the word `from` is not one of them.
*
* `vi.mock("...")` is deliberately excluded. It names a module without
* importing it, so a component whose only remaining mention is a test mock is
* dead — counting it would let a component outlive its last real use.
*/
function importedSpecifiers(sourceText, filePath) {
const source = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
const specifiers = new Set();
const visit = (node) => {
if (
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
node.moduleSpecifier &&
ts.isStringLiteral(node.moduleSpecifier)
) {
specifiers.add(node.moduleSpecifier.text);
} else if (
ts.isCallExpression(node) &&
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
(ts.isIdentifier(node.expression) && node.expression.text === "require")) &&
node.arguments.length > 0 &&
ts.isStringLiteral(node.arguments[0])
) {
specifiers.add(node.arguments[0].text);
} else if (
ts.isImportTypeNode(node) &&
ts.isLiteralTypeNode(node.argument) &&
ts.isStringLiteral(node.argument.literal)
) {
specifiers.add(node.argument.literal.text);
}
ts.forEachChild(node, visit);
};
visit(source);
return specifiers;
}
function walk(dir, out = []) {
for (const dirent of readdirSync(dir, { withFileTypes: true })) {
if (dirent.name.startsWith(".")) continue;
const full = join(dir, dirent.name);
if (dirent.isDirectory()) {
if (!SKIP_DIRS.has(dirent.name)) walk(full, out);
} else if (SOURCE_EXTENSIONS.some((ext) => dirent.name.endsWith(ext))) {
out.push(full);
}
}
return out;
}
/** Every on-disk path a specifier could mean, extensionless forms included. */
function resolutionCandidates(base) {
return [base, ...SOURCE_EXTENSIONS.flatMap((ext) => [`${base}${ext}`, join(base, `index${ext}`)])];
}
const manifest = JSON.parse(readFileSync(join(uiRoot, "package.json"), "utf8"));
const exportEntries = Object.entries(manifest.exports ?? {});
// Derived from the manifest rather than hard-coded: narrow a wildcard export
// and this check narrows with it instead of going quietly stale.
const wildcards = exportEntries
.filter(([subpath, target]) => subpath.includes("*") && String(target).includes("*"))
.map(([subpath, target]) => {
const targetPath = String(target).replace(/^\.\//, "");
return {
dir: targetPath.slice(0, targetPath.lastIndexOf("/")),
extension: targetPath.slice(targetPath.lastIndexOf("*") + 1),
prefix: subpath.replace(/^\.\//, "").replace("*", ""),
};
});
if (wildcards.length === 0) {
console.log("No wildcard exports in packages/ui — knip covers this workspace on its own.");
process.exit(0);
}
// A file named by a non-wildcard export is an entry point in its own right, so
// it is exempt. This is what makes the remedy the failure message suggests
// actually work.
const exactExportTargets = new Set(
exportEntries
.filter(([subpath, target]) => !subpath.includes("*") && !String(target).includes("*"))
.map(([, target]) => join(uiRoot, String(target).replace(/^\.\//, ""))),
);
const guarded = new Set();
for (const { dir, extension } of wildcards) {
let entries;
try {
entries = readdirSync(join(uiRoot, dir));
} catch {
continue; // Exported directory does not exist yet.
}
for (const entry of entries) {
if (!entry.endsWith(extension)) continue;
const file = join(uiRoot, dir, entry);
if (statSync(file).isFile() && !exactExportTargets.has(file)) guarded.add(file);
}
}
const sourceFiles = walk(repoRoot);
const known = new Set(sourceFiles);
/** Resolve one specifier from one importer to a guarded file, or null. */
function resolveSpecifier(specifier, importer) {
let base;
if (specifier.startsWith(".")) {
base = resolve(dirname(importer), specifier);
} else if (specifier === pkgName || specifier.startsWith(`${pkgName}/`)) {
const subpath = specifier.slice(pkgName.length + 1);
const wildcard = wildcards.find((w) => subpath.startsWith(w.prefix) && w.prefix !== "");
if (!wildcard) return null;
base = join(uiRoot, wildcard.dir, subpath.slice(wildcard.prefix.length));
} else {
return null; // A bare external package.
}
for (const candidate of resolutionCandidates(base)) {
if (known.has(candidate)) return candidate;
}
return null;
}
// Reachability: roots are everything outside the guarded set, so a guarded file
// counts as live only when the walk actually arrives at it.
const imports = new Map();
for (const file of sourceFiles) {
const found = new Set();
for (const specifier of importedSpecifiers(readFileSync(file, "utf8"), file)) {
const target = resolveSpecifier(specifier, file);
if (target !== null) found.add(target);
}
imports.set(file, found);
}
const reached = new Set();
const queue = sourceFiles.filter((file) => !guarded.has(file));
while (queue.length > 0) {
for (const target of imports.get(queue.pop()) ?? []) {
if (!reached.has(target)) {
reached.add(target);
queue.push(target);
}
}
}
const dead = [...guarded]
.filter((file) => !reached.has(file))
.map((file) => relative(repoRoot, file).split(sep).join("/"))
.sort();
if (dead.length > 0) {
console.error(`Unused files in packages/ui wildcard exports (${dead.length})`);
for (const file of dead) console.error(` ${file}`);
console.error(
"\nNothing imports these. Delete them — shadcn components come back with" +
"\n`pnpm ui:add <name>`. If a file is genuinely entry-like, give it a" +
"\nnon-wildcard entry in packages/ui/package.json `exports`.",
);
process.exit(1);
}
console.log(
`packages/ui wildcard exports clean (${guarded.size} files under ` +
`${wildcards.map((w) => w.dir).join(", ")}).`,
);
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# Proves check-ui-wildcard-exports.mjs actually fails on the cases it exists for.
#
# The check guards a blind spot that is invisible by construction: a component
# under a wildcard export registers as a knip entry point, so knip reports it
# as used no matter what. A guard for an invisible failure is worth exactly as
# much as its proof that it still fires, hence this test.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
check="$root_dir/scripts/check-ui-wildcard-exports.mjs"
ui_dir="$root_dir/packages/ui/components/ui"
out="$(mktemp)"
scratch=()
cleanup() {
rm -f "${scratch[@]+"${scratch[@]}"}" "$out"
}
trap cleanup EXIT
track() {
scratch+=("$1")
}
fail() {
echo "FAIL: $*" >&2
echo "Observed:" >&2
cat "$out" >&2
exit 1
}
expect_pass() {
if ! node "$check" >"$out" 2>&1; then fail "$1"; fi
}
expect_fail_naming() {
if node "$check" >"$out" 2>&1; then fail "$2"; fi
if ! grep -Fq "$1" "$out"; then fail "check failed but did not name $1"; fi
}
# 1. Clean tree passes. Runs first so a pre-existing dead file is reported as
# itself rather than surfacing later as a confusing probe failure.
expect_pass "check should pass on a clean tree"
# 2. A component nothing imports fails the check and is named.
probe="$ui_dir/zz-unused-export-probe.tsx"
track "$probe"
cat >"$probe" <<'EOF'
export function UnusedExportProbe() {
return null
}
EOF
expect_fail_naming "zz-unused-export-probe.tsx" \
"check should fail when packages/ui gains a zero-reference component"
# 3. Importing it from a consumer clears the check — the guard tracks real
# usage rather than just counting files.
importer="$root_dir/packages/views/zz-probe-importer.tsx"
track "$importer"
cat >"$importer" <<'EOF'
export { UnusedExportProbe } from "@multica/ui/components/ui/zz-unused-export-probe";
EOF
expect_pass "check should pass once a consumer imports the component"
rm -f "$importer" "$probe"
# 4. Basename collision. 17 of the 59 guarded files share a name with some
# other file in the repo, so a check that matched relative imports by
# basename would pass this dead component. `option-card` is real:
# packages/views/onboarding/components/option-card.tsx is imported as
# `../components/option-card` and must not vouch for the packages/ui file.
collide="$ui_dir/option-card.tsx"
track "$collide"
cat >"$collide" <<'EOF'
export function OptionCard() {
return null
}
EOF
expect_fail_naming "components/ui/option-card.tsx" \
"a same-named file elsewhere must not mark a dead ui component as used"
rm -f "$collide"
# 5. Two dead components importing only each other are still dead. Liveness is
# reachability from outside the guarded directories, not a reference count.
pair_a="$ui_dir/zz-cycle-a.tsx"
pair_b="$ui_dir/zz-cycle-b.tsx"
track "$pair_a"
track "$pair_b"
cat >"$pair_a" <<'EOF'
import { CycleB } from "./zz-cycle-b";
export function CycleA() {
return CycleB();
}
EOF
cat >"$pair_b" <<'EOF'
export function CycleB() {
return null;
}
EOF
expect_fail_naming "zz-cycle-a.tsx" "a mutually-importing dead pair must not vouch for itself"
if ! grep -Fq "zz-cycle-b.tsx" "$out"; then
fail "both halves of a dead import cycle should be reported"
fi
rm -f "$pair_a" "$pair_b"
# 6. A commented-out import is not an edge. This is the state a component
# passes through the moment its last real importer is deleted, so a text
# scan would keep vouching for it exactly when it stops being used.
comment_probe="$ui_dir/zz-comment-probe.tsx"
mention="$root_dir/packages/views/zz-probe-mention.ts"
track "$comment_probe"
track "$mention"
cat >"$comment_probe" <<'EOF'
export function CommentProbe() {
return null
}
EOF
cat >"$mention" <<'EOF'
// import { CommentProbe } from "@multica/ui/components/ui/zz-comment-probe";
const note = 'import { CommentProbe } from "@multica/ui/components/ui/zz-comment-probe"';
export const probeNote = note;
EOF
expect_fail_naming "zz-comment-probe.tsx" \
"a commented-out or stringified import must not count as a real edge"
rm -f "$comment_probe" "$mention"
expect_pass "check should pass again once every probe is removed"
echo "PASS: check-ui-wildcard-exports parses real imports, survives name collisions, dead cycles, and commented-out imports"