@viibestack/ui 0.1.0 → 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/README.md +24 -2
- package/package.json +6 -3
- package/src/components.tsx +460 -0
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
# @viibestack/ui
|
|
2
2
|
|
|
3
|
-
Dependency-free React
|
|
3
|
+
Dependency-free React UI kit -- a Feather/Lucide-style outline icon set (~28 icons, ported from `services/portal/frontend/src/Icons.tsx`) plus core components (Button, Card, Input, Textarea, Select, Badge, Modal, EmptyState, Spinner, Alert, Checkbox, Switch, Avatar, Tabs, and Table primitives). No runtime dependencies beyond `react`/`react-dom`, so it's safe to use anywhere React runs, including apps deployed on the OpenNext Cloudflare Workers adapter (no Node-native code, nothing that needs `fs`/`stream`/native bindings). Styled with plain Tailwind utility classes -- the consuming app needs Tailwind already set up (every app this is meant for already has it, see `scratch-scaffold.ts`).
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
|
-
import { CheckIcon, TrashIcon,
|
|
8
|
+
import { CheckIcon, TrashIcon, Button, Card, Input, Modal } from "@viibestack/ui";
|
|
9
9
|
|
|
10
10
|
<CheckIcon size={16} color="green" />
|
|
11
11
|
<TrashIcon className="text-red-500" />
|
|
12
|
+
|
|
13
|
+
<Button variant="primary" size="md" onClick={...}>Save</Button>
|
|
14
|
+
<Card title="Recent activity">...</Card>
|
|
15
|
+
<Input label="Email" type="email" error={errors.email} />
|
|
12
16
|
```
|
|
13
17
|
|
|
14
18
|
Every icon takes the same optional props: `size` (number, default 20), `color` (default `"currentColor"`, so it follows the surrounding text color / a Tailwind `text-*` class unless overridden), and `className`.
|
|
15
19
|
|
|
20
|
+
## Components
|
|
21
|
+
|
|
22
|
+
- **Button** -- `variant`: `primary | secondary | outline | ghost | danger`, `size`: `sm | md | lg`, plus every native `<button>` prop.
|
|
23
|
+
- **Card** -- optional `title`, wraps children in a bordered/padded container.
|
|
24
|
+
- **Input** / **Textarea** / **Select** -- optional `label` and `error`, plus every native form prop. `Select` takes `options: {value, label}[]` and an optional `placeholder`.
|
|
25
|
+
- **Badge** -- `variant`: `neutral | success | warning | danger | info`.
|
|
26
|
+
- **Modal** -- `open`, `onClose`, optional `title`; portals to `document.body`, closes on Escape or backdrop click.
|
|
27
|
+
- **EmptyState** -- `title`, optional `icon`/`description`/`action` -- the "nothing here yet" placeholder.
|
|
28
|
+
- **Spinner** -- `size` (number, default 20).
|
|
29
|
+
- **Alert** -- `variant`: `info | success | warning | danger`, optional `title` and `onDismiss`.
|
|
30
|
+
- **Checkbox** -- optional `label`, plus every native checkbox `<input>` prop.
|
|
31
|
+
- **Switch** -- controlled toggle: `checked`, `onChange(checked)`, optional `label`/`disabled`.
|
|
32
|
+
- **Avatar** -- `src` (image) or `name` (renders initials as a fallback), `size` (number, default 32).
|
|
33
|
+
- **Tabs** -- `items: {key, label, content}[]`; uncontrolled by default (`defaultKey`) or controlled via `activeKey`/`onChange`.
|
|
34
|
+
- **Table** / **Thead** / **Tbody** / **Tr** / **Th** / **Td** -- thin styled wrappers around native table elements, wrapped in a horizontal-scroll container. For sorting/filtering/pagination, use the `data-table` package bundle (`@tanstack/react-table`) instead -- these are for simple static display only.
|
|
35
|
+
|
|
36
|
+
`Button`/`Card`/etc. pick up each app's brand color automatically via the `bg-primary`/`text-primary` Tailwind utilities already wired to the `--primary` CSS variable every generated app's `globals.css` defines (see `template-patches.ts`'s `themedGlobalsCss`) -- no extra setup needed. Dark-mode styling uses Tailwind's default `dark:` variant (`prefers-color-scheme`).
|
|
37
|
+
|
|
16
38
|
## Consuming this from an app built outside this monorepo
|
|
17
39
|
|
|
18
40
|
This package ships raw, untranspiled `.tsx` source (same convention as `@sideblend/shared-types`) -- no build step to keep it trivial to iterate on. Next.js does **not** transpile `node_modules` by default, so any app that installs this from npm needs one line in its `next.config.ts`:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viibestack/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dependency-free React
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Dependency-free React UI kit -- icons plus core components (Button, Card, Input, Modal, etc.), styled with plain Tailwind utility classes, no runtime dependency beyond react/react-dom.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
7
7
|
"types": "./src/index.ts",
|
|
@@ -9,11 +9,14 @@
|
|
|
9
9
|
"src"
|
|
10
10
|
],
|
|
11
11
|
"peerDependencies": {
|
|
12
|
-
"react": ">=18"
|
|
12
|
+
"react": ">=18",
|
|
13
|
+
"react-dom": ">=18"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@types/react": "^19",
|
|
17
|
+
"@types/react-dom": "^19",
|
|
16
18
|
"react": "^19",
|
|
19
|
+
"react-dom": "^19",
|
|
17
20
|
"typescript": "^5"
|
|
18
21
|
},
|
|
19
22
|
"publishConfig": {
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Core UI building blocks -- Button, Card, Input, Textarea, Select, Badge,
|
|
4
|
+
// Modal, EmptyState, Spinner, Alert, Checkbox, Switch, Avatar, Tabs, and
|
|
5
|
+
// Table primitives. Styled with plain Tailwind utility classes
|
|
6
|
+
// (the consuming app already has Tailwind v4, see scratch-scaffold.ts) --
|
|
7
|
+
// no runtime dependency beyond react/react-dom. Brand color (bg-primary/
|
|
8
|
+
// text-primary/border-primary) comes from the --primary CSS custom
|
|
9
|
+
// property every generated app's globals.css already defines (see
|
|
10
|
+
// template-patches.ts's themedGlobalsCss) via Tailwind's @theme inline
|
|
11
|
+
// block -- these components pick that up automatically, no extra wiring.
|
|
12
|
+
// dark: variants use Tailwind's default prefers-color-scheme strategy.
|
|
13
|
+
|
|
14
|
+
import { createPortal } from "react-dom";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes, TableHTMLAttributes, ThHTMLAttributes, TdHTMLAttributes } from "react";
|
|
17
|
+
|
|
18
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
19
|
+
return parts.filter(Boolean).join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Button ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
|
25
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
26
|
+
|
|
27
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
28
|
+
variant?: ButtonVariant;
|
|
29
|
+
size?: ButtonSize;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
|
33
|
+
primary: "bg-primary text-white hover:brightness-90 disabled:opacity-50",
|
|
34
|
+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 disabled:opacity-50",
|
|
35
|
+
outline: "border border-gray-300 text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-900 disabled:opacity-50",
|
|
36
|
+
ghost: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50",
|
|
37
|
+
danger: "bg-red-600 text-white hover:bg-red-700 disabled:opacity-50",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const BUTTON_SIZES: Record<ButtonSize, string> = {
|
|
41
|
+
sm: "text-sm px-3 py-1.5 rounded-md",
|
|
42
|
+
md: "text-sm px-4 py-2 rounded-lg",
|
|
43
|
+
lg: "text-base px-5 py-2.5 rounded-lg",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function Button({ variant = "primary", size = "md", className, ...rest }: ButtonProps) {
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
className={cx(
|
|
50
|
+
"inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:cursor-not-allowed",
|
|
51
|
+
BUTTON_VARIANTS[variant],
|
|
52
|
+
BUTTON_SIZES[size],
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...rest}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Card ────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface CardProps {
|
|
63
|
+
title?: ReactNode;
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Card({ title, children, className }: CardProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div className={cx("rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900", className)}>
|
|
71
|
+
{title && <div className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
|
|
72
|
+
{children}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Field label wrapper (shared by Input/Textarea/Select) ──────────────────
|
|
78
|
+
|
|
79
|
+
function FieldWrapper({ label, error, children }: { label?: ReactNode; error?: ReactNode; children: ReactNode }) {
|
|
80
|
+
if (!label && !error) return <>{children}</>;
|
|
81
|
+
return (
|
|
82
|
+
<label className="flex flex-col gap-1.5">
|
|
83
|
+
{label && <span className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>}
|
|
84
|
+
{children}
|
|
85
|
+
{error && <span className="text-sm text-red-600 dark:text-red-400">{error}</span>}
|
|
86
|
+
</label>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const FIELD_CLASS =
|
|
91
|
+
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none " +
|
|
92
|
+
"focus:border-primary focus:ring-1 focus:ring-primary dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 " +
|
|
93
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
94
|
+
|
|
95
|
+
// ── Input ───────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
98
|
+
label?: ReactNode;
|
|
99
|
+
error?: ReactNode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function Input({ label, error, className, ...rest }: InputProps) {
|
|
103
|
+
return (
|
|
104
|
+
<FieldWrapper label={label} error={error}>
|
|
105
|
+
<input className={cx(FIELD_CLASS, Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest} />
|
|
106
|
+
</FieldWrapper>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Textarea ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
error?: ReactNode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function Textarea({ label, error, className, ...rest }: TextareaProps) {
|
|
118
|
+
return (
|
|
119
|
+
<FieldWrapper label={label} error={error}>
|
|
120
|
+
<textarea className={cx(FIELD_CLASS, "resize-y", Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest} />
|
|
121
|
+
</FieldWrapper>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Select ──────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface SelectOption { value: string; label: string }
|
|
128
|
+
|
|
129
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "children"> {
|
|
130
|
+
label?: ReactNode;
|
|
131
|
+
error?: ReactNode;
|
|
132
|
+
options: SelectOption[];
|
|
133
|
+
placeholder?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function Select({ label, error, options, placeholder, className, ...rest }: SelectProps) {
|
|
137
|
+
return (
|
|
138
|
+
<FieldWrapper label={label} error={error}>
|
|
139
|
+
<select className={cx(FIELD_CLASS, Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest}>
|
|
140
|
+
{placeholder && <option value="">{placeholder}</option>}
|
|
141
|
+
{options.map((o) => (
|
|
142
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</FieldWrapper>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Badge ───────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export type BadgeVariant = "neutral" | "success" | "warning" | "danger" | "info";
|
|
152
|
+
|
|
153
|
+
const BADGE_VARIANTS: Record<BadgeVariant, string> = {
|
|
154
|
+
neutral: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
155
|
+
success: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
|
|
156
|
+
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
|
157
|
+
danger: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
|
|
158
|
+
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export interface BadgeProps {
|
|
162
|
+
variant?: BadgeVariant;
|
|
163
|
+
children?: ReactNode;
|
|
164
|
+
className?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
|
|
168
|
+
return (
|
|
169
|
+
<span className={cx("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", BADGE_VARIANTS[variant], className)}>
|
|
170
|
+
{children}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Spinner ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export interface SpinnerProps {
|
|
178
|
+
size?: number;
|
|
179
|
+
className?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function Spinner({ size = 20, className }: SpinnerProps) {
|
|
183
|
+
return (
|
|
184
|
+
<svg
|
|
185
|
+
className={cx("animate-spin text-current", className)}
|
|
186
|
+
style={{ width: size, height: size }}
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
fill="none"
|
|
189
|
+
aria-label="Loading"
|
|
190
|
+
>
|
|
191
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
192
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
193
|
+
</svg>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── EmptyState ──────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface EmptyStateProps {
|
|
200
|
+
icon?: ReactNode;
|
|
201
|
+
title: ReactNode;
|
|
202
|
+
description?: ReactNode;
|
|
203
|
+
action?: ReactNode;
|
|
204
|
+
className?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
|
208
|
+
return (
|
|
209
|
+
<div className={cx("flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-gray-300 p-10 text-center dark:border-gray-700", className)}>
|
|
210
|
+
{icon && <div className="text-gray-400 dark:text-gray-600">{icon}</div>}
|
|
211
|
+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div>
|
|
212
|
+
{description && <div className="text-sm text-gray-500 dark:text-gray-400">{description}</div>}
|
|
213
|
+
{action && <div className="mt-2">{action}</div>}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Modal ───────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
export interface ModalProps {
|
|
221
|
+
open: boolean;
|
|
222
|
+
onClose: () => void;
|
|
223
|
+
title?: ReactNode;
|
|
224
|
+
children?: ReactNode;
|
|
225
|
+
className?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Portal to document.body so the modal escapes any parent's overflow/
|
|
229
|
+
// stacking context -- mounted-check guards the createPortal call during
|
|
230
|
+
// Next.js's SSR pass, where `document` doesn't exist yet.
|
|
231
|
+
export function Modal({ open, onClose, title, children, className }: ModalProps) {
|
|
232
|
+
const [mounted, setMounted] = useState(false);
|
|
233
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
234
|
+
|
|
235
|
+
useEffect(() => setMounted(true), []);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!open) return;
|
|
239
|
+
function onKey(e: KeyboardEvent) {
|
|
240
|
+
if (e.key === "Escape") onClose();
|
|
241
|
+
}
|
|
242
|
+
document.addEventListener("keydown", onKey);
|
|
243
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
244
|
+
}, [open, onClose]);
|
|
245
|
+
|
|
246
|
+
if (!mounted || !open) return null;
|
|
247
|
+
|
|
248
|
+
return createPortal(
|
|
249
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
250
|
+
<div
|
|
251
|
+
ref={panelRef}
|
|
252
|
+
role="dialog"
|
|
253
|
+
aria-modal="true"
|
|
254
|
+
className={cx("w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900", className)}
|
|
255
|
+
>
|
|
256
|
+
{title && <div className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
|
|
257
|
+
{children}
|
|
258
|
+
</div>
|
|
259
|
+
</div>,
|
|
260
|
+
document.body,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Alert ───────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export type AlertVariant = "info" | "success" | "warning" | "danger";
|
|
267
|
+
|
|
268
|
+
const ALERT_VARIANTS: Record<AlertVariant, string> = {
|
|
269
|
+
info: "bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900",
|
|
270
|
+
success: "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-900",
|
|
271
|
+
warning: "bg-amber-50 text-amber-800 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-900",
|
|
272
|
+
danger: "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900",
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export interface AlertProps {
|
|
276
|
+
variant?: AlertVariant;
|
|
277
|
+
title?: ReactNode;
|
|
278
|
+
children?: ReactNode;
|
|
279
|
+
onDismiss?: () => void;
|
|
280
|
+
className?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function Alert({ variant = "info", title, children, onDismiss, className }: AlertProps) {
|
|
284
|
+
return (
|
|
285
|
+
<div className={cx("flex items-start justify-between gap-3 rounded-lg border p-3.5 text-sm", ALERT_VARIANTS[variant], className)}>
|
|
286
|
+
<div>
|
|
287
|
+
{title && <div className="font-medium">{title}</div>}
|
|
288
|
+
{children && <div className={title ? "mt-0.5" : undefined}>{children}</div>}
|
|
289
|
+
</div>
|
|
290
|
+
{onDismiss && (
|
|
291
|
+
<button type="button" onClick={onDismiss} aria-label="Dismiss" className="shrink-0 opacity-60 hover:opacity-100">
|
|
292
|
+
✕
|
|
293
|
+
</button>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Checkbox ────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
302
|
+
label?: ReactNode;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
|
|
306
|
+
return (
|
|
307
|
+
<label className="inline-flex items-center gap-2 text-sm text-gray-900 dark:text-gray-100">
|
|
308
|
+
<input type="checkbox" className={cx("h-4 w-4 rounded border-gray-300 accent-primary dark:border-gray-700", className)} {...rest} />
|
|
309
|
+
{label}
|
|
310
|
+
</label>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Switch ──────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export interface SwitchProps {
|
|
317
|
+
checked: boolean;
|
|
318
|
+
onChange: (checked: boolean) => void;
|
|
319
|
+
label?: ReactNode;
|
|
320
|
+
disabled?: boolean;
|
|
321
|
+
className?: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function Switch({ checked, onChange, label, disabled, className }: SwitchProps) {
|
|
325
|
+
return (
|
|
326
|
+
<label className={cx("inline-flex items-center gap-2 text-sm text-gray-900 dark:text-gray-100", disabled && "opacity-50")}>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
role="switch"
|
|
330
|
+
aria-checked={checked}
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
onClick={() => onChange(!checked)}
|
|
333
|
+
className={cx(
|
|
334
|
+
"relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:cursor-not-allowed",
|
|
335
|
+
checked ? "bg-primary" : "bg-gray-300 dark:bg-gray-700",
|
|
336
|
+
className,
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
<span className={cx("absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform", checked && "translate-x-4")} />
|
|
340
|
+
</button>
|
|
341
|
+
{label}
|
|
342
|
+
</label>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Avatar ──────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
export interface AvatarProps {
|
|
349
|
+
src?: string;
|
|
350
|
+
name?: string;
|
|
351
|
+
size?: number;
|
|
352
|
+
className?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function initialsFor(name: string): string {
|
|
356
|
+
const parts = name.trim().split(/\s+/);
|
|
357
|
+
return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function Avatar({ src, name, size = 32, className }: AvatarProps) {
|
|
361
|
+
const style = { width: size, height: size, fontSize: size * 0.4 };
|
|
362
|
+
if (src) {
|
|
363
|
+
// eslint-disable-next-line @next/next/no-img-element -- a generic package can't depend on next/image
|
|
364
|
+
return <img src={src} alt={name ?? ""} style={style} className={cx("rounded-full object-cover", className)} />;
|
|
365
|
+
}
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
style={style}
|
|
369
|
+
className={cx("flex items-center justify-center rounded-full bg-primary font-medium text-white", className)}
|
|
370
|
+
aria-label={name}
|
|
371
|
+
>
|
|
372
|
+
{name ? initialsFor(name) : ""}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Tabs ────────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export interface TabItem {
|
|
380
|
+
key: string;
|
|
381
|
+
label: ReactNode;
|
|
382
|
+
content: ReactNode;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface TabsProps {
|
|
386
|
+
items: TabItem[];
|
|
387
|
+
defaultKey?: string;
|
|
388
|
+
activeKey?: string;
|
|
389
|
+
onChange?: (key: string) => void;
|
|
390
|
+
className?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Uncontrolled by default (defaultKey / internal state) -- pass activeKey +
|
|
394
|
+
// onChange together to control it externally instead.
|
|
395
|
+
export function Tabs({ items, defaultKey, activeKey, onChange, className }: TabsProps) {
|
|
396
|
+
const [internalKey, setInternalKey] = useState(defaultKey ?? items[0]?.key);
|
|
397
|
+
const key = activeKey ?? internalKey;
|
|
398
|
+
const active = items.find((i) => i.key === key) ?? items[0];
|
|
399
|
+
|
|
400
|
+
function select(k: string) {
|
|
401
|
+
if (activeKey === undefined) setInternalKey(k);
|
|
402
|
+
onChange?.(k);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<div className={className}>
|
|
407
|
+
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-800">
|
|
408
|
+
{items.map((item) => (
|
|
409
|
+
<button
|
|
410
|
+
key={item.key}
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={() => select(item.key)}
|
|
413
|
+
className={cx(
|
|
414
|
+
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
|
415
|
+
item.key === key
|
|
416
|
+
? "border-primary text-primary"
|
|
417
|
+
: "border-transparent text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100",
|
|
418
|
+
)}
|
|
419
|
+
>
|
|
420
|
+
{item.label}
|
|
421
|
+
</button>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
<div className="pt-4">{active?.content}</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Table primitives ─────────────────────────────────────────────────────────
|
|
430
|
+
// Thin styled wrappers around native table elements -- for simple, fully
|
|
431
|
+
// client-rendered data. For sorting/filtering/pagination, request the
|
|
432
|
+
// "data-table" package bundle (@tanstack/react-table) instead.
|
|
433
|
+
|
|
434
|
+
export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
|
|
435
|
+
return (
|
|
436
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
|
|
437
|
+
<table className={cx("w-full text-left text-sm", className)} {...rest} />
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function Thead(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
443
|
+
return <thead className="bg-gray-50 dark:bg-gray-900" {...props} />;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function Tbody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
447
|
+
return <tbody className="divide-y divide-gray-200 dark:divide-gray-800" {...props} />;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function Tr(props: HTMLAttributes<HTMLTableRowElement>) {
|
|
451
|
+
return <tr {...props} />;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
455
|
+
return <th className={cx("px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400", className)} {...rest} />;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
459
|
+
return <td className={cx("px-4 py-2.5 text-gray-900 dark:text-gray-100", className)} {...rest} />;
|
|
460
|
+
}
|
package/src/index.ts
CHANGED