@stampui/blocks 1.0.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/components/ai-chat-shell.d.ts +1 -0
- package/dist/components/ai-chat-shell.js +23 -0
- package/dist/components/prompt-input.d.ts +5 -0
- package/dist/components/prompt-input.js +47 -0
- package/dist/components/registry-card.d.ts +6 -0
- package/dist/components/registry-card.js +15 -0
- package/dist/components/registry-explorer.d.ts +8 -0
- package/dist/components/registry-explorer.js +38 -0
- package/dist/components/token-stream.d.ts +7 -0
- package/dist/components/token-stream.js +21 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +23 -0
- package/dist/manifests.d.ts +3 -0
- package/dist/manifests.js +1666 -0
- package/dist/types.d.ts +44 -0
- package/dist/types.js +2 -0
- package/package.json +28 -0
- package/src/components/blocks/ai-chat-shell.tsx +97 -0
- package/src/components/blocks/auth-panel.tsx +203 -0
- package/src/components/blocks/feature-grid.tsx +122 -0
- package/src/components/blocks/hero-section.tsx +73 -0
- package/src/components/blocks/notification-center.tsx +185 -0
- package/src/components/blocks/onboarding-flow.tsx +230 -0
- package/src/components/blocks/pricing-section.tsx +135 -0
- package/src/components/blocks/project-command-center.tsx +188 -0
- package/src/components/blocks/prompt-input.tsx +81 -0
- package/src/components/blocks/registry-card.tsx +104 -0
- package/src/components/blocks/registry-explorer.tsx +78 -0
- package/src/components/blocks/settings-layout.tsx +178 -0
- package/src/components/blocks/stats-strip.tsx +100 -0
- package/src/components/blocks/token-stream.tsx +42 -0
- package/src/components/blocks/usage-card.tsx +116 -0
- package/src/components/core/accordion.tsx +58 -0
- package/src/components/core/alert-dialog.tsx +113 -0
- package/src/components/core/alert.tsx +48 -0
- package/src/components/core/animated-number.tsx +77 -0
- package/src/components/core/aspect-ratio.tsx +20 -0
- package/src/components/core/avatar-stack.tsx +61 -0
- package/src/components/core/avatar.tsx +90 -0
- package/src/components/core/badge.tsx +39 -0
- package/src/components/core/breadcrumb.tsx +63 -0
- package/src/components/core/button-group.tsx +37 -0
- package/src/components/core/button.tsx +110 -0
- package/src/components/core/calendar.tsx +143 -0
- package/src/components/core/card.tsx +60 -0
- package/src/components/core/carousel.tsx +170 -0
- package/src/components/core/chart.tsx +377 -0
- package/src/components/core/checkbox.tsx +64 -0
- package/src/components/core/collapsible.tsx +30 -0
- package/src/components/core/combobox.tsx +114 -0
- package/src/components/core/command-box.tsx +22 -0
- package/src/components/core/command.tsx +165 -0
- package/src/components/core/confirm-action.tsx +94 -0
- package/src/components/core/context-menu.tsx +139 -0
- package/src/components/core/copy-button.tsx +41 -0
- package/src/components/core/data-table.tsx +173 -0
- package/src/components/core/date-picker.tsx +73 -0
- package/src/components/core/dialog.tsx +83 -0
- package/src/components/core/drawer.tsx +87 -0
- package/src/components/core/dropdown-menu.tsx +147 -0
- package/src/components/core/empty.tsx +34 -0
- package/src/components/core/field.tsx +39 -0
- package/src/components/core/file-upload.tsx +143 -0
- package/src/components/core/hover-card.tsx +31 -0
- package/src/components/core/inline-edit.tsx +104 -0
- package/src/components/core/input-group.tsx +47 -0
- package/src/components/core/input-otp.tsx +108 -0
- package/src/components/core/input.tsx +37 -0
- package/src/components/core/kbd.tsx +47 -0
- package/src/components/core/label.tsx +28 -0
- package/src/components/core/marquee.tsx +61 -0
- package/src/components/core/menubar.tsx +120 -0
- package/src/components/core/multi-select.tsx +145 -0
- package/src/components/core/native-select.tsx +27 -0
- package/src/components/core/navigation-menu.tsx +130 -0
- package/src/components/core/number-stepper.tsx +80 -0
- package/src/components/core/pagination.tsx +80 -0
- package/src/components/core/password-input.tsx +90 -0
- package/src/components/core/popover.tsx +34 -0
- package/src/components/core/progress.tsx +63 -0
- package/src/components/core/radio-group.tsx +77 -0
- package/src/components/core/resizable.tsx +250 -0
- package/src/components/core/scroll-area.tsx +38 -0
- package/src/components/core/select.tsx +128 -0
- package/src/components/core/separator.tsx +47 -0
- package/src/components/core/sheet.tsx +118 -0
- package/src/components/core/sidebar.tsx +129 -0
- package/src/components/core/skeleton.tsx +32 -0
- package/src/components/core/slider.tsx +97 -0
- package/src/components/core/sonner.tsx +29 -0
- package/src/components/core/spinner.tsx +60 -0
- package/src/components/core/status-pulse.tsx +67 -0
- package/src/components/core/stepper.tsx +111 -0
- package/src/components/core/switch.tsx +72 -0
- package/src/components/core/table.tsx +104 -0
- package/src/components/core/tabs.tsx +55 -0
- package/src/components/core/tag-input.tsx +93 -0
- package/src/components/core/textarea.tsx +44 -0
- package/src/components/core/timeline.tsx +81 -0
- package/src/components/core/toggle-group.tsx +56 -0
- package/src/components/core/toggle.tsx +66 -0
- package/src/components/core/tooltip.tsx +31 -0
- package/src/components/core/typing-indicator.tsx +51 -0
- package/src/index.ts +8 -0
- package/src/manifests.ts +1682 -0
- package/src/types.ts +58 -0
- package/src/ui.ts +13 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { cx } from "@/lib/cx"
|
|
5
|
+
|
|
6
|
+
// ── Context ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
interface MenubarCtx { open: string | null; setOpen: (id: string | null) => void }
|
|
9
|
+
const MenubarContext = React.createContext<MenubarCtx>({ open: null, setOpen: () => {} })
|
|
10
|
+
|
|
11
|
+
// ── Root ──────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface MenubarProps extends React.HTMLAttributes<HTMLDivElement> {}
|
|
14
|
+
|
|
15
|
+
export function Menubar({ className, children, ...props }: MenubarProps) {
|
|
16
|
+
const [open, setOpen] = React.useState<string | null>(null)
|
|
17
|
+
|
|
18
|
+
React.useEffect(() => {
|
|
19
|
+
if (!open) return
|
|
20
|
+
const close = () => setOpen(null)
|
|
21
|
+
document.addEventListener("click", close)
|
|
22
|
+
return () => document.removeEventListener("click", close)
|
|
23
|
+
}, [open])
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<MenubarContext.Provider value={{ open, setOpen }}>
|
|
27
|
+
<div
|
|
28
|
+
role="menubar"
|
|
29
|
+
className={cx("flex items-center gap-1 rounded-lg border border-border bg-background p-1", className)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</div>
|
|
34
|
+
</MenubarContext.Provider>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Menu ──────────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface MenubarMenuProps { id: string; trigger: React.ReactNode; children: React.ReactNode }
|
|
41
|
+
|
|
42
|
+
export function MenubarMenu({ id, trigger, children }: MenubarMenuProps) {
|
|
43
|
+
const { open, setOpen } = React.useContext(MenubarContext)
|
|
44
|
+
const isOpen = open === id
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="relative">
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
role="menuitem"
|
|
51
|
+
aria-haspopup="menu"
|
|
52
|
+
aria-expanded={isOpen}
|
|
53
|
+
onClick={(e) => { e.stopPropagation(); setOpen(isOpen ? null : id) }}
|
|
54
|
+
className={cx(
|
|
55
|
+
"flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors select-none",
|
|
56
|
+
isOpen
|
|
57
|
+
? "bg-surface-2 text-foreground"
|
|
58
|
+
: "text-muted-foreground hover:bg-surface-2 hover:text-foreground"
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{trigger}
|
|
62
|
+
</button>
|
|
63
|
+
|
|
64
|
+
{isOpen && (
|
|
65
|
+
<div
|
|
66
|
+
role="menu"
|
|
67
|
+
onClick={(e) => e.stopPropagation()}
|
|
68
|
+
className="absolute left-0 top-full mt-1 z-50 min-w-40 rounded-lg border border-border bg-background p-1 shadow-lg"
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Item ──────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
export interface MenubarItemProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
80
|
+
inset?: boolean
|
|
81
|
+
shortcut?: string
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function MenubarItem({ className, children, inset, shortcut, ...props }: MenubarItemProps) {
|
|
85
|
+
const { setOpen } = React.useContext(MenubarContext)
|
|
86
|
+
return (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
role="menuitem"
|
|
90
|
+
onClick={(e) => { props.onClick?.(e); setOpen(null) }}
|
|
91
|
+
className={cx(
|
|
92
|
+
"flex w-full items-center justify-between rounded-md px-2 py-1.5 text-sm text-foreground",
|
|
93
|
+
"transition-colors hover:bg-surface-2 focus-visible:bg-surface-2 outline-none",
|
|
94
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
95
|
+
inset && "pl-8",
|
|
96
|
+
className
|
|
97
|
+
)}
|
|
98
|
+
{...props}
|
|
99
|
+
>
|
|
100
|
+
<span>{children}</span>
|
|
101
|
+
{shortcut && <span className="text-xs text-muted-foreground font-mono">{shortcut}</span>}
|
|
102
|
+
</button>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Separator ─────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
export function MenubarSeparator({ className }: { className?: string }) {
|
|
109
|
+
return <div role="separator" className={cx("my-1 h-px bg-border", className)} />
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Label ─────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export function MenubarLabel({ className, children }: React.HTMLAttributes<HTMLDivElement>) {
|
|
115
|
+
return (
|
|
116
|
+
<div className={cx("px-2 py-1 text-xs font-semibold text-muted-foreground", className)}>
|
|
117
|
+
{children}
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as RadixPopover from "@radix-ui/react-popover"
|
|
5
|
+
import { Check, ChevronDown, X } from "lucide-react"
|
|
6
|
+
import { cx } from "@/lib/cx"
|
|
7
|
+
|
|
8
|
+
export interface MultiSelectOption {
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
disabled?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface MultiSelectProps {
|
|
15
|
+
options: MultiSelectOption[]
|
|
16
|
+
value?: string[]
|
|
17
|
+
onValueChange?: (value: string[]) => void
|
|
18
|
+
placeholder?: string
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
className?: string
|
|
21
|
+
maxDisplay?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function MultiSelect({
|
|
25
|
+
options,
|
|
26
|
+
value = [],
|
|
27
|
+
onValueChange,
|
|
28
|
+
placeholder = "Select options...",
|
|
29
|
+
disabled,
|
|
30
|
+
className,
|
|
31
|
+
maxDisplay = 3,
|
|
32
|
+
}: MultiSelectProps) {
|
|
33
|
+
const [open, setOpen] = React.useState(false)
|
|
34
|
+
const listId = React.useId()
|
|
35
|
+
|
|
36
|
+
function toggle(optionValue: string) {
|
|
37
|
+
if (!onValueChange) return
|
|
38
|
+
if (value.includes(optionValue)) {
|
|
39
|
+
onValueChange(value.filter((v) => v !== optionValue))
|
|
40
|
+
} else {
|
|
41
|
+
onValueChange([...value, optionValue])
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function remove(optionValue: string, e: React.MouseEvent) {
|
|
46
|
+
e.stopPropagation()
|
|
47
|
+
onValueChange?.(value.filter((v) => v !== optionValue))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const selectedLabels = value
|
|
51
|
+
.map((v) => options.find((o) => o.value === v)?.label)
|
|
52
|
+
.filter(Boolean) as string[]
|
|
53
|
+
|
|
54
|
+
const visibleLabels = selectedLabels.slice(0, maxDisplay)
|
|
55
|
+
const overflow = selectedLabels.length - maxDisplay
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<RadixPopover.Root open={open} onOpenChange={setOpen}>
|
|
59
|
+
<RadixPopover.Trigger asChild>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
aria-expanded={open}
|
|
64
|
+
aria-haspopup="listbox"
|
|
65
|
+
aria-controls={listId}
|
|
66
|
+
aria-label={selectedLabels.length ? selectedLabels.join(", ") : placeholder}
|
|
67
|
+
className={cx(
|
|
68
|
+
"flex min-h-9 w-full items-center justify-between gap-2 rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm outline-none",
|
|
69
|
+
"hover:border-border-strong transition-colors text-left",
|
|
70
|
+
"focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
71
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
72
|
+
className
|
|
73
|
+
)}
|
|
74
|
+
>
|
|
75
|
+
<div className="flex flex-1 flex-wrap gap-1">
|
|
76
|
+
{selectedLabels.length === 0 ? (
|
|
77
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
78
|
+
) : (
|
|
79
|
+
<>
|
|
80
|
+
{visibleLabels.map((label, i) => (
|
|
81
|
+
<span
|
|
82
|
+
key={value[i]}
|
|
83
|
+
className="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2 py-0.5 text-xs font-medium"
|
|
84
|
+
>
|
|
85
|
+
{label}
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
aria-label={`Remove ${label}`}
|
|
89
|
+
onClick={(e) => remove(value[i], e)}
|
|
90
|
+
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
91
|
+
>
|
|
92
|
+
<X className="h-3 w-3" />
|
|
93
|
+
</button>
|
|
94
|
+
</span>
|
|
95
|
+
))}
|
|
96
|
+
{overflow > 0 && (
|
|
97
|
+
<span className="inline-flex items-center rounded-md bg-surface-3 px-2 py-0.5 text-xs text-muted-foreground">
|
|
98
|
+
+{overflow} more
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</>
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
<ChevronDown className={cx("h-4 w-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-180")} />
|
|
105
|
+
</button>
|
|
106
|
+
</RadixPopover.Trigger>
|
|
107
|
+
|
|
108
|
+
<RadixPopover.Portal>
|
|
109
|
+
<RadixPopover.Content
|
|
110
|
+
id={listId}
|
|
111
|
+
role="listbox"
|
|
112
|
+
aria-multiselectable="true"
|
|
113
|
+
align="start"
|
|
114
|
+
sideOffset={4}
|
|
115
|
+
className="z-50 w-[var(--radix-popover-trigger-width)] max-h-60 overflow-auto rounded-xl border border-border bg-card p-1.5 shadow-lg outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
|
|
116
|
+
>
|
|
117
|
+
{options.map((option) => {
|
|
118
|
+
const selected = value.includes(option.value)
|
|
119
|
+
return (
|
|
120
|
+
<button
|
|
121
|
+
key={option.value}
|
|
122
|
+
type="button"
|
|
123
|
+
role="option"
|
|
124
|
+
aria-selected={selected}
|
|
125
|
+
disabled={option.disabled}
|
|
126
|
+
onClick={() => toggle(option.value)}
|
|
127
|
+
className={cx(
|
|
128
|
+
"relative flex w-full cursor-default select-none items-center gap-2 rounded-lg px-2.5 py-1.5 text-sm outline-none transition-colors",
|
|
129
|
+
"hover:bg-surface-2 focus-visible:bg-surface-2",
|
|
130
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
131
|
+
selected && "font-medium text-foreground"
|
|
132
|
+
)}
|
|
133
|
+
>
|
|
134
|
+
<span className="flex h-4 w-4 items-center justify-center rounded border border-border shrink-0">
|
|
135
|
+
{selected && <Check className="h-3 w-3" />}
|
|
136
|
+
</span>
|
|
137
|
+
{option.label}
|
|
138
|
+
</button>
|
|
139
|
+
)
|
|
140
|
+
})}
|
|
141
|
+
</RadixPopover.Content>
|
|
142
|
+
</RadixPopover.Portal>
|
|
143
|
+
</RadixPopover.Root>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ChevronDown } from "lucide-react"
|
|
3
|
+
import { cx } from "@/lib/cx"
|
|
4
|
+
|
|
5
|
+
export interface NativeSelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
|
6
|
+
|
|
7
|
+
export const NativeSelect = React.forwardRef<HTMLSelectElement, NativeSelectProps>(
|
|
8
|
+
({ className, children, ...props }, ref) => (
|
|
9
|
+
<div className="relative inline-flex w-full">
|
|
10
|
+
<select
|
|
11
|
+
ref={ref}
|
|
12
|
+
className={cx(
|
|
13
|
+
"h-9 w-full appearance-none rounded-lg border border-border bg-surface-2 px-3 pr-8 py-2 text-sm text-foreground outline-none",
|
|
14
|
+
"hover:border-border-strong transition-colors",
|
|
15
|
+
"focus-visible:ring-1 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
16
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
17
|
+
className
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{children}
|
|
22
|
+
</select>
|
|
23
|
+
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
|
24
|
+
</div>
|
|
25
|
+
)
|
|
26
|
+
)
|
|
27
|
+
NativeSelect.displayName = "NativeSelect"
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { ChevronDown } from "lucide-react"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
// ── Context ───────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface NavMenuCtx { open: string | null; setOpen: (id: string | null) => void }
|
|
10
|
+
const NavMenuContext = React.createContext<NavMenuCtx>({ open: null, setOpen: () => {} })
|
|
11
|
+
|
|
12
|
+
// ── Root ──────────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export interface NavigationMenuProps extends React.HTMLAttributes<HTMLElement> {}
|
|
15
|
+
|
|
16
|
+
export function NavigationMenu({ className, children, ...props }: NavigationMenuProps) {
|
|
17
|
+
const [open, setOpen] = React.useState<string | null>(null)
|
|
18
|
+
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
if (!open) return
|
|
21
|
+
const close = () => setOpen(null)
|
|
22
|
+
document.addEventListener("click", close)
|
|
23
|
+
return () => document.removeEventListener("click", close)
|
|
24
|
+
}, [open])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<NavMenuContext.Provider value={{ open, setOpen }}>
|
|
28
|
+
<nav
|
|
29
|
+
aria-label="Navigation"
|
|
30
|
+
className={cx("relative flex items-center gap-1", className)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</nav>
|
|
35
|
+
</NavMenuContext.Provider>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Item (link only) ──────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
export interface NavigationMenuLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
42
|
+
active?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function NavigationMenuLink({ className, active, children, ...props }: NavigationMenuLinkProps) {
|
|
46
|
+
return (
|
|
47
|
+
<a
|
|
48
|
+
data-active={active || undefined}
|
|
49
|
+
className={cx(
|
|
50
|
+
"inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors",
|
|
51
|
+
active ? "text-foreground bg-surface-2" : "text-muted-foreground hover:text-foreground hover:bg-surface-2",
|
|
52
|
+
className
|
|
53
|
+
)}
|
|
54
|
+
{...props}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</a>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Item with dropdown ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface NavigationMenuItemProps {
|
|
64
|
+
id: string
|
|
65
|
+
trigger: React.ReactNode
|
|
66
|
+
children: React.ReactNode
|
|
67
|
+
className?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function NavigationMenuItem({ id, trigger, children, className }: NavigationMenuItemProps) {
|
|
71
|
+
const { open, setOpen } = React.useContext(NavMenuContext)
|
|
72
|
+
const isOpen = open === id
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div className={cx("relative", className)}>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
aria-expanded={isOpen}
|
|
79
|
+
aria-haspopup="true"
|
|
80
|
+
onClick={(e) => { e.stopPropagation(); setOpen(isOpen ? null : id) }}
|
|
81
|
+
className={cx(
|
|
82
|
+
"inline-flex items-center gap-1 px-3 py-2 text-sm font-medium rounded-md transition-colors select-none",
|
|
83
|
+
isOpen ? "text-foreground bg-surface-2" : "text-muted-foreground hover:text-foreground hover:bg-surface-2"
|
|
84
|
+
)}
|
|
85
|
+
>
|
|
86
|
+
{trigger}
|
|
87
|
+
<ChevronDown className={cx("h-3.5 w-3.5 transition-transform duration-200", isOpen && "rotate-180")} />
|
|
88
|
+
</button>
|
|
89
|
+
|
|
90
|
+
{isOpen && (
|
|
91
|
+
<div
|
|
92
|
+
onClick={(e) => e.stopPropagation()}
|
|
93
|
+
className="absolute left-0 top-full mt-1.5 z-50 min-w-48 rounded-xl border border-border bg-background p-2 shadow-lg"
|
|
94
|
+
>
|
|
95
|
+
{children}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Dropdown content items ────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
export interface NavigationMenuContentItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
105
|
+
title: string
|
|
106
|
+
description?: string
|
|
107
|
+
icon?: React.ReactNode
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function NavigationMenuContentItem({ title, description, icon, className, ...props }: NavigationMenuContentItemProps) {
|
|
111
|
+
return (
|
|
112
|
+
<a
|
|
113
|
+
className={cx(
|
|
114
|
+
"flex gap-3 rounded-lg p-2.5 transition-colors hover:bg-surface-2 group cursor-pointer",
|
|
115
|
+
className
|
|
116
|
+
)}
|
|
117
|
+
{...props}
|
|
118
|
+
>
|
|
119
|
+
{icon && (
|
|
120
|
+
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-border bg-surface-2 text-muted-foreground group-hover:text-foreground transition-colors">
|
|
121
|
+
{icon}
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
<div className="flex-1 min-w-0">
|
|
125
|
+
<div className="text-sm font-medium text-foreground">{title}</div>
|
|
126
|
+
{description && <div className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{description}</div>}
|
|
127
|
+
</div>
|
|
128
|
+
</a>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Minus, Plus } from "lucide-react"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
export interface NumberStepperProps {
|
|
8
|
+
value?: number
|
|
9
|
+
defaultValue?: number
|
|
10
|
+
onChange?: (value: number) => void
|
|
11
|
+
min?: number
|
|
12
|
+
max?: number
|
|
13
|
+
step?: number
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
className?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function NumberStepper({
|
|
19
|
+
value: controlledValue,
|
|
20
|
+
defaultValue = 0,
|
|
21
|
+
onChange,
|
|
22
|
+
min,
|
|
23
|
+
max,
|
|
24
|
+
step = 1,
|
|
25
|
+
disabled = false,
|
|
26
|
+
className,
|
|
27
|
+
}: NumberStepperProps) {
|
|
28
|
+
const [internal, setInternal] = React.useState(defaultValue)
|
|
29
|
+
const controlled = controlledValue !== undefined
|
|
30
|
+
const value = controlled ? controlledValue : internal
|
|
31
|
+
|
|
32
|
+
const update = (next: number) => {
|
|
33
|
+
if (min !== undefined && next < min) return
|
|
34
|
+
if (max !== undefined && next > max) return
|
|
35
|
+
if (!controlled) setInternal(next)
|
|
36
|
+
onChange?.(next)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const decrement = () => update(value - step)
|
|
40
|
+
const increment = () => update(value + step)
|
|
41
|
+
|
|
42
|
+
const atMin = min !== undefined && value <= min
|
|
43
|
+
const atMax = max !== undefined && value >= max
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cx("inline-flex items-center rounded-lg border border-border bg-input overflow-hidden", className)}>
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
onClick={decrement}
|
|
50
|
+
disabled={disabled || atMin}
|
|
51
|
+
aria-label="Decrease"
|
|
52
|
+
className={cx(
|
|
53
|
+
"flex h-9 w-9 items-center justify-center text-muted-foreground transition-colors",
|
|
54
|
+
"hover:bg-surface-2 hover:text-foreground",
|
|
55
|
+
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<Minus className="h-3.5 w-3.5" />
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
<span className="min-w-12 select-none px-2 text-center text-sm font-medium tabular-nums text-foreground">
|
|
62
|
+
{value}
|
|
63
|
+
</span>
|
|
64
|
+
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={increment}
|
|
68
|
+
disabled={disabled || atMax}
|
|
69
|
+
aria-label="Increase"
|
|
70
|
+
className={cx(
|
|
71
|
+
"flex h-9 w-9 items-center justify-center text-muted-foreground transition-colors",
|
|
72
|
+
"hover:bg-surface-2 hover:text-foreground",
|
|
73
|
+
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
<Plus className="h-3.5 w-3.5" />
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
|
3
|
+
import { cx } from "@/lib/cx"
|
|
4
|
+
|
|
5
|
+
const itemClass = cx(
|
|
6
|
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors",
|
|
7
|
+
"h-9 min-w-9 px-2 border border-border",
|
|
8
|
+
"text-muted-foreground hover:bg-surface-2 hover:text-foreground",
|
|
9
|
+
"disabled:pointer-events-none disabled:opacity-40",
|
|
10
|
+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-strong"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
const activeClass = "bg-foreground text-background border-foreground hover:bg-foreground hover:text-background"
|
|
14
|
+
|
|
15
|
+
export interface PaginationProps {
|
|
16
|
+
page: number
|
|
17
|
+
totalPages: number
|
|
18
|
+
onPageChange?: (page: number) => void
|
|
19
|
+
siblingCount?: number
|
|
20
|
+
className?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getPages(page: number, total: number, sibling: number): (number | "…")[] {
|
|
24
|
+
if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1)
|
|
25
|
+
const left = Math.max(2, page - sibling)
|
|
26
|
+
const right = Math.min(total - 1, page + sibling)
|
|
27
|
+
const pages: (number | "…")[] = [1]
|
|
28
|
+
if (left > 2) pages.push("…")
|
|
29
|
+
for (let i = left; i <= right; i++) pages.push(i)
|
|
30
|
+
if (right < total - 1) pages.push("…")
|
|
31
|
+
pages.push(total)
|
|
32
|
+
return pages
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function Pagination({ page, totalPages, onPageChange, siblingCount = 1, className }: PaginationProps) {
|
|
36
|
+
const pages = getPages(page, totalPages, siblingCount)
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<nav role="navigation" aria-label="Pagination" className={cx("flex items-center gap-1", className)}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={() => onPageChange?.(page - 1)}
|
|
43
|
+
disabled={page <= 1}
|
|
44
|
+
aria-label="Previous page"
|
|
45
|
+
className={itemClass}
|
|
46
|
+
>
|
|
47
|
+
<ChevronLeft className="h-4 w-4" />
|
|
48
|
+
</button>
|
|
49
|
+
|
|
50
|
+
{pages.map((p, i) =>
|
|
51
|
+
p === "…" ? (
|
|
52
|
+
<span key={`ellipsis-${i}`} className={cx(itemClass, "cursor-default border-transparent")}>
|
|
53
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
54
|
+
</span>
|
|
55
|
+
) : (
|
|
56
|
+
<button
|
|
57
|
+
key={p}
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => onPageChange?.(p as number)}
|
|
60
|
+
aria-current={p === page ? "page" : undefined}
|
|
61
|
+
aria-label={`Page ${p}`}
|
|
62
|
+
className={cx(itemClass, p === page && activeClass)}
|
|
63
|
+
>
|
|
64
|
+
{p}
|
|
65
|
+
</button>
|
|
66
|
+
)
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => onPageChange?.(page + 1)}
|
|
72
|
+
disabled={page >= totalPages}
|
|
73
|
+
aria-label="Next page"
|
|
74
|
+
className={itemClass}
|
|
75
|
+
>
|
|
76
|
+
<ChevronRight className="h-4 w-4" />
|
|
77
|
+
</button>
|
|
78
|
+
</nav>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Eye, EyeOff } from "lucide-react"
|
|
5
|
+
import { cx } from "@/lib/cx"
|
|
6
|
+
|
|
7
|
+
function getStrength(password: string): 0 | 1 | 2 | 3 | 4 {
|
|
8
|
+
let score = 0
|
|
9
|
+
if (password.length >= 8) score++
|
|
10
|
+
if (password.length >= 12) score++
|
|
11
|
+
if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++
|
|
12
|
+
if (/\d/.test(password)) score++
|
|
13
|
+
if (/[^A-Za-z0-9]/.test(password)) score++
|
|
14
|
+
return Math.min(score, 4) as 0 | 1 | 2 | 3 | 4
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const strengthConfig = [
|
|
18
|
+
{ label: "", color: "bg-border" },
|
|
19
|
+
{ label: "Weak", color: "bg-red-500" },
|
|
20
|
+
{ label: "Fair", color: "bg-orange-400" },
|
|
21
|
+
{ label: "Good", color: "bg-yellow-400" },
|
|
22
|
+
{ label: "Strong", color: "bg-green-500" },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
export interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
26
|
+
showStrength?: boolean
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
30
|
+
({ className, showStrength = false, onChange, value, defaultValue, ...props }, ref) => {
|
|
31
|
+
const [visible, setVisible] = React.useState(false)
|
|
32
|
+
const [internalValue, setInternalValue] = React.useState((defaultValue as string) ?? "")
|
|
33
|
+
|
|
34
|
+
const controlled = value !== undefined
|
|
35
|
+
const password = controlled ? (value as string) : internalValue
|
|
36
|
+
const strength = showStrength ? getStrength(password) : 0
|
|
37
|
+
|
|
38
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
39
|
+
if (!controlled) setInternalValue(e.target.value)
|
|
40
|
+
onChange?.(e)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="w-full space-y-2">
|
|
45
|
+
<div className="relative">
|
|
46
|
+
<input
|
|
47
|
+
ref={ref}
|
|
48
|
+
type={visible ? "text" : "password"}
|
|
49
|
+
value={controlled ? value : internalValue}
|
|
50
|
+
onChange={handleChange}
|
|
51
|
+
className={cx(
|
|
52
|
+
"flex w-full rounded-lg border border-border bg-input px-3 py-2 pr-10 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-border-strong disabled:cursor-not-allowed disabled:opacity-50",
|
|
53
|
+
className
|
|
54
|
+
)}
|
|
55
|
+
{...props}
|
|
56
|
+
/>
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
onClick={() => setVisible((v) => !v)}
|
|
60
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
|
|
61
|
+
aria-label={visible ? "Hide password" : "Show password"}
|
|
62
|
+
tabIndex={-1}
|
|
63
|
+
>
|
|
64
|
+
{visible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{showStrength && password.length > 0 && (
|
|
69
|
+
<div className="space-y-1">
|
|
70
|
+
<div className="flex gap-1">
|
|
71
|
+
{[1, 2, 3, 4].map((level) => (
|
|
72
|
+
<div
|
|
73
|
+
key={level}
|
|
74
|
+
className={cx(
|
|
75
|
+
"h-1 flex-1 rounded-full transition-colors duration-300",
|
|
76
|
+
strength >= level ? strengthConfig[strength].color : "bg-border"
|
|
77
|
+
)}
|
|
78
|
+
/>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
<p className="text-[11px] text-muted-foreground">
|
|
82
|
+
{strengthConfig[strength].label}
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
PasswordInput.displayName = "PasswordInput"
|