canopy-ui 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/index.ts +6 -0
- package/src/lib/cn.test.ts +11 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/index.ts +1 -0
- package/src/shell/WorkbenchMain.tsx +17 -0
- package/src/shell/WorkbenchNavItem.test.ts +28 -0
- package/src/shell/WorkbenchNavItem.tsx +72 -0
- package/src/shell/WorkbenchPane.tsx +29 -0
- package/src/shell/WorkbenchRail.tsx +37 -0
- package/src/shell/WorkbenchShell.tsx +26 -0
- package/src/shell/WorkbenchSubHeader.tsx +24 -0
- package/src/shell/index.ts +9 -0
- package/src/shell/states.tsx +69 -0
- package/src/shell/usePaneCollapsed.ts +35 -0
- package/src/shell/usePaneWidth.ts +35 -0
- package/src/tokens/index.ts +44 -0
- package/src/tokens/preset.css +84 -0
- package/src/tokens/tokens.css +105 -0
- package/src/ui/badge.tsx +52 -0
- package/src/ui/button.tsx +58 -0
- package/src/ui/index.ts +16 -0
- package/src/ui/input.tsx +20 -0
- package/src/ui/skeleton.tsx +15 -0
- package/src/ui/table.tsx +114 -0
- package/src/ui/tabs.tsx +82 -0
- package/src/ui/textarea.tsx +18 -0
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "canopy-ui",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"exports": {
|
|
6
|
+
".": "./src/index.ts",
|
|
7
|
+
"./lib": "./src/lib/index.ts",
|
|
8
|
+
"./ui": "./src/ui/index.ts",
|
|
9
|
+
"./shell": "./src/shell/index.ts",
|
|
10
|
+
"./tokens": "./src/tokens/index.ts",
|
|
11
|
+
"./tokens/preset.css": "./src/tokens/preset.css",
|
|
12
|
+
"./tokens/tokens.css": "./src/tokens/tokens.css"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"registry": "https://registry.npmjs.org",
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"@base-ui/react": "^1.3.0",
|
|
20
|
+
"class-variance-authority": "^0.7.1",
|
|
21
|
+
"clsx": "^2.1.1",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-dom": "^19.0.0",
|
|
24
|
+
"tailwind-merge": "^3.5.0"
|
|
25
|
+
},
|
|
26
|
+
"files": ["src"]
|
|
27
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// The full canopy-ui surface, re-exported from the subpath modules.
|
|
2
|
+
// Prefer the subpath imports (canopy-ui/ui, /shell, /lib) in app code;
|
|
3
|
+
// this barrel keeps the convenience top-level entry working.
|
|
4
|
+
export * from './lib'
|
|
5
|
+
export * from './shell'
|
|
6
|
+
export * from './ui'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { cn } from './cn'
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('merges conditional classes', () => {
|
|
6
|
+
expect(cn('a', false && 'b', 'c')).toBe('a c')
|
|
7
|
+
})
|
|
8
|
+
it('lets later tailwind classes win', () => {
|
|
9
|
+
expect(cn('px-2', 'px-4')).toBe('px-4')
|
|
10
|
+
})
|
|
11
|
+
})
|
package/src/lib/cn.ts
ADDED
package/src/lib/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn } from './cn'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The scrolling main column. Forwards a ref and arbitrary <main> props so a
|
|
6
|
+
* surface can mark it as a scroll-spy root (e.g. data-ddd-scroll).
|
|
7
|
+
*/
|
|
8
|
+
export const WorkbenchMain = forwardRef<
|
|
9
|
+
HTMLElement,
|
|
10
|
+
ComponentPropsWithoutRef<'main'> & { children: ReactNode }
|
|
11
|
+
>(function WorkbenchMain({ children, className, ...rest }, ref) {
|
|
12
|
+
return (
|
|
13
|
+
<main ref={ref} className={cn('min-h-0 min-w-0 flex-1 overflow-y-auto', className)} {...rest}>
|
|
14
|
+
{children}
|
|
15
|
+
</main>
|
|
16
|
+
)
|
|
17
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { workbenchNavItemClass } from './WorkbenchNavItem'
|
|
3
|
+
|
|
4
|
+
describe('workbenchNavItemClass', () => {
|
|
5
|
+
it('uses the orange-tinted active treatment when active', () => {
|
|
6
|
+
const c = workbenchNavItemClass({ active: true })
|
|
7
|
+
expect(c).toContain('bg-primary/10')
|
|
8
|
+
expect(c).toContain('border-primary/30')
|
|
9
|
+
expect(c).toContain('text-primary')
|
|
10
|
+
})
|
|
11
|
+
it('uses the muted resting treatment when inactive', () => {
|
|
12
|
+
const c = workbenchNavItemClass({ active: false })
|
|
13
|
+
expect(c).toContain('text-muted-foreground')
|
|
14
|
+
expect(c).toContain('hover:bg-accent')
|
|
15
|
+
expect(c).not.toContain('bg-primary/10')
|
|
16
|
+
})
|
|
17
|
+
it('uses the neutral grey highlight when variant is neutral and active', () => {
|
|
18
|
+
const c = workbenchNavItemClass({ active: true, variant: 'neutral' })
|
|
19
|
+
expect(c).toContain('bg-accent')
|
|
20
|
+
expect(c).toContain('text-foreground')
|
|
21
|
+
expect(c).not.toContain('bg-primary/10')
|
|
22
|
+
})
|
|
23
|
+
it('defaults to the accent variant', () => {
|
|
24
|
+
expect(workbenchNavItemClass({ active: true })).toBe(
|
|
25
|
+
workbenchNavItemClass({ active: true, variant: 'accent' }),
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, type JSX, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
|
|
4
|
+
export function workbenchNavItemClass({
|
|
5
|
+
active,
|
|
6
|
+
variant = 'accent',
|
|
7
|
+
}: {
|
|
8
|
+
active?: boolean
|
|
9
|
+
variant?: 'accent' | 'neutral'
|
|
10
|
+
}): string {
|
|
11
|
+
const activeClass =
|
|
12
|
+
variant === 'neutral'
|
|
13
|
+
? 'bg-accent border-transparent text-foreground font-medium'
|
|
14
|
+
: 'bg-primary/10 border-primary/30 text-primary font-medium'
|
|
15
|
+
return cn(
|
|
16
|
+
'flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
|
|
17
|
+
active
|
|
18
|
+
? activeClass
|
|
19
|
+
: 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface WorkbenchNavItemProps {
|
|
24
|
+
active?: boolean
|
|
25
|
+
/** Active-state accent: 'accent' = orange primary tint (default), 'neutral' = grey highlight. */
|
|
26
|
+
variant?: 'accent' | 'neutral'
|
|
27
|
+
count?: number
|
|
28
|
+
/** When true, merge styling onto the single child element (e.g. a router Link). */
|
|
29
|
+
asChild?: boolean
|
|
30
|
+
children: ReactNode
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One rail entry: label + optional right-aligned count badge + active state.
|
|
35
|
+
*
|
|
36
|
+
* Default form: renders a presentational <div>; the caller wraps it in their own
|
|
37
|
+
* Link/NavLink. asChild form: clones the single child, merges styling onto it,
|
|
38
|
+
* and uses the child's own text as the label (router-agnostic; no radix dep).
|
|
39
|
+
*/
|
|
40
|
+
export function WorkbenchNavItem({
|
|
41
|
+
active,
|
|
42
|
+
variant,
|
|
43
|
+
count,
|
|
44
|
+
asChild,
|
|
45
|
+
children,
|
|
46
|
+
}: WorkbenchNavItemProps): JSX.Element {
|
|
47
|
+
const className = workbenchNavItemClass({ active, variant })
|
|
48
|
+
const badge =
|
|
49
|
+
count !== undefined ? (
|
|
50
|
+
<span className="shrink-0 text-[11px] text-muted-foreground">{count}</span>
|
|
51
|
+
) : null
|
|
52
|
+
|
|
53
|
+
// asChild requires a single valid element; a non-element child falls back to the div form.
|
|
54
|
+
if (asChild && isValidElement(children)) {
|
|
55
|
+
const el = children as ReactElement<{ className?: string; children?: ReactNode }>
|
|
56
|
+
return cloneElement(
|
|
57
|
+
el,
|
|
58
|
+
{ className: cn(className, el.props.className) },
|
|
59
|
+
<>
|
|
60
|
+
<span className="truncate">{el.props.children}</span>
|
|
61
|
+
{badge}
|
|
62
|
+
</>,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={className}>
|
|
68
|
+
<span className="truncate">{children}</span>
|
|
69
|
+
{badge}
|
|
70
|
+
</div>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
|
|
4
|
+
/** Shipped for the deferred ace-web adoption; not yet consumed in canopy. */
|
|
5
|
+
/** A generic bordered side panel (e.g. a detail or chat column). */
|
|
6
|
+
export function WorkbenchPane({
|
|
7
|
+
width,
|
|
8
|
+
side = 'left',
|
|
9
|
+
children,
|
|
10
|
+
className,
|
|
11
|
+
}: {
|
|
12
|
+
width?: string
|
|
13
|
+
side?: 'left' | 'right'
|
|
14
|
+
children: ReactNode
|
|
15
|
+
className?: string
|
|
16
|
+
}): JSX.Element {
|
|
17
|
+
return (
|
|
18
|
+
<section
|
|
19
|
+
className={cn(
|
|
20
|
+
'shrink-0 bg-background',
|
|
21
|
+
side === 'right' ? 'border-l border-border' : 'border-r border-border',
|
|
22
|
+
width,
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
>
|
|
26
|
+
{children}
|
|
27
|
+
</section>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bordered left rail chrome. Header slot (identity / title / filters) over a
|
|
6
|
+
* scrollable body that takes arbitrary children (a tree or a flat list).
|
|
7
|
+
*/
|
|
8
|
+
export function WorkbenchRail({
|
|
9
|
+
// `width` must be a md-prefixed Tailwind width (e.g. `md:w-64`): on phones the
|
|
10
|
+
// rail spans full width and stacks above the main column, so the width only
|
|
11
|
+
// applies once the layout goes side-by-side at md+.
|
|
12
|
+
width = 'md:w-64',
|
|
13
|
+
header,
|
|
14
|
+
children,
|
|
15
|
+
className,
|
|
16
|
+
}: {
|
|
17
|
+
width?: string
|
|
18
|
+
header?: ReactNode
|
|
19
|
+
children: ReactNode
|
|
20
|
+
className?: string
|
|
21
|
+
}): JSX.Element {
|
|
22
|
+
return (
|
|
23
|
+
<aside
|
|
24
|
+
className={cn(
|
|
25
|
+
'flex shrink-0 flex-col bg-background',
|
|
26
|
+
// Phone: full-width strip with a capped height so the main column below
|
|
27
|
+
// stays reachable. Desktop: fixed-width left rail with a right border.
|
|
28
|
+
'w-full max-h-[42vh] border-b border-border md:max-h-none md:border-b-0 md:border-r',
|
|
29
|
+
width,
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{header && <div className="border-b border-border">{header}</div>}
|
|
34
|
+
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
35
|
+
</aside>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react'
|
|
2
|
+
import { cn } from '../lib/cn'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Full-bleed outer scaffold: optional top header over a body row. The caller
|
|
6
|
+
* composes the body (rail + main + optional side panes) as children, and may
|
|
7
|
+
* wrap <WorkbenchShell> in its own provider (e.g. for scroll-spy).
|
|
8
|
+
*/
|
|
9
|
+
export function WorkbenchShell({
|
|
10
|
+
header,
|
|
11
|
+
children,
|
|
12
|
+
className,
|
|
13
|
+
}: {
|
|
14
|
+
header?: ReactNode
|
|
15
|
+
children: ReactNode
|
|
16
|
+
className?: string
|
|
17
|
+
}): JSX.Element {
|
|
18
|
+
return (
|
|
19
|
+
<div className={cn('flex h-full flex-col bg-background text-foreground', className)}>
|
|
20
|
+
{header}
|
|
21
|
+
{/* Rail + main sit side-by-side on desktop; on phones they stack so the
|
|
22
|
+
main column gets full width instead of being crushed by the rail. */}
|
|
23
|
+
<div className="flex flex-1 flex-col overflow-hidden md:flex-row">{children}</div>
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** The per-section bar inside the main area: title + count + right action. */
|
|
4
|
+
export function WorkbenchSubHeader({
|
|
5
|
+
title,
|
|
6
|
+
count,
|
|
7
|
+
action,
|
|
8
|
+
}: {
|
|
9
|
+
title: string
|
|
10
|
+
count?: number
|
|
11
|
+
action?: ReactNode
|
|
12
|
+
}): JSX.Element {
|
|
13
|
+
return (
|
|
14
|
+
<div className="mb-6 flex items-center justify-between gap-3 border-b border-border pb-4">
|
|
15
|
+
<div className="flex min-w-0 items-baseline gap-2">
|
|
16
|
+
<h1 className="text-base font-semibold text-foreground">{title}</h1>
|
|
17
|
+
{count !== undefined && (
|
|
18
|
+
<span className="text-[12px] text-muted-foreground">{count}</span>
|
|
19
|
+
)}
|
|
20
|
+
</div>
|
|
21
|
+
{action && <div className="shrink-0">{action}</div>}
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { WorkbenchShell } from './WorkbenchShell'
|
|
2
|
+
export { WorkbenchMain } from './WorkbenchMain'
|
|
3
|
+
export { WorkbenchRail } from './WorkbenchRail'
|
|
4
|
+
export { WorkbenchPane } from './WorkbenchPane'
|
|
5
|
+
export { WorkbenchNavItem, workbenchNavItemClass } from './WorkbenchNavItem'
|
|
6
|
+
export { WorkbenchSubHeader } from './WorkbenchSubHeader'
|
|
7
|
+
export { LoadingSpinner, EmptyState, ErrorState, WorkbenchSkeleton } from './states'
|
|
8
|
+
export { usePaneWidth } from './usePaneWidth'
|
|
9
|
+
export { usePaneCollapsed } from './usePaneCollapsed'
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { JSX, ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** LoadingSpinner/EmptyState/ErrorState are shipped for consumers (e.g. deferred ace-web); WorkbenchSkeleton is used by the Agent sections today. */
|
|
4
|
+
export function LoadingSpinner({ label = 'Loading…' }: { label?: string }): JSX.Element {
|
|
5
|
+
return (
|
|
6
|
+
<div className="flex items-center gap-3 p-6 text-muted-foreground">
|
|
7
|
+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" />
|
|
8
|
+
<span>{label}</span>
|
|
9
|
+
</div>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function EmptyState({
|
|
14
|
+
title,
|
|
15
|
+
description,
|
|
16
|
+
action,
|
|
17
|
+
}: {
|
|
18
|
+
title: string
|
|
19
|
+
description?: string
|
|
20
|
+
action?: ReactNode
|
|
21
|
+
}): JSX.Element {
|
|
22
|
+
return (
|
|
23
|
+
<div className="flex flex-col items-center justify-center gap-2 p-12 text-center">
|
|
24
|
+
<h3 className="text-lg font-semibold text-muted-foreground">{title}</h3>
|
|
25
|
+
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
|
26
|
+
{action && <div className="mt-4">{action}</div>}
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ErrorState({
|
|
32
|
+
title = 'Something went wrong',
|
|
33
|
+
message,
|
|
34
|
+
onRetry,
|
|
35
|
+
}: {
|
|
36
|
+
title?: string
|
|
37
|
+
message: string
|
|
38
|
+
onRetry?: () => void
|
|
39
|
+
}): JSX.Element {
|
|
40
|
+
return (
|
|
41
|
+
<div className="rounded border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
|
|
42
|
+
<div className="font-semibold">{title}</div>
|
|
43
|
+
<div className="mt-1">{message}</div>
|
|
44
|
+
{onRetry && (
|
|
45
|
+
<button
|
|
46
|
+
type="button"
|
|
47
|
+
onClick={onRetry}
|
|
48
|
+
className="mt-3 rounded bg-destructive px-3 py-1 text-destructive-foreground hover:bg-destructive/90"
|
|
49
|
+
>
|
|
50
|
+
Retry
|
|
51
|
+
</button>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Pulsing card placeholders while a section lazy-loads its data. */
|
|
58
|
+
export function WorkbenchSkeleton({ rows = 3 }: { rows?: number }): JSX.Element {
|
|
59
|
+
return (
|
|
60
|
+
<div className="animate-pulse space-y-3">
|
|
61
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
62
|
+
<div key={i} className="rounded-xl border border-border bg-card p-5">
|
|
63
|
+
<div className="mb-2 h-4 w-2/3 rounded bg-muted" />
|
|
64
|
+
<div className="h-3 w-full rounded bg-muted/70" />
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* localStorage-persisted collapse state for a workbench pane, keyed per
|
|
5
|
+
* pane so multiple rails on one page don't collide. Generalized from
|
|
6
|
+
* hooks/useChatPaneCollapsed.ts. Falls back to per-tab state when storage
|
|
7
|
+
* is unavailable (private mode).
|
|
8
|
+
*/
|
|
9
|
+
export function usePaneCollapsed(
|
|
10
|
+
storageKey: string,
|
|
11
|
+
defaultCollapsed = false,
|
|
12
|
+
): { collapsed: boolean; toggle: () => void; setCollapsed: (v: boolean) => void } {
|
|
13
|
+
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
|
14
|
+
if (typeof window === "undefined") return defaultCollapsed;
|
|
15
|
+
try {
|
|
16
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
17
|
+
if (raw === null) return defaultCollapsed;
|
|
18
|
+
return raw === "1";
|
|
19
|
+
} catch {
|
|
20
|
+
return defaultCollapsed;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
try {
|
|
26
|
+
window.localStorage.setItem(storageKey, collapsed ? "1" : "0");
|
|
27
|
+
} catch {
|
|
28
|
+
// storage disabled — preference is per-tab only
|
|
29
|
+
}
|
|
30
|
+
}, [storageKey, collapsed]);
|
|
31
|
+
|
|
32
|
+
const toggle = useCallback(() => setCollapsed((c) => !c), []);
|
|
33
|
+
|
|
34
|
+
return { collapsed, toggle, setCollapsed };
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* localStorage-persisted width (px) for a resizable workbench rail, keyed
|
|
5
|
+
* per pane. Pair with WorkbenchRail's `resizable` + `onResize` to make a
|
|
6
|
+
* rail drag-to-resize. Falls back to per-tab state when storage is
|
|
7
|
+
* unavailable (private mode).
|
|
8
|
+
*/
|
|
9
|
+
export function usePaneWidth(
|
|
10
|
+
storageKey: string,
|
|
11
|
+
defaultWidth: number,
|
|
12
|
+
): { width: number; setWidth: (w: number) => void } {
|
|
13
|
+
const [width, setWidthState] = useState<number>(() => {
|
|
14
|
+
if (typeof window === "undefined") return defaultWidth;
|
|
15
|
+
try {
|
|
16
|
+
const raw = window.localStorage.getItem(storageKey);
|
|
17
|
+
const n = raw == null ? NaN : Number(raw);
|
|
18
|
+
return Number.isFinite(n) && n > 0 ? n : defaultWidth;
|
|
19
|
+
} catch {
|
|
20
|
+
return defaultWidth;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
try {
|
|
26
|
+
window.localStorage.setItem(storageKey, String(Math.round(width)));
|
|
27
|
+
} catch {
|
|
28
|
+
// storage disabled — width is per-tab only
|
|
29
|
+
}
|
|
30
|
+
}, [storageKey, width]);
|
|
31
|
+
|
|
32
|
+
const setWidth = useCallback((w: number) => setWidthState(w), []);
|
|
33
|
+
|
|
34
|
+
return { width, setWidth };
|
|
35
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-ui/tokens — the shared token contract.
|
|
3
|
+
*
|
|
4
|
+
* The substance of the token system is CSS, imported directly:
|
|
5
|
+
* import "canopy-ui/tokens/preset.css" // Tailwind v4 @theme name → var mapping
|
|
6
|
+
* import "canopy-ui/tokens/tokens.css" // default :root / .dark VALUES
|
|
7
|
+
*
|
|
8
|
+
* This module exposes the canonical list of token names so tooling/tests can
|
|
9
|
+
* assert an app's palette covers the contract.
|
|
10
|
+
*/
|
|
11
|
+
export const TOKEN_NAMES = [
|
|
12
|
+
'background',
|
|
13
|
+
'foreground',
|
|
14
|
+
'card',
|
|
15
|
+
'card-foreground',
|
|
16
|
+
'popover',
|
|
17
|
+
'popover-foreground',
|
|
18
|
+
'primary',
|
|
19
|
+
'primary-foreground',
|
|
20
|
+
'secondary',
|
|
21
|
+
'secondary-foreground',
|
|
22
|
+
'accent',
|
|
23
|
+
'accent-foreground',
|
|
24
|
+
'muted',
|
|
25
|
+
'muted-foreground',
|
|
26
|
+
'foreground-secondary',
|
|
27
|
+
'foreground-subtle',
|
|
28
|
+
'border',
|
|
29
|
+
'input',
|
|
30
|
+
'ring',
|
|
31
|
+
'destructive',
|
|
32
|
+
'destructive-foreground',
|
|
33
|
+
'success',
|
|
34
|
+
'success-foreground',
|
|
35
|
+
'warning',
|
|
36
|
+
'warning-foreground',
|
|
37
|
+
'info',
|
|
38
|
+
'info-foreground',
|
|
39
|
+
'special',
|
|
40
|
+
'special-foreground',
|
|
41
|
+
'radius',
|
|
42
|
+
] as const
|
|
43
|
+
|
|
44
|
+
export type TokenName = (typeof TOKEN_NAMES)[number]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-ui — Tailwind v4 token PRESET.
|
|
3
|
+
*
|
|
4
|
+
* This is the shared CONTRACT: it maps Tailwind color/radius utilities
|
|
5
|
+
* (`bg-primary`, `text-muted-foreground`, `border-border`, `rounded-lg`, …)
|
|
6
|
+
* onto a fixed set of CSS-variable NAMES (`--primary`, `--muted-foreground`,
|
|
7
|
+
* `--border`, `--radius`, …). Every app that builds on the workbench imports
|
|
8
|
+
* this preset so its primitives (Button, Badge, Table, …) resolve to the same
|
|
9
|
+
* utility names. The preset owns the NAMES; each app owns the VALUES (palette),
|
|
10
|
+
* which it sets on `:root` / `.dark` in its own globals (see tokens.css for the
|
|
11
|
+
* default values, which an app may import then override).
|
|
12
|
+
*
|
|
13
|
+
* Usage (in an app's index.css, after `@import "tailwindcss";`):
|
|
14
|
+
* @import "canopy-ui/tokens/preset.css";
|
|
15
|
+
* @import "canopy-ui/tokens/tokens.css"; // optional defaults
|
|
16
|
+
* :root { --primary: <app palette>; ... } // override values
|
|
17
|
+
*/
|
|
18
|
+
@theme inline {
|
|
19
|
+
/* Surfaces & text */
|
|
20
|
+
--color-background: var(--background);
|
|
21
|
+
--color-foreground: var(--foreground);
|
|
22
|
+
--color-card: var(--card);
|
|
23
|
+
--color-card-foreground: var(--card-foreground);
|
|
24
|
+
--color-popover: var(--popover);
|
|
25
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
26
|
+
|
|
27
|
+
/* Brand / interactive */
|
|
28
|
+
--color-primary: var(--primary);
|
|
29
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
30
|
+
--color-secondary: var(--secondary);
|
|
31
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
32
|
+
--color-accent: var(--accent);
|
|
33
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
34
|
+
|
|
35
|
+
/* Muted + extended neutral text scale (brightest → dimmest):
|
|
36
|
+
foreground > foreground-secondary > muted-foreground > foreground-subtle */
|
|
37
|
+
--color-muted: var(--muted);
|
|
38
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
39
|
+
--color-foreground-secondary: var(--foreground-secondary);
|
|
40
|
+
--color-foreground-subtle: var(--foreground-subtle);
|
|
41
|
+
|
|
42
|
+
/* Lines & focus */
|
|
43
|
+
--color-border: var(--border);
|
|
44
|
+
--color-input: var(--input);
|
|
45
|
+
--color-ring: var(--ring);
|
|
46
|
+
|
|
47
|
+
/* Destructive + status / categorical accents (badges, chips, status states) */
|
|
48
|
+
--color-destructive: var(--destructive);
|
|
49
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
50
|
+
--color-success: var(--success);
|
|
51
|
+
--color-success-foreground: var(--success-foreground);
|
|
52
|
+
--color-warning: var(--warning);
|
|
53
|
+
--color-warning-foreground: var(--warning-foreground);
|
|
54
|
+
--color-info: var(--info);
|
|
55
|
+
--color-info-foreground: var(--info-foreground);
|
|
56
|
+
--color-special: var(--special);
|
|
57
|
+
--color-special-foreground: var(--special-foreground);
|
|
58
|
+
|
|
59
|
+
/* Charts */
|
|
60
|
+
--color-chart-1: var(--chart-1);
|
|
61
|
+
--color-chart-2: var(--chart-2);
|
|
62
|
+
--color-chart-3: var(--chart-3);
|
|
63
|
+
--color-chart-4: var(--chart-4);
|
|
64
|
+
--color-chart-5: var(--chart-5);
|
|
65
|
+
|
|
66
|
+
/* Sidebar */
|
|
67
|
+
--color-sidebar: var(--sidebar);
|
|
68
|
+
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
69
|
+
--color-sidebar-primary: var(--sidebar-primary);
|
|
70
|
+
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
71
|
+
--color-sidebar-accent: var(--sidebar-accent);
|
|
72
|
+
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
73
|
+
--color-sidebar-border: var(--sidebar-border);
|
|
74
|
+
--color-sidebar-ring: var(--sidebar-ring);
|
|
75
|
+
|
|
76
|
+
/* Radius scale — derived from the single --radius value the app sets. */
|
|
77
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
78
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
79
|
+
--radius-lg: var(--radius);
|
|
80
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
81
|
+
--radius-2xl: calc(var(--radius) * 1.8);
|
|
82
|
+
--radius-3xl: calc(var(--radius) * 2.2);
|
|
83
|
+
--radius-4xl: calc(var(--radius) * 2.6);
|
|
84
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* canopy-ui — DEFAULT token VALUES (the palette baseline).
|
|
3
|
+
*
|
|
4
|
+
* These are the default values for the CSS-variable names declared by the
|
|
5
|
+
* preset (preset.css). They are a complete, working "Warm Earth" palette so a
|
|
6
|
+
* consuming app renders correctly out of the box. Each app is expected to
|
|
7
|
+
* OVERRIDE these in its own globals (define `:root` / `.dark` AFTER importing
|
|
8
|
+
* this file); identical var names mean only the values shift per app.
|
|
9
|
+
*
|
|
10
|
+
* The app defaults to `.dark` (apps apply the `.dark` class pre-render); the
|
|
11
|
+
* `:root` light set is the opt-in companion. App-specific concerns (font face,
|
|
12
|
+
* custom animations) are NOT defined here — they live in each app's globals.
|
|
13
|
+
*/
|
|
14
|
+
:root {
|
|
15
|
+
/* Warm Earth LIGHT — the opt-in companion. */
|
|
16
|
+
--background: oklch(0.985 0.002 106.42); /* warm off-white (~stone-50) */
|
|
17
|
+
--foreground: oklch(0.216 0.006 56.04); /* ~stone-900 */
|
|
18
|
+
--card: oklch(1 0 0); /* white */
|
|
19
|
+
--card-foreground: oklch(0.216 0.006 56.04);
|
|
20
|
+
--popover: oklch(1 0 0);
|
|
21
|
+
--popover-foreground: oklch(0.216 0.006 56.04);
|
|
22
|
+
--primary: oklch(0.646 0.222 41.12); /* ~orange-600 (readable on white) */
|
|
23
|
+
--primary-foreground: oklch(1 0 0);
|
|
24
|
+
--secondary: oklch(0.970 0.001 106.42); /* ~stone-100 */
|
|
25
|
+
--secondary-foreground: oklch(0.216 0.006 56.04);
|
|
26
|
+
--muted: oklch(0.970 0.001 106.42); /* ~stone-100 */
|
|
27
|
+
--muted-foreground: oklch(0.553 0.013 58.07); /* ~stone-500 */
|
|
28
|
+
--foreground-secondary: oklch(0.374 0.01 67.56); /* ~stone-700 — secondary text */
|
|
29
|
+
--foreground-subtle: oklch(0.709 0.01 56.26); /* ~stone-400 — faint text */
|
|
30
|
+
--accent: oklch(0.970 0.001 106.42);
|
|
31
|
+
--accent-foreground: oklch(0.216 0.006 56.04);
|
|
32
|
+
--destructive: oklch(0.577 0.245 27.325); /* ~red-600 */
|
|
33
|
+
--destructive-foreground: oklch(1 0 0);
|
|
34
|
+
--success: oklch(0.596 0.145 163.22); /* ~emerald-600 */
|
|
35
|
+
--success-foreground: oklch(1 0 0);
|
|
36
|
+
--warning: oklch(0.666 0.179 58.32); /* ~amber-600 */
|
|
37
|
+
--warning-foreground: oklch(1 0 0);
|
|
38
|
+
--info: oklch(0.588 0.158 241.97); /* ~sky-600 */
|
|
39
|
+
--info-foreground: oklch(1 0 0);
|
|
40
|
+
--special: oklch(0.541 0.281 293.0); /* ~violet-600 */
|
|
41
|
+
--special-foreground: oklch(1 0 0);
|
|
42
|
+
--border: oklch(0.869 0.005 56.37); /* ~stone-200 */
|
|
43
|
+
--input: oklch(0.869 0.005 56.37); /* ~stone-200 */
|
|
44
|
+
--ring: oklch(0.646 0.222 41.12 / 0.4); /* orange-600/40 */
|
|
45
|
+
--chart-1: oklch(0.646 0.222 41.12);
|
|
46
|
+
--chart-2: oklch(0.553 0.013 58.07);
|
|
47
|
+
--chart-3: oklch(0.709 0.01 56.26);
|
|
48
|
+
--chart-4: oklch(0.809 0.005 56.0);
|
|
49
|
+
--chart-5: oklch(0.869 0.005 56.37);
|
|
50
|
+
--radius: 0.625rem; /* theme-agnostic; inherited by .dark */
|
|
51
|
+
--sidebar: oklch(0.985 0.002 106.42);
|
|
52
|
+
--sidebar-foreground: oklch(0.216 0.006 56.04);
|
|
53
|
+
--sidebar-primary: oklch(0.646 0.222 41.12);
|
|
54
|
+
--sidebar-primary-foreground: oklch(1 0 0);
|
|
55
|
+
--sidebar-accent: oklch(0.970 0.001 106.42);
|
|
56
|
+
--sidebar-accent-foreground: oklch(0.216 0.006 56.04);
|
|
57
|
+
--sidebar-border: oklch(0.869 0.005 56.37);
|
|
58
|
+
--sidebar-ring: oklch(0.646 0.222 41.12 / 0.4);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dark {
|
|
62
|
+
/* Warm Earth DARK — the default. */
|
|
63
|
+
--background: oklch(0.147 0.004 49.25); /* ~stone-950 */
|
|
64
|
+
--foreground: oklch(0.923 0.003 48.72); /* ~stone-100 */
|
|
65
|
+
--card: oklch(0.216 0.006 56.04); /* ~stone-900 */
|
|
66
|
+
--card-foreground: oklch(0.923 0.003 48.72);
|
|
67
|
+
--popover: oklch(0.216 0.006 56.04);
|
|
68
|
+
--popover-foreground: oklch(0.923 0.003 48.72);
|
|
69
|
+
--primary: oklch(0.757 0.161 53.57); /* orange-400 */
|
|
70
|
+
--primary-foreground: oklch(0.147 0.004 49.25);
|
|
71
|
+
--secondary: oklch(0.268 0.007 34.3); /* ~stone-800 */
|
|
72
|
+
--secondary-foreground: oklch(0.923 0.003 48.72);
|
|
73
|
+
--muted: oklch(0.268 0.007 34.3); /* ~stone-800 */
|
|
74
|
+
--muted-foreground: oklch(0.553 0.013 58.07);/* ~stone-500 */
|
|
75
|
+
--foreground-secondary: oklch(0.809 0.005 56.0); /* ~stone-300 — secondary text */
|
|
76
|
+
--foreground-subtle: oklch(0.374 0.01 67.56); /* ~stone-700 — faint text */
|
|
77
|
+
--accent: oklch(0.268 0.007 34.3);
|
|
78
|
+
--accent-foreground: oklch(0.923 0.003 48.72);
|
|
79
|
+
--destructive: oklch(0.704 0.191 22.216); /* ~red-500 — errors / destructive */
|
|
80
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
81
|
+
--success: oklch(0.765 0.177 163.22); /* ~emerald-400 — success / opportunity */
|
|
82
|
+
--success-foreground: oklch(0.147 0.004 49.25);
|
|
83
|
+
--warning: oklch(0.828 0.189 84.43); /* ~amber-400 — warning / ship-gap */
|
|
84
|
+
--warning-foreground: oklch(0.147 0.004 49.25);
|
|
85
|
+
--info: oklch(0.746 0.16 232.66); /* ~sky-400 — info / alignment */
|
|
86
|
+
--info-foreground: oklch(0.147 0.004 49.25);
|
|
87
|
+
--special: oklch(0.606 0.25 292.72); /* ~violet-400 — pattern / highlight */
|
|
88
|
+
--special-foreground: oklch(0.985 0 0);
|
|
89
|
+
--border: oklch(0.268 0.007 34.3); /* ~stone-800 */
|
|
90
|
+
--input: oklch(0.374 0.01 67.56); /* ~stone-700 */
|
|
91
|
+
--ring: oklch(0.757 0.161 53.57 / 0.4); /* orange-400/40 */
|
|
92
|
+
--chart-1: oklch(0.757 0.161 53.57);
|
|
93
|
+
--chart-2: oklch(0.553 0.013 58.07);
|
|
94
|
+
--chart-3: oklch(0.439 0 0);
|
|
95
|
+
--chart-4: oklch(0.371 0 0);
|
|
96
|
+
--chart-5: oklch(0.269 0 0);
|
|
97
|
+
--sidebar: oklch(0.147 0.004 49.25);
|
|
98
|
+
--sidebar-foreground: oklch(0.923 0.003 48.72);
|
|
99
|
+
--sidebar-primary: oklch(0.757 0.161 53.57);
|
|
100
|
+
--sidebar-primary-foreground: oklch(0.147 0.004 49.25);
|
|
101
|
+
--sidebar-accent: oklch(0.216 0.006 56.04);
|
|
102
|
+
--sidebar-accent-foreground: oklch(0.923 0.003 48.72);
|
|
103
|
+
--sidebar-border: oklch(0.268 0.007 34.3);
|
|
104
|
+
--sidebar-ring: oklch(0.757 0.161 53.57 / 0.4);
|
|
105
|
+
}
|
package/src/ui/badge.tsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { mergeProps } from "@base-ui/react/merge-props"
|
|
2
|
+
import { useRender } from "@base-ui/react/use-render"
|
|
3
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn"
|
|
6
|
+
|
|
7
|
+
const badgeVariants = cva(
|
|
8
|
+
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
|
9
|
+
{
|
|
10
|
+
variants: {
|
|
11
|
+
variant: {
|
|
12
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
13
|
+
secondary:
|
|
14
|
+
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
|
15
|
+
destructive:
|
|
16
|
+
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
|
17
|
+
outline:
|
|
18
|
+
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
|
19
|
+
ghost:
|
|
20
|
+
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
|
21
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
defaultVariants: {
|
|
25
|
+
variant: "default",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
function Badge({
|
|
31
|
+
className,
|
|
32
|
+
variant = "default",
|
|
33
|
+
render,
|
|
34
|
+
...props
|
|
35
|
+
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
|
36
|
+
return useRender({
|
|
37
|
+
defaultTagName: "span",
|
|
38
|
+
props: mergeProps<"span">(
|
|
39
|
+
{
|
|
40
|
+
className: cn(badgeVariants({ variant }), className),
|
|
41
|
+
},
|
|
42
|
+
props
|
|
43
|
+
),
|
|
44
|
+
render,
|
|
45
|
+
state: {
|
|
46
|
+
slot: "badge",
|
|
47
|
+
variant,
|
|
48
|
+
},
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
|
|
6
|
+
const buttonVariants = cva(
|
|
7
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
|
+
{
|
|
9
|
+
variants: {
|
|
10
|
+
variant: {
|
|
11
|
+
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
12
|
+
outline:
|
|
13
|
+
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
14
|
+
secondary:
|
|
15
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
16
|
+
ghost:
|
|
17
|
+
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
18
|
+
destructive:
|
|
19
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
20
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
default:
|
|
24
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
25
|
+
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
26
|
+
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
27
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
28
|
+
icon: "size-8",
|
|
29
|
+
"icon-xs":
|
|
30
|
+
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
31
|
+
"icon-sm":
|
|
32
|
+
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
33
|
+
"icon-lg": "size-9",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
defaultVariants: {
|
|
37
|
+
variant: "default",
|
|
38
|
+
size: "default",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
function Button({
|
|
44
|
+
className,
|
|
45
|
+
variant = "default",
|
|
46
|
+
size = "default",
|
|
47
|
+
...props
|
|
48
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
49
|
+
return (
|
|
50
|
+
<ButtonPrimitive
|
|
51
|
+
data-slot="button"
|
|
52
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { Button, buttonVariants }
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { Button, buttonVariants } from './button'
|
|
2
|
+
export { Badge, badgeVariants } from './badge'
|
|
3
|
+
export { Input } from './input'
|
|
4
|
+
export { Skeleton } from './skeleton'
|
|
5
|
+
export { Textarea } from './textarea'
|
|
6
|
+
export {
|
|
7
|
+
Table,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableBody,
|
|
10
|
+
TableFooter,
|
|
11
|
+
TableHead,
|
|
12
|
+
TableRow,
|
|
13
|
+
TableCell,
|
|
14
|
+
TableCaption,
|
|
15
|
+
} from './table'
|
|
16
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } from './tabs'
|
package/src/ui/input.tsx
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { Input as InputPrimitive } from "@base-ui/react/input"
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn"
|
|
5
|
+
|
|
6
|
+
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
7
|
+
return (
|
|
8
|
+
<InputPrimitive
|
|
9
|
+
type={type}
|
|
10
|
+
data-slot="input"
|
|
11
|
+
className={cn(
|
|
12
|
+
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-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 disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
13
|
+
className
|
|
14
|
+
)}
|
|
15
|
+
{...props}
|
|
16
|
+
/>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { Input }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn"
|
|
4
|
+
|
|
5
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="skeleton"
|
|
9
|
+
className={cn("animate-pulse rounded-md bg-muted", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export { Skeleton }
|
package/src/ui/table.tsx
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn"
|
|
4
|
+
|
|
5
|
+
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
data-slot="table-container"
|
|
9
|
+
className="relative w-full overflow-x-auto rounded-lg border border-border bg-card"
|
|
10
|
+
>
|
|
11
|
+
<table
|
|
12
|
+
data-slot="table"
|
|
13
|
+
className={cn("w-full caption-bottom text-sm", className)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
21
|
+
return (
|
|
22
|
+
<thead
|
|
23
|
+
data-slot="table-header"
|
|
24
|
+
className={cn("bg-background [&_tr]:border-b [&_tr]:border-border", className)}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
31
|
+
return (
|
|
32
|
+
<tbody
|
|
33
|
+
data-slot="table-body"
|
|
34
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
35
|
+
{...props}
|
|
36
|
+
/>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
41
|
+
return (
|
|
42
|
+
<tfoot
|
|
43
|
+
data-slot="table-footer"
|
|
44
|
+
className={cn(
|
|
45
|
+
"border-t border-border bg-background font-medium text-foreground-secondary [&>tr]:last:border-b-0",
|
|
46
|
+
className
|
|
47
|
+
)}
|
|
48
|
+
{...props}
|
|
49
|
+
/>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
54
|
+
return (
|
|
55
|
+
<tr
|
|
56
|
+
data-slot="table-row"
|
|
57
|
+
className={cn(
|
|
58
|
+
"border-b border-border transition-colors hover:bg-card/50 data-[state=selected]:bg-muted",
|
|
59
|
+
className
|
|
60
|
+
)}
|
|
61
|
+
{...props}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
67
|
+
return (
|
|
68
|
+
<th
|
|
69
|
+
data-slot="table-head"
|
|
70
|
+
className={cn(
|
|
71
|
+
"h-9 px-3 text-left align-middle text-[10px] font-semibold uppercase tracking-wider whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
|
72
|
+
className
|
|
73
|
+
)}
|
|
74
|
+
{...props}
|
|
75
|
+
/>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
80
|
+
return (
|
|
81
|
+
<td
|
|
82
|
+
data-slot="table-cell"
|
|
83
|
+
className={cn(
|
|
84
|
+
"px-3 py-2.5 align-middle whitespace-nowrap text-foreground-secondary [&:has([role=checkbox])]:pr-0",
|
|
85
|
+
className
|
|
86
|
+
)}
|
|
87
|
+
{...props}
|
|
88
|
+
/>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function TableCaption({
|
|
93
|
+
className,
|
|
94
|
+
...props
|
|
95
|
+
}: React.ComponentProps<"caption">) {
|
|
96
|
+
return (
|
|
97
|
+
<caption
|
|
98
|
+
data-slot="table-caption"
|
|
99
|
+
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
|
100
|
+
{...props}
|
|
101
|
+
/>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export {
|
|
106
|
+
Table,
|
|
107
|
+
TableHeader,
|
|
108
|
+
TableBody,
|
|
109
|
+
TableFooter,
|
|
110
|
+
TableHead,
|
|
111
|
+
TableRow,
|
|
112
|
+
TableCell,
|
|
113
|
+
TableCaption,
|
|
114
|
+
}
|
package/src/ui/tabs.tsx
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
|
|
8
|
+
function Tabs({
|
|
9
|
+
className,
|
|
10
|
+
orientation = "horizontal",
|
|
11
|
+
...props
|
|
12
|
+
}: TabsPrimitive.Root.Props) {
|
|
13
|
+
return (
|
|
14
|
+
<TabsPrimitive.Root
|
|
15
|
+
data-slot="tabs"
|
|
16
|
+
data-orientation={orientation}
|
|
17
|
+
className={cn(
|
|
18
|
+
"group/tabs flex gap-2 data-horizontal:flex-col",
|
|
19
|
+
className
|
|
20
|
+
)}
|
|
21
|
+
{...props}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const tabsListVariants = cva(
|
|
27
|
+
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
28
|
+
{
|
|
29
|
+
variants: {
|
|
30
|
+
variant: {
|
|
31
|
+
default: "bg-card border border-border",
|
|
32
|
+
line: "gap-1 bg-transparent border-b border-border",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
defaultVariants: {
|
|
36
|
+
variant: "default",
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
function TabsList({
|
|
42
|
+
className,
|
|
43
|
+
variant = "default",
|
|
44
|
+
...props
|
|
45
|
+
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
|
46
|
+
return (
|
|
47
|
+
<TabsPrimitive.List
|
|
48
|
+
data-slot="tabs-list"
|
|
49
|
+
data-variant={variant}
|
|
50
|
+
className={cn(tabsListVariants({ variant }), className)}
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|
57
|
+
return (
|
|
58
|
+
<TabsPrimitive.Tab
|
|
59
|
+
data-slot="tabs-trigger"
|
|
60
|
+
className={cn(
|
|
61
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
62
|
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:border-transparent",
|
|
63
|
+
"data-active:bg-background data-active:text-foreground data-active:border-border",
|
|
64
|
+
"after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
|
65
|
+
className
|
|
66
|
+
)}
|
|
67
|
+
{...props}
|
|
68
|
+
/>
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
|
73
|
+
return (
|
|
74
|
+
<TabsPrimitive.Panel
|
|
75
|
+
data-slot="tabs-content"
|
|
76
|
+
className={cn("flex-1 text-sm outline-none", className)}
|
|
77
|
+
{...props}
|
|
78
|
+
/>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn"
|
|
4
|
+
|
|
5
|
+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|
6
|
+
return (
|
|
7
|
+
<textarea
|
|
8
|
+
data-slot="textarea"
|
|
9
|
+
className={cn(
|
|
10
|
+
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none 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 disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
11
|
+
className
|
|
12
|
+
)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Textarea }
|