@olwiba/ui 0.1.14 → 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.
@@ -0,0 +1,184 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { ColumnDef } from '@tanstack/react-table';
5
+ import { MoreHorizontal, UserPlus } from 'lucide-react';
6
+ import {
7
+ Avatar,
8
+ AvatarFallback,
9
+ AvatarImage,
10
+ Badge,
11
+ DialogClose,
12
+ DialogContent,
13
+ DialogDescription,
14
+ DialogFooter,
15
+ DialogHeader,
16
+ DialogTitle,
17
+ DialogTrigger,
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuTrigger,
22
+ Label,
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ Dialog,
29
+ } from '@olwiba/cn';
30
+ import { Button } from '../primitives/Button';
31
+ import { Input } from '../primitives/Input';
32
+ import { DataTable } from '../components/DataTable';
33
+
34
+ export interface TeamMemberRecord {
35
+ id: string;
36
+ name: string;
37
+ email: string;
38
+ avatar?: string;
39
+ role: string;
40
+ }
41
+
42
+ export interface TeamMembersPanelProps {
43
+ members: TeamMemberRecord[];
44
+ /** Roles offered in the invite dialog and the per-row role menu. @default ['Owner', 'Admin', 'Member'] */
45
+ roles?: string[];
46
+ onInvite?: (email: string, role: string) => void;
47
+ onRoleChange?: (memberId: string, role: string) => void;
48
+ onRemove?: (memberId: string) => void;
49
+ title?: string;
50
+ description?: string;
51
+ }
52
+
53
+ function InviteDialog({ roles, onInvite, onClose }: { roles: string[]; onInvite?: (email: string, role: string) => void; onClose: () => void }) {
54
+ const [email, setEmail] = React.useState('');
55
+ const [role, setRole] = React.useState(roles[roles.length - 1] ?? roles[0]);
56
+
57
+ return (
58
+ <DialogContent>
59
+ <form
60
+ onSubmit={(e) => {
61
+ e.preventDefault();
62
+ onInvite?.(email, role);
63
+ onClose();
64
+ }}
65
+ >
66
+ <DialogHeader>
67
+ <DialogTitle>Invite a team member</DialogTitle>
68
+ <DialogDescription>They&rsquo;ll get an email invite to join this workspace.</DialogDescription>
69
+ </DialogHeader>
70
+ <div className="space-y-4 py-4">
71
+ <div className="space-y-2">
72
+ <Label htmlFor="invite-email">Email address</Label>
73
+ <Input id="invite-email" type="email" required placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
74
+ </div>
75
+ <div className="space-y-2">
76
+ <Label>Role</Label>
77
+ <Select value={role} onValueChange={setRole}>
78
+ <SelectTrigger><SelectValue /></SelectTrigger>
79
+ <SelectContent>
80
+ {roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
81
+ </SelectContent>
82
+ </Select>
83
+ </div>
84
+ </div>
85
+ <DialogFooter>
86
+ <DialogClose asChild>
87
+ <Button type="button" variant="outline">Cancel</Button>
88
+ </DialogClose>
89
+ <Button type="submit">Send invite</Button>
90
+ </DialogFooter>
91
+ </form>
92
+ </DialogContent>
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Member list with role management and an invite dialog. One block for
98
+ * team/org administration — built on `DataTable` rather than a bespoke list.
99
+ */
100
+ export function TeamMembersPanel({
101
+ members,
102
+ roles = ['Owner', 'Admin', 'Member'],
103
+ onInvite,
104
+ onRoleChange,
105
+ onRemove,
106
+ title = 'Team members',
107
+ description = 'Manage who has access to this workspace.',
108
+ }: TeamMembersPanelProps) {
109
+ const [inviteOpen, setInviteOpen] = React.useState(false);
110
+ const columns = React.useMemo<ColumnDef<TeamMemberRecord>[]>(() => [
111
+ {
112
+ accessorKey: 'name',
113
+ header: 'Name',
114
+ cell: ({ row }) => (
115
+ <div className="flex items-center gap-3">
116
+ <Avatar className="size-8">
117
+ <AvatarImage src={row.original.avatar} alt="" />
118
+ <AvatarFallback>{row.original.name.slice(0, 2).toUpperCase()}</AvatarFallback>
119
+ </Avatar>
120
+ <div>
121
+ <p className="text-sm font-medium leading-none">{row.original.name}</p>
122
+ <p className="mt-1 text-xs text-muted-foreground">{row.original.email}</p>
123
+ </div>
124
+ </div>
125
+ ),
126
+ },
127
+ {
128
+ accessorKey: 'role',
129
+ header: 'Role',
130
+ cell: ({ row }) =>
131
+ onRoleChange ? (
132
+ <Select value={row.original.role} onValueChange={(value) => onRoleChange(row.original.id, value)}>
133
+ <SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
134
+ <SelectContent>
135
+ {roles.map((r) => <SelectItem key={r} value={r}>{r}</SelectItem>)}
136
+ </SelectContent>
137
+ </Select>
138
+ ) : (
139
+ <Badge variant="secondary">{row.original.role}</Badge>
140
+ ),
141
+ },
142
+ ...(onRemove ? [{
143
+ id: 'actions',
144
+ header: '',
145
+ cell: ({ row }) => (
146
+ <DropdownMenu>
147
+ <DropdownMenuTrigger asChild>
148
+ <Button variant="ghost" size="icon" className="size-8">
149
+ <MoreHorizontal className="size-4" />
150
+ <span className="sr-only">Row actions</span>
151
+ </Button>
152
+ </DropdownMenuTrigger>
153
+ <DropdownMenuContent align="end">
154
+ <DropdownMenuItem className="text-destructive" onClick={() => onRemove(row.original.id)}>
155
+ Remove member
156
+ </DropdownMenuItem>
157
+ </DropdownMenuContent>
158
+ </DropdownMenu>
159
+ ),
160
+ enableSorting: false,
161
+ } satisfies ColumnDef<TeamMemberRecord>] : []),
162
+ ], [roles, onRoleChange, onRemove]);
163
+
164
+ return (
165
+ <div className="space-y-4">
166
+ <div className="flex flex-wrap items-center justify-between gap-4">
167
+ <div>
168
+ <h2 className="text-base font-semibold">{title}</h2>
169
+ <p className="mt-1 text-sm text-muted-foreground">{description}</p>
170
+ </div>
171
+ {onInvite && (
172
+ <Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
173
+ <DialogTrigger asChild>
174
+ <Button><UserPlus className="size-4" /> Invite member</Button>
175
+ </DialogTrigger>
176
+ {/* key resets the form fields each time the dialog opens */}
177
+ <InviteDialog key={String(inviteOpen)} roles={roles} onInvite={onInvite} onClose={() => setInviteOpen(false)} />
178
+ </Dialog>
179
+ )}
180
+ </div>
181
+ <DataTable columns={columns} data={members} searchKey="name" searchPlaceholder="Search members…" pageSize={0} />
182
+ </div>
183
+ );
184
+ }
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Activity } from 'lucide-react';
5
+ import { Avatar, AvatarFallback, AvatarImage, cn } from '@olwiba/cn';
6
+
7
+ export interface ActivityFeedItem {
8
+ id: string;
9
+ /** Main line — pass rich nodes for emphasis, e.g. <><b>Ana</b> deployed to production</>. */
10
+ title: React.ReactNode;
11
+ description?: string;
12
+ /** Pre-formatted timestamp, e.g. "2h ago" or "Mar 4". */
13
+ timestamp?: string;
14
+ /** Avatar image — takes precedence over `icon`. */
15
+ avatar?: string;
16
+ /** Fallback initials when `avatar` is set but fails to load. */
17
+ initials?: string;
18
+ /** Icon node rendered in the timeline marker when there is no avatar. */
19
+ icon?: React.ReactNode;
20
+ }
21
+
22
+ export interface ActivityFeedProps {
23
+ items: ActivityFeedItem[];
24
+ emptyMessage?: string;
25
+ className?: string;
26
+ }
27
+
28
+ /**
29
+ * Vertical activity timeline — avatar or icon markers connected by a rail,
30
+ * one row per event. Presentation-only: pass pre-formatted timestamps and
31
+ * rich `title` nodes from your own data layer.
32
+ */
33
+ export function ActivityFeed({
34
+ items,
35
+ emptyMessage = 'No activity yet.',
36
+ className,
37
+ }: ActivityFeedProps) {
38
+ if (items.length === 0) {
39
+ return (
40
+ <div className={cn('flex flex-col items-center gap-2 py-10 text-center', className)}>
41
+ <Activity className="size-6 text-muted-foreground" />
42
+ <p className="text-sm text-muted-foreground">{emptyMessage}</p>
43
+ </div>
44
+ );
45
+ }
46
+
47
+ return (
48
+ <ol className={className}>
49
+ {items.map((item, i) => (
50
+ <li key={item.id} className="relative flex gap-3 pb-6 last:pb-0">
51
+ {/* Rail connecting this marker to the next */}
52
+ {i !== items.length - 1 && (
53
+ <span aria-hidden className="absolute left-4 top-9 bottom-0 w-px -translate-x-1/2 bg-border" />
54
+ )}
55
+ <span className="relative z-10 flex size-8 shrink-0 items-center justify-center">
56
+ {item.avatar ? (
57
+ <Avatar className="size-8">
58
+ <AvatarImage src={item.avatar} alt="" />
59
+ <AvatarFallback className="text-xs">{item.initials ?? '?'}</AvatarFallback>
60
+ </Avatar>
61
+ ) : (
62
+ <span className="flex size-8 items-center justify-center rounded-full border bg-card text-muted-foreground [&>svg]:size-4">
63
+ {item.icon ?? <Activity />}
64
+ </span>
65
+ )}
66
+ </span>
67
+ <div className="min-w-0 flex-1 pt-1">
68
+ <div className="flex items-baseline justify-between gap-2">
69
+ <p className="text-sm">{item.title}</p>
70
+ {item.timestamp && (
71
+ <span className="shrink-0 text-xs text-muted-foreground">{item.timestamp}</span>
72
+ )}
73
+ </div>
74
+ {item.description && (
75
+ <p className="mt-0.5 text-xs text-muted-foreground">{item.description}</p>
76
+ )}
77
+ </div>
78
+ </li>
79
+ ))}
80
+ </ol>
81
+ );
82
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { LucideIcon } from 'lucide-react';
5
+ import {
6
+ CommandDialog,
7
+ CommandEmpty,
8
+ CommandGroup,
9
+ CommandInput,
10
+ CommandItem,
11
+ CommandList,
12
+ CommandSeparator,
13
+ CommandShortcut,
14
+ } from '@olwiba/cn';
15
+ import { RegisterHotkeys } from './RegisterHotkeys';
16
+ import { useControlledOpen } from '../hooks/use-controlled-open';
17
+
18
+ export interface CommandMenuItem {
19
+ id: string;
20
+ label: string;
21
+ icon?: LucideIcon;
22
+ shortcut?: string;
23
+ keywords?: string[];
24
+ onSelect: () => void;
25
+ }
26
+
27
+ export interface CommandMenuGroup {
28
+ heading: string;
29
+ items: CommandMenuItem[];
30
+ }
31
+
32
+ export interface CommandMenuProps {
33
+ groups: CommandMenuGroup[];
34
+ placeholder?: string;
35
+ emptyMessage?: string;
36
+ /** Controlled open state — omit to let the component manage it internally. */
37
+ open?: boolean;
38
+ onOpenChange?: (open: boolean) => void;
39
+ /** Registers Cmd+K (mac) / Ctrl+K (win) to toggle the palette. @default true */
40
+ hotkey?: boolean;
41
+ }
42
+
43
+ /**
44
+ * Global search / Cmd+K command palette. One component — pass different
45
+ * `groups` per surface rather than building a bespoke dialog each time.
46
+ */
47
+ export function CommandMenu({
48
+ groups,
49
+ placeholder = 'Type a command or search…',
50
+ emptyMessage = 'No results found.',
51
+ open: openProp,
52
+ onOpenChange,
53
+ hotkey = true,
54
+ }: CommandMenuProps) {
55
+ const internal = useControlledOpen(false);
56
+ const isOpen = openProp ?? internal.isOpen;
57
+ const setOpen = React.useCallback(
58
+ (next: boolean) => {
59
+ if (openProp === undefined) internal.setIsOpen(next);
60
+ onOpenChange?.(next);
61
+ },
62
+ [openProp, onOpenChange, internal.setIsOpen],
63
+ );
64
+
65
+ const runItem = (item: CommandMenuItem) => {
66
+ setOpen(false);
67
+ item.onSelect();
68
+ };
69
+
70
+ return (
71
+ <>
72
+ {hotkey && (
73
+ <RegisterHotkeys
74
+ hotkeys={[
75
+ { key: 'k', meta: true, handler: () => setOpen(!isOpen) },
76
+ { key: 'k', ctrl: true, handler: () => setOpen(!isOpen) },
77
+ ]}
78
+ />
79
+ )}
80
+ <CommandDialog open={isOpen} onOpenChange={setOpen}>
81
+ <CommandInput placeholder={placeholder} />
82
+ <CommandList>
83
+ <CommandEmpty>{emptyMessage}</CommandEmpty>
84
+ {groups.map((group, i) => (
85
+ <React.Fragment key={group.heading}>
86
+ {i > 0 && <CommandSeparator />}
87
+ <CommandGroup heading={group.heading}>
88
+ {group.items.map((item) => (
89
+ <CommandItem
90
+ key={item.id}
91
+ value={[item.label, ...(item.keywords ?? [])].join(' ')}
92
+ onSelect={() => runItem(item)}
93
+ >
94
+ {item.icon && <item.icon />}
95
+ <span>{item.label}</span>
96
+ {item.shortcut && <CommandShortcut>{item.shortcut}</CommandShortcut>}
97
+ </CommandItem>
98
+ ))}
99
+ </CommandGroup>
100
+ </React.Fragment>
101
+ ))}
102
+ </CommandList>
103
+ </CommandDialog>
104
+ </>
105
+ );
106
+ }
@@ -0,0 +1,212 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ type ColumnDef,
6
+ type SortingState,
7
+ flexRender,
8
+ getCoreRowModel,
9
+ getPaginationRowModel,
10
+ getSortedRowModel,
11
+ useReactTable,
12
+ } from '@tanstack/react-table';
13
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Search } from 'lucide-react';
14
+ import { Checkbox, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, cn } from '@olwiba/cn';
15
+ import { Button } from '../primitives/Button';
16
+ import { Input } from '../primitives/Input';
17
+
18
+ export interface DataTableProps<TData> {
19
+ columns: ColumnDef<TData>[];
20
+ data: TData[];
21
+ /** Shows a quick-filter input above the table, matching against `searchKey`. */
22
+ searchKey?: string;
23
+ searchPlaceholder?: string;
24
+ /** Adds a checkbox column and reports the selected rows. */
25
+ selectable?: boolean;
26
+ onSelectionChange?: (rows: TData[]) => void;
27
+ /** Rows per page. Set to `0` to disable pagination entirely. @default 10 */
28
+ pageSize?: number;
29
+ /** Slot rendered top-right of the toolbar — e.g. an "Add" button. */
30
+ toolbar?: React.ReactNode;
31
+ onRowClick?: (row: TData) => void;
32
+ emptyMessage?: string;
33
+ className?: string;
34
+ }
35
+
36
+ const selectColumn: ColumnDef<Record<string, unknown>> = {
37
+ id: '__select',
38
+ header: ({ table }) => (
39
+ <Checkbox
40
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate')}
41
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
42
+ aria-label="Select all"
43
+ />
44
+ ),
45
+ cell: ({ row }) => (
46
+ <Checkbox
47
+ checked={row.getIsSelected()}
48
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
49
+ onClick={(e) => e.stopPropagation()}
50
+ aria-label="Select row"
51
+ />
52
+ ),
53
+ enableSorting: false,
54
+ };
55
+
56
+ /**
57
+ * Sortable, paginated, optionally-selectable data table. One component —
58
+ * toggle `searchKey`/`selectable`/`pageSize` rather than reaching for a
59
+ * different table component per use case.
60
+ */
61
+ export function DataTable<TData>({
62
+ columns,
63
+ data,
64
+ searchKey,
65
+ searchPlaceholder = 'Search…',
66
+ selectable = false,
67
+ onSelectionChange,
68
+ pageSize = 10,
69
+ toolbar,
70
+ onRowClick,
71
+ emptyMessage = 'No results.',
72
+ className,
73
+ }: DataTableProps<TData>) {
74
+ const [sorting, setSorting] = React.useState<SortingState>([]);
75
+ const [rowSelection, setRowSelection] = React.useState({});
76
+ const [globalFilter, setGlobalFilter] = React.useState('');
77
+
78
+ const allColumns = React.useMemo(
79
+ () => (selectable ? [selectColumn as ColumnDef<TData>, ...columns] : columns),
80
+ [selectable, columns],
81
+ );
82
+
83
+ const table = useReactTable({
84
+ data,
85
+ columns: allColumns,
86
+ state: { sorting, rowSelection, globalFilter },
87
+ onSortingChange: setSorting,
88
+ onRowSelectionChange: setRowSelection,
89
+ onGlobalFilterChange: setGlobalFilter,
90
+ globalFilterFn: (row, _columnId, filterValue) => {
91
+ if (!searchKey) return true;
92
+ const value = row.getValue(searchKey);
93
+ return String(value ?? '').toLowerCase().includes(String(filterValue).toLowerCase());
94
+ },
95
+ getCoreRowModel: getCoreRowModel(),
96
+ getSortedRowModel: getSortedRowModel(),
97
+ ...(pageSize > 0 ? { getPaginationRowModel: getPaginationRowModel() } : {}),
98
+ initialState: pageSize > 0 ? { pagination: { pageSize } } : undefined,
99
+ enableRowSelection: selectable,
100
+ });
101
+
102
+ React.useEffect(() => {
103
+ if (!onSelectionChange) return;
104
+ onSelectionChange(table.getSelectedRowModel().rows.map((r) => r.original));
105
+ // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ }, [rowSelection]);
107
+
108
+ const rows = table.getRowModel().rows;
109
+
110
+ return (
111
+ <div className={cn('space-y-4', className)}>
112
+ {(searchKey || toolbar) && (
113
+ <div className="flex items-center justify-between gap-4">
114
+ {searchKey ? (
115
+ <div className="relative max-w-sm flex-1">
116
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
117
+ <Input
118
+ value={globalFilter}
119
+ onChange={(e) => setGlobalFilter(e.target.value)}
120
+ placeholder={searchPlaceholder}
121
+ className="pl-8"
122
+ />
123
+ </div>
124
+ ) : <div />}
125
+ {toolbar}
126
+ </div>
127
+ )}
128
+
129
+ <div className="overflow-hidden rounded-lg border">
130
+ <Table>
131
+ <TableHeader>
132
+ {table.getHeaderGroups().map((headerGroup) => (
133
+ <TableRow key={headerGroup.id}>
134
+ {headerGroup.headers.map((header) => {
135
+ const canSort = header.column.getCanSort();
136
+ const sortDir = header.column.getIsSorted();
137
+ return (
138
+ <TableHead
139
+ key={header.id}
140
+ aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : canSort ? 'none' : undefined}
141
+ >
142
+ {header.isPlaceholder ? null : canSort ? (
143
+ <button
144
+ type="button"
145
+ className="flex items-center gap-1.5 font-medium hover:text-foreground"
146
+ onClick={header.column.getToggleSortingHandler()}
147
+ >
148
+ {flexRender(header.column.columnDef.header, header.getContext())}
149
+ {sortDir === 'asc' ? <ArrowUp className="size-3.5" /> : sortDir === 'desc' ? <ArrowDown className="size-3.5" /> : <ArrowUpDown className="size-3.5 text-muted-foreground/50" />}
150
+ </button>
151
+ ) : (
152
+ flexRender(header.column.columnDef.header, header.getContext())
153
+ )}
154
+ </TableHead>
155
+ );
156
+ })}
157
+ </TableRow>
158
+ ))}
159
+ </TableHeader>
160
+ <TableBody>
161
+ {rows.length ? (
162
+ rows.map((row) => (
163
+ <TableRow
164
+ key={row.id}
165
+ data-state={row.getIsSelected() ? 'selected' : undefined}
166
+ onClick={() => onRowClick?.(row.original)}
167
+ tabIndex={onRowClick ? 0 : undefined}
168
+ onKeyDown={onRowClick ? (e) => {
169
+ if (e.key === 'Enter' && e.target === e.currentTarget) onRowClick(row.original);
170
+ } : undefined}
171
+ className={cn(onRowClick && 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
172
+ >
173
+ {row.getVisibleCells().map((cell) => (
174
+ <TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
175
+ ))}
176
+ </TableRow>
177
+ ))
178
+ ) : (
179
+ <TableRow>
180
+ <TableCell colSpan={allColumns.length} className="h-24 text-center text-muted-foreground">
181
+ {emptyMessage}
182
+ </TableCell>
183
+ </TableRow>
184
+ )}
185
+ </TableBody>
186
+ </Table>
187
+ </div>
188
+
189
+ {(selectable || (pageSize > 0 && table.getPageCount() > 1)) && (
190
+ <div className="flex items-center justify-between">
191
+ <p className="text-sm text-muted-foreground">
192
+ {selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`}
193
+ {selectable && pageSize > 0 && table.getPageCount() > 1 && ' · '}
194
+ {pageSize > 0 && table.getPageCount() > 1 && `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
195
+ </p>
196
+ {pageSize > 0 && table.getPageCount() > 1 && (
197
+ <div className="flex gap-2">
198
+ <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
199
+ <ChevronLeft className="size-4" /> Previous
200
+ </Button>
201
+ <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
202
+ Next <ChevronRight className="size-4" />
203
+ </Button>
204
+ </div>
205
+ )}
206
+ </div>
207
+ )}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ export type { ColumnDef as DataTableColumn };