@windforge/ui 0.1.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/dist/index.d.ts +1195 -0
- package/dist/index.js +3628 -0
- package/package.json +66 -0
- package/src/catalog.ts +654 -0
- package/src/components/accordion.tsx +91 -0
- package/src/components/alert.tsx +58 -0
- package/src/components/autocomplete.tsx +174 -0
- package/src/components/avatar.tsx +60 -0
- package/src/components/badge.tsx +37 -0
- package/src/components/breadcrumb.tsx +62 -0
- package/src/components/button-group.tsx +23 -0
- package/src/components/button.tsx +53 -0
- package/src/components/calendar.tsx +61 -0
- package/src/components/card.tsx +72 -0
- package/src/components/chart.tsx +130 -0
- package/src/components/checkbox.tsx +27 -0
- package/src/components/chip.tsx +75 -0
- package/src/components/code-block.tsx +126 -0
- package/src/components/command.tsx +139 -0
- package/src/components/data-table.tsx +194 -0
- package/src/components/date-picker.tsx +77 -0
- package/src/components/dialog.tsx +57 -0
- package/src/components/dropdown-menu.tsx +186 -0
- package/src/components/form-field.tsx +97 -0
- package/src/components/input.tsx +29 -0
- package/src/components/label.tsx +18 -0
- package/src/components/layout.tsx +179 -0
- package/src/components/link.tsx +37 -0
- package/src/components/modal.tsx +67 -0
- package/src/components/multi-select.tsx +175 -0
- package/src/components/pagination.tsx +72 -0
- package/src/components/popover.tsx +25 -0
- package/src/components/progress.tsx +31 -0
- package/src/components/radio-group.tsx +34 -0
- package/src/components/select.tsx +134 -0
- package/src/components/separator.tsx +21 -0
- package/src/components/sheet.tsx +80 -0
- package/src/components/skeleton.tsx +11 -0
- package/src/components/slider.tsx +28 -0
- package/src/components/stepper.tsx +69 -0
- package/src/components/switch.tsx +33 -0
- package/src/components/table.tsx +121 -0
- package/src/components/tabs.tsx +90 -0
- package/src/components/text.tsx +109 -0
- package/src/components/textarea.tsx +27 -0
- package/src/components/toast.tsx +107 -0
- package/src/components/toggle-button.tsx +103 -0
- package/src/components/tooltip.tsx +26 -0
- package/src/icons/forge-icon.tsx +55 -0
- package/src/icons/icon-set.ts +60 -0
- package/src/icons/svg-icon.tsx +43 -0
- package/src/index.ts +80 -0
- package/src/layouts/app-bar.tsx +95 -0
- package/src/layouts/app-shell.tsx +80 -0
- package/src/layouts/side-nav.tsx +196 -0
- package/src/layouts/theme-provider.tsx +128 -0
- package/src/lib/recipes.ts +50 -0
- package/src/lib/types.ts +3 -0
- package/src/lib/use-media-query.ts +18 -0
- package/src/lib/utils.ts +10 -0
- package/tailwind-preset.cjs +77 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Box, type BoxProps } from './layout'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
import type { NoStyle } from '../lib/types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Card — a surface that groups related content. Pass `title`, `description`,
|
|
8
|
+
* an optional `headerAction` (e.g. a Badge opposite the title), `footer`
|
|
9
|
+
* actions, and the body as `children`; there are no sub-components to assemble.
|
|
10
|
+
* Under the hood it's a `Box` with the universal surface preset, so any Box prop
|
|
11
|
+
* (`boxShadow`, `borderRadius`, …) still applies.
|
|
12
|
+
*
|
|
13
|
+
* <Card title="Deploy preview" description="Pushed 4 minutes ago"
|
|
14
|
+
* footer={<Button>Visit preview</Button>}>
|
|
15
|
+
* Vercel finished building your branch.
|
|
16
|
+
* </Card>
|
|
17
|
+
*/
|
|
18
|
+
// Card owns its padding (the header/body/footer sections carry it) so it can
|
|
19
|
+
// never double up against the outer surface Box. `padding` overrides the
|
|
20
|
+
// section inset on the spacing scale; default `lg` keeps the original 24px look.
|
|
21
|
+
type CardPadding = NonNullable<BoxProps['padding']>
|
|
22
|
+
const SECTION_PADDING: Record<CardPadding, string> = {
|
|
23
|
+
none: 'p-none', xs: 'p-xs', sm: 'p-sm', md: 'p-md', lg: 'p-lg', xl: 'p-xl', '2xl': 'p-2xl',
|
|
24
|
+
nbsp: 'p-nbsp', card: 'p-card', gutter: 'p-gutter', section: 'p-section', page: 'p-page',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CardProps extends Omit<NoStyle<BoxProps>, 'title' | 'paddingX' | 'paddingY'> {
|
|
28
|
+
title?: React.ReactNode
|
|
29
|
+
description?: React.ReactNode
|
|
30
|
+
/** Trailing header content — sits opposite the title (e.g. a status Badge). */
|
|
31
|
+
headerAction?: React.ReactNode
|
|
32
|
+
/** Footer content — typically buttons. */
|
|
33
|
+
footer?: React.ReactNode
|
|
34
|
+
/** Inset for the header/body/footer sections. Overrides (never stacks on) the
|
|
35
|
+
* default; defaults to the `card` spacing token. */
|
|
36
|
+
padding?: CardPadding
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|
40
|
+
({ title, description, headerAction, footer, children, padding = 'card', ...props }, ref) => {
|
|
41
|
+
const hasHeader = title != null || description != null || headerAction != null
|
|
42
|
+
const pad = SECTION_PADDING[padding]
|
|
43
|
+
return (
|
|
44
|
+
<Box
|
|
45
|
+
ref={ref}
|
|
46
|
+
background="surface"
|
|
47
|
+
border="default"
|
|
48
|
+
borderRadius="2xl"
|
|
49
|
+
boxShadow="sm"
|
|
50
|
+
className={cn('text-primary')}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{hasHeader && (
|
|
54
|
+
<div className={cn('flex flex-col gap-1.5', pad)}>
|
|
55
|
+
{(title != null || headerAction != null) && (
|
|
56
|
+
<div className={cn('flex items-center justify-between gap-3')}>
|
|
57
|
+
{title != null && <div className={cn('text-lg font-semibold leading-snug tracking-tight')}>{title}</div>}
|
|
58
|
+
{headerAction}
|
|
59
|
+
</div>
|
|
60
|
+
)}
|
|
61
|
+
{description != null && <div className={cn('text-sm text-primary')}>{description}</div>}
|
|
62
|
+
</div>
|
|
63
|
+
)}
|
|
64
|
+
{children != null && <div className={cn(pad, hasHeader && 'pt-0')}>{children}</div>}
|
|
65
|
+
{footer != null && (
|
|
66
|
+
<div className={cn('flex items-center gap-3', pad, (hasHeader || children != null) && 'pt-0')}>{footer}</div>
|
|
67
|
+
)}
|
|
68
|
+
</Box>
|
|
69
|
+
)
|
|
70
|
+
},
|
|
71
|
+
)
|
|
72
|
+
Card.displayName = 'Card'
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as echarts from 'echarts/core'
|
|
3
|
+
import type { EChartsCoreOption } from 'echarts/core'
|
|
4
|
+
import { LineChart, BarChart, PieChart, ScatterChart } from 'echarts/charts'
|
|
5
|
+
import {
|
|
6
|
+
TitleComponent, TooltipComponent, GridComponent, LegendComponent,
|
|
7
|
+
DataZoomComponent, MarkLineComponent, MarkAreaComponent,
|
|
8
|
+
} from 'echarts/components'
|
|
9
|
+
import { CanvasRenderer } from 'echarts/renderers'
|
|
10
|
+
|
|
11
|
+
echarts.use([
|
|
12
|
+
LineChart, BarChart, PieChart, ScatterChart,
|
|
13
|
+
TitleComponent, TooltipComponent, GridComponent, LegendComponent,
|
|
14
|
+
DataZoomComponent, MarkLineComponent, MarkAreaComponent,
|
|
15
|
+
CanvasRenderer,
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Chart — a token-driven ECharts surface. Pass a standard ECharts `option`; the
|
|
20
|
+
* series palette, axes, grid, tooltip, and fonts are themed from the live `--wf-*`
|
|
21
|
+
* tokens, so charts re-skin with the brand and flip with light/dark automatically
|
|
22
|
+
* (no ThemeProvider required — it watches the document root). Auto-resizes.
|
|
23
|
+
*
|
|
24
|
+
* <Chart option={{ xAxis: { type: 'category', data: months },
|
|
25
|
+
* yAxis: { type: 'value' },
|
|
26
|
+
* series: [{ type: 'line', data: revenue }] }} />
|
|
27
|
+
*
|
|
28
|
+
* ECharts is the one canvas dependency; everything else stays DOM + tokens.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChartProps {
|
|
31
|
+
/** A standard ECharts option object. */
|
|
32
|
+
option: EChartsCoreOption
|
|
33
|
+
/** Pixel or CSS height (width always fills the container). Default 320. */
|
|
34
|
+
height?: number | string
|
|
35
|
+
/** Replace the previous option wholesale instead of merging. Default true. */
|
|
36
|
+
notMerge?: boolean
|
|
37
|
+
/** ECharts events to bind, e.g. `{ click: (p) => … }`. */
|
|
38
|
+
onEvents?: Record<string, (params: unknown) => void>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cssVar = (name: string) =>
|
|
42
|
+
getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
|
43
|
+
|
|
44
|
+
/** Build an ECharts theme object from the resolved Windforge tokens. */
|
|
45
|
+
function buildTheme(): object {
|
|
46
|
+
const text = cssVar('--wf-color-text-primary')
|
|
47
|
+
const muted = cssVar('--wf-color-text-secondary')
|
|
48
|
+
const faint = cssVar('--wf-color-text-tertiary')
|
|
49
|
+
const line = cssVar('--wf-color-border-default')
|
|
50
|
+
const subtle = cssVar('--wf-color-border-subtle')
|
|
51
|
+
const surface = cssVar('--wf-color-background-paper')
|
|
52
|
+
const font = cssVar('--wf-font-sans')
|
|
53
|
+
const palette = [
|
|
54
|
+
cssVar('--wf-color-brand-primary'),
|
|
55
|
+
cssVar('--wf-color-status-info-default'),
|
|
56
|
+
cssVar('--wf-color-status-success-default'),
|
|
57
|
+
cssVar('--wf-color-status-warning-default'),
|
|
58
|
+
cssVar('--wf-color-status-error-default'),
|
|
59
|
+
cssVar('--wf-color-brand-secondary'),
|
|
60
|
+
].filter(Boolean)
|
|
61
|
+
|
|
62
|
+
const axis = {
|
|
63
|
+
axisLine: { lineStyle: { color: line } },
|
|
64
|
+
axisTick: { lineStyle: { color: line } },
|
|
65
|
+
axisLabel: { color: muted },
|
|
66
|
+
splitLine: { lineStyle: { color: subtle } },
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
color: palette,
|
|
70
|
+
backgroundColor: 'transparent',
|
|
71
|
+
textStyle: { color: text, fontFamily: font },
|
|
72
|
+
title: { textStyle: { color: text }, subtextStyle: { color: faint } },
|
|
73
|
+
legend: { textStyle: { color: muted } },
|
|
74
|
+
tooltip: {
|
|
75
|
+
backgroundColor: surface,
|
|
76
|
+
borderColor: line,
|
|
77
|
+
textStyle: { color: text, fontFamily: font },
|
|
78
|
+
axisPointer: { lineStyle: { color: muted }, crossStyle: { color: muted } },
|
|
79
|
+
},
|
|
80
|
+
categoryAxis: axis,
|
|
81
|
+
valueAxis: axis,
|
|
82
|
+
logAxis: axis,
|
|
83
|
+
timeAxis: axis,
|
|
84
|
+
// Series labels (notably pie's outside labels) don't inherit the global
|
|
85
|
+
// textStyle — pin them to the foreground token so they stay readable in both
|
|
86
|
+
// modes, and tint the connector lines with the border token.
|
|
87
|
+
pie: {
|
|
88
|
+
label: { color: text, fontFamily: font },
|
|
89
|
+
labelLine: { lineStyle: { color: line } },
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function Chart({ option, height = 320, notMerge = true, onEvents }: ChartProps) {
|
|
95
|
+
const elRef = React.useRef<HTMLDivElement>(null)
|
|
96
|
+
const chartRef = React.useRef<echarts.ECharts | null>(null)
|
|
97
|
+
// Bump on any document-root attribute change (mode class / runtime token style),
|
|
98
|
+
// so the chart re-inits with the new theme — provider-independent reactivity.
|
|
99
|
+
const [themeKey, bump] = React.useReducer((x: number) => x + 1, 0)
|
|
100
|
+
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
const mo = new MutationObserver(() => bump())
|
|
103
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] })
|
|
104
|
+
return () => mo.disconnect()
|
|
105
|
+
}, [])
|
|
106
|
+
|
|
107
|
+
// (Re)create the instance when the theme changes; ECharts bakes the theme at init.
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (!elRef.current) return
|
|
110
|
+
const chart = echarts.init(elRef.current, buildTheme())
|
|
111
|
+
chartRef.current = chart
|
|
112
|
+
chart.setOption(option, notMerge)
|
|
113
|
+
if (onEvents) for (const [name, handler] of Object.entries(onEvents)) chart.on(name, handler)
|
|
114
|
+
const ro = new ResizeObserver(() => chart.resize())
|
|
115
|
+
ro.observe(elRef.current)
|
|
116
|
+
return () => {
|
|
117
|
+
ro.disconnect()
|
|
118
|
+
chart.dispose()
|
|
119
|
+
chartRef.current = null
|
|
120
|
+
}
|
|
121
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
|
+
}, [themeKey])
|
|
123
|
+
|
|
124
|
+
// Push option updates to the existing instance (no re-init needed).
|
|
125
|
+
React.useEffect(() => {
|
|
126
|
+
chartRef.current?.setOption(option, notMerge)
|
|
127
|
+
}, [option, notMerge])
|
|
128
|
+
|
|
129
|
+
return <div ref={elRef} role="img" style={{ height, width: '100%' }} />
|
|
130
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
|
|
3
|
+
import { Check } from 'lucide-react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import type { NoStyle } from '../lib/types'
|
|
6
|
+
import { focusRing } from '../lib/recipes'
|
|
7
|
+
|
|
8
|
+
export const Checkbox = React.forwardRef<
|
|
9
|
+
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
|
10
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>>
|
|
11
|
+
>(({ ...props }, ref) => (
|
|
12
|
+
<CheckboxPrimitive.Root
|
|
13
|
+
ref={ref}
|
|
14
|
+
className={cn(
|
|
15
|
+
'peer h-5 w-5 shrink-0 rounded-sm border border-strong transition-colors',
|
|
16
|
+
focusRing,
|
|
17
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
18
|
+
'data-[state=checked]:border-transparent data-[state=checked]:bg-surface-inverse data-[state=checked]:text-inverse',
|
|
19
|
+
)}
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<CheckboxPrimitive.Indicator className={cn('flex items-center justify-center text-current')}>
|
|
23
|
+
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
|
24
|
+
</CheckboxPrimitive.Indicator>
|
|
25
|
+
</CheckboxPrimitive.Root>
|
|
26
|
+
))
|
|
27
|
+
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import type { NoStyle } from '../lib/types'
|
|
6
|
+
import { focusRingInset } from '../lib/recipes'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Chip — an interactive pill: clickable (filter/selection) and optionally
|
|
10
|
+
* deletable. Distinct from `Badge`, which is a static label. Set `selected` for
|
|
11
|
+
* the active state and pass `onDelete` to show a trailing ✕.
|
|
12
|
+
*/
|
|
13
|
+
const chip = cva(
|
|
14
|
+
'inline-flex items-center gap-1.5 rounded-full border text-sm font-medium transition-colors ' +
|
|
15
|
+
'[&_svg]:size-3.5 disabled:opacity-50 disabled:pointer-events-none',
|
|
16
|
+
{
|
|
17
|
+
variants: {
|
|
18
|
+
selected: {
|
|
19
|
+
true: 'border-transparent bg-surface-inverse text-inverse',
|
|
20
|
+
false: 'border-strong bg-surface text-primary hover:bg-surface-subtle',
|
|
21
|
+
},
|
|
22
|
+
clickable: { true: 'cursor-pointer ' + focusRingInset, false: '' },
|
|
23
|
+
size: {
|
|
24
|
+
sm: 'px-2 py-0.5',
|
|
25
|
+
md: 'px-3 py-1',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: { selected: false, clickable: false, size: 'md' },
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export interface ChipProps
|
|
33
|
+
extends NoStyle<Omit<React.HTMLAttributes<HTMLElement>, 'onClick'>>,
|
|
34
|
+
VariantProps<typeof chip> {
|
|
35
|
+
onClick?: React.MouseEventHandler<HTMLButtonElement>
|
|
36
|
+
/** When provided, renders a trailing ✕ that calls this instead of selecting. */
|
|
37
|
+
onDelete?: () => void
|
|
38
|
+
disabled?: boolean
|
|
39
|
+
icon?: React.ReactNode
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const Chip = React.forwardRef<HTMLElement, ChipProps>(
|
|
43
|
+
({ selected, size, onClick, onDelete, disabled, icon, children, ...props }, ref) => {
|
|
44
|
+
const clickable = !!onClick
|
|
45
|
+
const Comp: React.ElementType = clickable ? 'button' : 'span'
|
|
46
|
+
return (
|
|
47
|
+
<Comp
|
|
48
|
+
ref={ref as never}
|
|
49
|
+
className={cn(chip({ selected, clickable, size }))}
|
|
50
|
+
{...(clickable ? { type: 'button', onClick, disabled, 'aria-pressed': !!selected } : {})}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{icon}
|
|
54
|
+
{children}
|
|
55
|
+
{onDelete && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
aria-label="Remove"
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onClick={(event) => {
|
|
61
|
+
event.stopPropagation()
|
|
62
|
+
onDelete()
|
|
63
|
+
}}
|
|
64
|
+
className={cn('-mr-1 ml-0.5 rounded-full p-0.5 opacity-70 hover:opacity-100', focusRingInset)}
|
|
65
|
+
>
|
|
66
|
+
<X />
|
|
67
|
+
</button>
|
|
68
|
+
)}
|
|
69
|
+
</Comp>
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
Chip.displayName = 'Chip'
|
|
74
|
+
|
|
75
|
+
export { chip as chipVariants }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Highlight, type PrismTheme } from 'prism-react-renderer'
|
|
3
|
+
import { Check, Copy } from 'lucide-react'
|
|
4
|
+
import { cn } from '../lib/utils'
|
|
5
|
+
import { focusRingInset } from '../lib/recipes'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CodeBlock — the on-system, syntax-highlighted code surface. One source of truth
|
|
9
|
+
* for every snippet in the system (docs, examples, anywhere). Highlighting runs
|
|
10
|
+
* locally via prism-react-renderer — no network, no API. The token theme below is
|
|
11
|
+
* built from `var(--wf-*)` references, so colors resolve live: the code re-skins
|
|
12
|
+
* with light/dark and with a brand swap automatically, no JS reactivity needed.
|
|
13
|
+
*
|
|
14
|
+
* Takes no className by design — its surface is fixed so every snippet in a product
|
|
15
|
+
* renders identically.
|
|
16
|
+
*
|
|
17
|
+
* <CodeBlock code={src} language="tsx" filename="button.tsx" showLineNumbers />
|
|
18
|
+
*/
|
|
19
|
+
export interface CodeBlockProps {
|
|
20
|
+
code: string
|
|
21
|
+
/** Prism language id. Default 'tsx'. */
|
|
22
|
+
language?: string
|
|
23
|
+
/** Optional header filename; shown left of the language label. */
|
|
24
|
+
filename?: string
|
|
25
|
+
/** Render a left gutter with 1-based line numbers. */
|
|
26
|
+
showLineNumbers?: boolean
|
|
27
|
+
/** 1-based line numbers to emphasize. */
|
|
28
|
+
highlightLines?: number[]
|
|
29
|
+
/** Soft-wrap long lines instead of scrolling horizontally. */
|
|
30
|
+
wrap?: boolean
|
|
31
|
+
/** Max height before vertical scroll, e.g. '24rem'. */
|
|
32
|
+
maxHeight?: string
|
|
33
|
+
/** Show the copy button (default true). */
|
|
34
|
+
copyable?: boolean
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Token-driven Prism theme. Every color is a live CSS variable, so the panel
|
|
38
|
+
// follows the active mode and brand with zero JS — keywords ride the brand, the
|
|
39
|
+
// status ramp carries strings/numbers/props, neutrals carry text/comments.
|
|
40
|
+
const prismTheme: PrismTheme = {
|
|
41
|
+
plain: { color: 'var(--wf-color-text-primary)', backgroundColor: 'transparent' },
|
|
42
|
+
styles: [
|
|
43
|
+
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--wf-color-text-tertiary)', fontStyle: 'italic' } },
|
|
44
|
+
{ types: ['punctuation'], style: { color: 'var(--wf-color-text-secondary)' } },
|
|
45
|
+
{ types: ['tag', 'keyword', 'selector', 'operator', 'builtin'], style: { color: 'var(--wf-color-brand-primary)' } },
|
|
46
|
+
{ types: ['function', 'class-name', 'function-variable'], style: { color: 'var(--wf-color-status-info-default)' } },
|
|
47
|
+
{ types: ['attr-name', 'property', 'variable', 'entity'], style: { color: 'var(--wf-color-status-error-default)' } },
|
|
48
|
+
{ types: ['string', 'char', 'attr-value', 'inserted', 'regex'], style: { color: 'var(--wf-color-status-success-default)' } },
|
|
49
|
+
{ types: ['number', 'boolean', 'constant', 'symbol'], style: { color: 'var(--wf-color-status-warning-default)' } },
|
|
50
|
+
],
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function CodeBlock({
|
|
54
|
+
code, language = 'tsx', filename, showLineNumbers, highlightLines, wrap, maxHeight, copyable = true,
|
|
55
|
+
}: CodeBlockProps) {
|
|
56
|
+
const [copied, setCopied] = React.useState(false)
|
|
57
|
+
const text = code.replace(/\n+$/, '')
|
|
58
|
+
const highlightSet = React.useMemo(() => new Set(highlightLines ?? []), [highlightLines])
|
|
59
|
+
|
|
60
|
+
const copy = () => {
|
|
61
|
+
void navigator.clipboard?.writeText(text)
|
|
62
|
+
setCopied(true)
|
|
63
|
+
window.setTimeout(() => setCopied(false), 1400)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const copyButton = (
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
onClick={copy}
|
|
70
|
+
aria-label={copied ? 'Copied' : 'Copy code'}
|
|
71
|
+
className={cn(
|
|
72
|
+
'inline-flex size-8 items-center justify-center rounded-md text-secondary opacity-70 transition-opacity',
|
|
73
|
+
'hover:bg-surface-subtle hover:opacity-100 [&_svg]:size-4',
|
|
74
|
+
focusRingInset,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
{copied ? <Check className="text-success" /> : <Copy />}
|
|
78
|
+
</button>
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="relative overflow-hidden rounded-xl border border-border bg-surface-inset text-sm">
|
|
83
|
+
{filename != null && (
|
|
84
|
+
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
|
|
85
|
+
<span className="truncate font-mono text-sm text-secondary">{filename}</span>
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
<span className="select-none text-sm uppercase tracking-wide text-tertiary">{language}</span>
|
|
88
|
+
{copyable && copyButton}
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
|
|
93
|
+
{filename == null && copyable && <div className="absolute right-2 top-2 z-10">{copyButton}</div>}
|
|
94
|
+
|
|
95
|
+
<Highlight code={text} language={language} theme={prismTheme}>
|
|
96
|
+
{({ style, tokens, getLineProps, getTokenProps }) => (
|
|
97
|
+
<pre
|
|
98
|
+
className={cn('m-0 overflow-auto py-3 font-mono text-sm leading-relaxed', filename == null && copyable && 'pr-12')}
|
|
99
|
+
style={{ ...style, background: 'transparent', maxHeight }}
|
|
100
|
+
>
|
|
101
|
+
{tokens.map((line, i) => {
|
|
102
|
+
const lineProps = getLineProps({ line })
|
|
103
|
+
const isHot = highlightSet.has(i + 1)
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
key={i}
|
|
107
|
+
{...lineProps}
|
|
108
|
+
className={cn(lineProps.className, 'flex px-4', wrap && 'whitespace-pre-wrap', isHot && 'bg-brand-subtle')}
|
|
109
|
+
>
|
|
110
|
+
{showLineNumbers && (
|
|
111
|
+
<span className="mr-4 inline-block w-6 shrink-0 select-none text-right text-tertiary">{i + 1}</span>
|
|
112
|
+
)}
|
|
113
|
+
<span className={cn('flex-1', wrap && 'break-words')}>
|
|
114
|
+
{line.map((token, key) => (
|
|
115
|
+
<span key={key} {...getTokenProps({ token })} />
|
|
116
|
+
))}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
})}
|
|
121
|
+
</pre>
|
|
122
|
+
)}
|
|
123
|
+
</Highlight>
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
|
3
|
+
import { Command as CommandPrimitive } from 'cmdk'
|
|
4
|
+
import { Search } from 'lucide-react'
|
|
5
|
+
import { cn } from '../lib/utils'
|
|
6
|
+
import type { NoStyle } from '../lib/types'
|
|
7
|
+
import { menuItem, overlayBackdrop } from '../lib/recipes'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Command — a fast, filterable command menu / palette (cmdk under the hood),
|
|
11
|
+
* token-styled to match the system. Compose `Command` > `CommandInput` +
|
|
12
|
+
* `CommandList` (`CommandEmpty`/`CommandGroup`/`CommandItem`/`CommandSeparator`),
|
|
13
|
+
* or drop the whole thing into `CommandDialog` for a ⌘K palette.
|
|
14
|
+
*
|
|
15
|
+
* <CommandDialog open={open} onOpenChange={setOpen}>
|
|
16
|
+
* <CommandInput placeholder="Type a command…" />
|
|
17
|
+
* <CommandList>
|
|
18
|
+
* <CommandEmpty>No results.</CommandEmpty>
|
|
19
|
+
* <CommandGroup heading="Actions">
|
|
20
|
+
* <CommandItem onSelect={…}>New file</CommandItem>
|
|
21
|
+
* </CommandGroup>
|
|
22
|
+
* </CommandList>
|
|
23
|
+
* </CommandDialog>
|
|
24
|
+
*/
|
|
25
|
+
export const Command = React.forwardRef<
|
|
26
|
+
React.ElementRef<typeof CommandPrimitive>,
|
|
27
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive>>
|
|
28
|
+
>(({ ...props }, ref) => (
|
|
29
|
+
<CommandPrimitive
|
|
30
|
+
ref={ref}
|
|
31
|
+
className={cn('flex h-full w-full flex-col overflow-hidden rounded-xl bg-surface text-primary')}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
))
|
|
35
|
+
Command.displayName = 'Command'
|
|
36
|
+
|
|
37
|
+
export const CommandInput = React.forwardRef<
|
|
38
|
+
React.ElementRef<typeof CommandPrimitive.Input>,
|
|
39
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>>
|
|
40
|
+
>(({ ...props }, ref) => (
|
|
41
|
+
<div className="flex items-center gap-2 border-b border-border px-3">
|
|
42
|
+
<Search className="size-4 shrink-0 text-tertiary" />
|
|
43
|
+
<CommandPrimitive.Input
|
|
44
|
+
ref={ref}
|
|
45
|
+
className={cn(
|
|
46
|
+
'flex h-11 w-full bg-transparent py-3 text-sm text-primary outline-none placeholder:text-tertiary',
|
|
47
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
48
|
+
)}
|
|
49
|
+
{...props}
|
|
50
|
+
/>
|
|
51
|
+
</div>
|
|
52
|
+
))
|
|
53
|
+
CommandInput.displayName = 'CommandInput'
|
|
54
|
+
|
|
55
|
+
export const CommandList = React.forwardRef<
|
|
56
|
+
React.ElementRef<typeof CommandPrimitive.List>,
|
|
57
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>>
|
|
58
|
+
>(({ ...props }, ref) => (
|
|
59
|
+
<CommandPrimitive.List ref={ref} className={cn('max-h-80 overflow-y-auto overflow-x-hidden p-1')} {...props} />
|
|
60
|
+
))
|
|
61
|
+
CommandList.displayName = 'CommandList'
|
|
62
|
+
|
|
63
|
+
export const CommandEmpty = React.forwardRef<
|
|
64
|
+
React.ElementRef<typeof CommandPrimitive.Empty>,
|
|
65
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>>
|
|
66
|
+
>(({ ...props }, ref) => (
|
|
67
|
+
<CommandPrimitive.Empty ref={ref} className={cn('py-6 text-center text-sm text-tertiary')} {...props} />
|
|
68
|
+
))
|
|
69
|
+
CommandEmpty.displayName = 'CommandEmpty'
|
|
70
|
+
|
|
71
|
+
export const CommandGroup = React.forwardRef<
|
|
72
|
+
React.ElementRef<typeof CommandPrimitive.Group>,
|
|
73
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>>
|
|
74
|
+
>(({ ...props }, ref) => (
|
|
75
|
+
<CommandPrimitive.Group
|
|
76
|
+
ref={ref}
|
|
77
|
+
className={cn(
|
|
78
|
+
'overflow-hidden p-1 text-primary',
|
|
79
|
+
'[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-sm [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:text-tertiary',
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
))
|
|
84
|
+
CommandGroup.displayName = 'CommandGroup'
|
|
85
|
+
|
|
86
|
+
export const CommandSeparator = React.forwardRef<
|
|
87
|
+
React.ElementRef<typeof CommandPrimitive.Separator>,
|
|
88
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>>
|
|
89
|
+
>(({ ...props }, ref) => (
|
|
90
|
+
<CommandPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border')} {...props} />
|
|
91
|
+
))
|
|
92
|
+
CommandSeparator.displayName = 'CommandSeparator'
|
|
93
|
+
|
|
94
|
+
export const CommandItem = React.forwardRef<
|
|
95
|
+
React.ElementRef<typeof CommandPrimitive.Item>,
|
|
96
|
+
NoStyle<React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>>
|
|
97
|
+
>(({ ...props }, ref) => (
|
|
98
|
+
<CommandPrimitive.Item
|
|
99
|
+
ref={ref}
|
|
100
|
+
className={cn(
|
|
101
|
+
menuItem,
|
|
102
|
+
'gap-2 px-2 py-2 text-primary [&_svg]:size-4 [&_svg]:text-secondary',
|
|
103
|
+
'data-[selected=true]:bg-surface-inset data-[selected=true]:text-primary',
|
|
104
|
+
)}
|
|
105
|
+
{...props}
|
|
106
|
+
/>
|
|
107
|
+
))
|
|
108
|
+
CommandItem.displayName = 'CommandItem'
|
|
109
|
+
|
|
110
|
+
export function CommandShortcut({ ...props }: NoStyle<React.HTMLAttributes<HTMLSpanElement>>) {
|
|
111
|
+
return <span className={cn('ml-auto text-sm tracking-widest text-tertiary')} {...props} />
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface CommandDialogProps {
|
|
115
|
+
open?: boolean
|
|
116
|
+
onOpenChange?: (open: boolean) => void
|
|
117
|
+
children: React.ReactNode
|
|
118
|
+
/** Accessible label for the palette dialog. */
|
|
119
|
+
label?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function CommandDialog({ open, onOpenChange, children, label = 'Command palette' }: CommandDialogProps) {
|
|
123
|
+
return (
|
|
124
|
+
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
|
125
|
+
<DialogPrimitive.Portal>
|
|
126
|
+
<DialogPrimitive.Overlay className={cn(overlayBackdrop)} />
|
|
127
|
+
<DialogPrimitive.Content
|
|
128
|
+
className={cn(
|
|
129
|
+
'fixed left-1/2 top-[15%] z-50 w-full max-w-2xl -translate-x-1/2 overflow-hidden',
|
|
130
|
+
'rounded-2xl border border-border bg-surface shadow-xl animate-scale-in',
|
|
131
|
+
)}
|
|
132
|
+
>
|
|
133
|
+
<DialogPrimitive.Title className="sr-only">{label}</DialogPrimitive.Title>
|
|
134
|
+
<Command>{children}</Command>
|
|
135
|
+
</DialogPrimitive.Content>
|
|
136
|
+
</DialogPrimitive.Portal>
|
|
137
|
+
</DialogPrimitive.Root>
|
|
138
|
+
)
|
|
139
|
+
}
|