@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 CHANGED
@@ -1,18 +1,40 @@
1
1
  # @viibestack/ui
2
2
 
3
- Dependency-free React components -- currently a Feather/Lucide-style outline icon set (~28 icons), ported from `services/portal/frontend/src/Icons.tsx`. No runtime dependencies beyond `react` itself, 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).
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, SettingsIcon } from "@viibestack/ui";
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.1.0",
4
- "description": "Dependency-free React icon set -- Feather/Lucide-style outline glyphs, no runtime dependency on any icon library.",
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
@@ -1 +1,2 @@
1
1
  export * from "./icons";
2
+ export * from "./components";