@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.
Files changed (107) hide show
  1. package/dist/components/ai-chat-shell.d.ts +1 -0
  2. package/dist/components/ai-chat-shell.js +23 -0
  3. package/dist/components/prompt-input.d.ts +5 -0
  4. package/dist/components/prompt-input.js +47 -0
  5. package/dist/components/registry-card.d.ts +6 -0
  6. package/dist/components/registry-card.js +15 -0
  7. package/dist/components/registry-explorer.d.ts +8 -0
  8. package/dist/components/registry-explorer.js +38 -0
  9. package/dist/components/token-stream.d.ts +7 -0
  10. package/dist/components/token-stream.js +21 -0
  11. package/dist/index.d.ts +2 -0
  12. package/dist/index.js +23 -0
  13. package/dist/manifests.d.ts +3 -0
  14. package/dist/manifests.js +1666 -0
  15. package/dist/types.d.ts +44 -0
  16. package/dist/types.js +2 -0
  17. package/package.json +28 -0
  18. package/src/components/blocks/ai-chat-shell.tsx +97 -0
  19. package/src/components/blocks/auth-panel.tsx +203 -0
  20. package/src/components/blocks/feature-grid.tsx +122 -0
  21. package/src/components/blocks/hero-section.tsx +73 -0
  22. package/src/components/blocks/notification-center.tsx +185 -0
  23. package/src/components/blocks/onboarding-flow.tsx +230 -0
  24. package/src/components/blocks/pricing-section.tsx +135 -0
  25. package/src/components/blocks/project-command-center.tsx +188 -0
  26. package/src/components/blocks/prompt-input.tsx +81 -0
  27. package/src/components/blocks/registry-card.tsx +104 -0
  28. package/src/components/blocks/registry-explorer.tsx +78 -0
  29. package/src/components/blocks/settings-layout.tsx +178 -0
  30. package/src/components/blocks/stats-strip.tsx +100 -0
  31. package/src/components/blocks/token-stream.tsx +42 -0
  32. package/src/components/blocks/usage-card.tsx +116 -0
  33. package/src/components/core/accordion.tsx +58 -0
  34. package/src/components/core/alert-dialog.tsx +113 -0
  35. package/src/components/core/alert.tsx +48 -0
  36. package/src/components/core/animated-number.tsx +77 -0
  37. package/src/components/core/aspect-ratio.tsx +20 -0
  38. package/src/components/core/avatar-stack.tsx +61 -0
  39. package/src/components/core/avatar.tsx +90 -0
  40. package/src/components/core/badge.tsx +39 -0
  41. package/src/components/core/breadcrumb.tsx +63 -0
  42. package/src/components/core/button-group.tsx +37 -0
  43. package/src/components/core/button.tsx +110 -0
  44. package/src/components/core/calendar.tsx +143 -0
  45. package/src/components/core/card.tsx +60 -0
  46. package/src/components/core/carousel.tsx +170 -0
  47. package/src/components/core/chart.tsx +377 -0
  48. package/src/components/core/checkbox.tsx +64 -0
  49. package/src/components/core/collapsible.tsx +30 -0
  50. package/src/components/core/combobox.tsx +114 -0
  51. package/src/components/core/command-box.tsx +22 -0
  52. package/src/components/core/command.tsx +165 -0
  53. package/src/components/core/confirm-action.tsx +94 -0
  54. package/src/components/core/context-menu.tsx +139 -0
  55. package/src/components/core/copy-button.tsx +41 -0
  56. package/src/components/core/data-table.tsx +173 -0
  57. package/src/components/core/date-picker.tsx +73 -0
  58. package/src/components/core/dialog.tsx +83 -0
  59. package/src/components/core/drawer.tsx +87 -0
  60. package/src/components/core/dropdown-menu.tsx +147 -0
  61. package/src/components/core/empty.tsx +34 -0
  62. package/src/components/core/field.tsx +39 -0
  63. package/src/components/core/file-upload.tsx +143 -0
  64. package/src/components/core/hover-card.tsx +31 -0
  65. package/src/components/core/inline-edit.tsx +104 -0
  66. package/src/components/core/input-group.tsx +47 -0
  67. package/src/components/core/input-otp.tsx +108 -0
  68. package/src/components/core/input.tsx +37 -0
  69. package/src/components/core/kbd.tsx +47 -0
  70. package/src/components/core/label.tsx +28 -0
  71. package/src/components/core/marquee.tsx +61 -0
  72. package/src/components/core/menubar.tsx +120 -0
  73. package/src/components/core/multi-select.tsx +145 -0
  74. package/src/components/core/native-select.tsx +27 -0
  75. package/src/components/core/navigation-menu.tsx +130 -0
  76. package/src/components/core/number-stepper.tsx +80 -0
  77. package/src/components/core/pagination.tsx +80 -0
  78. package/src/components/core/password-input.tsx +90 -0
  79. package/src/components/core/popover.tsx +34 -0
  80. package/src/components/core/progress.tsx +63 -0
  81. package/src/components/core/radio-group.tsx +77 -0
  82. package/src/components/core/resizable.tsx +250 -0
  83. package/src/components/core/scroll-area.tsx +38 -0
  84. package/src/components/core/select.tsx +128 -0
  85. package/src/components/core/separator.tsx +47 -0
  86. package/src/components/core/sheet.tsx +118 -0
  87. package/src/components/core/sidebar.tsx +129 -0
  88. package/src/components/core/skeleton.tsx +32 -0
  89. package/src/components/core/slider.tsx +97 -0
  90. package/src/components/core/sonner.tsx +29 -0
  91. package/src/components/core/spinner.tsx +60 -0
  92. package/src/components/core/status-pulse.tsx +67 -0
  93. package/src/components/core/stepper.tsx +111 -0
  94. package/src/components/core/switch.tsx +72 -0
  95. package/src/components/core/table.tsx +104 -0
  96. package/src/components/core/tabs.tsx +55 -0
  97. package/src/components/core/tag-input.tsx +93 -0
  98. package/src/components/core/textarea.tsx +44 -0
  99. package/src/components/core/timeline.tsx +81 -0
  100. package/src/components/core/toggle-group.tsx +56 -0
  101. package/src/components/core/toggle.tsx +66 -0
  102. package/src/components/core/tooltip.tsx +31 -0
  103. package/src/components/core/typing-indicator.tsx +51 -0
  104. package/src/index.ts +8 -0
  105. package/src/manifests.ts +1682 -0
  106. package/src/types.ts +58 -0
  107. 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"