@cosxai/ui 1.0.0-alpha.1 → 1.0.0-alpha.3

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 (48) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +6 -1
  3. package/src/components/ActionBar.tsx +607 -0
  4. package/src/components/ActivityTimeline.tsx +77 -0
  5. package/src/components/AsyncSelect.tsx +205 -0
  6. package/src/components/Breadcrumb.tsx +129 -0
  7. package/src/components/Button.tsx +48 -40
  8. package/src/components/ChoiceCards.tsx +68 -0
  9. package/src/components/CodeInput.tsx +150 -0
  10. package/src/components/CommandPalette.tsx +228 -0
  11. package/src/components/ConfirmDialog.tsx +294 -0
  12. package/src/components/CopyField.tsx +104 -0
  13. package/src/components/DateInput.tsx +366 -0
  14. package/src/components/Dialog.tsx +27 -3
  15. package/src/components/Dropzone.tsx +187 -0
  16. package/src/components/FileCard.tsx +233 -0
  17. package/src/components/FileRow.tsx +159 -0
  18. package/src/components/FolderTree.tsx +377 -0
  19. package/src/components/ListToolbar.tsx +177 -0
  20. package/src/components/MentionInput.tsx +233 -0
  21. package/src/components/Menu.tsx +109 -0
  22. package/src/components/PageHeader.tsx +62 -0
  23. package/src/components/PageState.tsx +84 -0
  24. package/src/components/Progress.tsx +97 -0
  25. package/src/components/PulseDot.tsx +31 -0
  26. package/src/components/RecipientsInput.tsx +252 -0
  27. package/src/components/SearchField.tsx +137 -0
  28. package/src/components/SegmentedControl.tsx +95 -0
  29. package/src/components/SidePanel.tsx +130 -0
  30. package/src/components/Skeleton.tsx +73 -0
  31. package/src/components/StageProgress.tsx +51 -0
  32. package/src/components/Steps.tsx +58 -0
  33. package/src/components/SyncStatus.tsx +56 -0
  34. package/src/components/UploadCheck.tsx +118 -0
  35. package/src/components/UploadList.tsx +176 -0
  36. package/src/components/VirtualList.tsx +90 -0
  37. package/src/components/WindowDrop.tsx +131 -0
  38. package/src/components/inputs-aria.ts +5 -0
  39. package/src/components/inputs-date.ts +92 -0
  40. package/src/components/inputs-listbox.tsx +154 -0
  41. package/src/components/inputs-search.ts +60 -0
  42. package/src/components/status-files.ts +124 -0
  43. package/src/components/status-motion.ts +30 -0
  44. package/src/components/useSelection.ts +142 -0
  45. package/src/index.ts +38 -0
  46. package/src/components/core.test.tsx +0 -242
  47. package/src/components/forms.test.tsx +0 -280
  48. package/src/components/overlays.test.tsx +0 -245
@@ -0,0 +1,187 @@
1
+ import { FileX, Upload } from 'lucide-react';
2
+ import { useRef, useState, type ComponentProps, type ReactNode } from 'react';
3
+
4
+ import { cn } from '../lib/cn';
5
+ import { Button } from './Button';
6
+ import { Icon } from './Icon';
7
+ import { checkFiles, DEFAULT_MAX_SIZE, formatBytes, fromInput, hasFiles, readDataTransfer, type DroppedFile, type FileRules, type Rejected } from './status-files';
8
+ import { useWindowDrop } from './WindowDrop';
9
+
10
+ export type DropzoneLabels = {
11
+ idle?: string;
12
+ limit?: (maxSize: number) => string;
13
+ dragging?: string;
14
+ release?: (target: string) => string;
15
+ choose?: string;
16
+ chooseFolder?: string;
17
+ compact?: string;
18
+ refused?: (r: Rejected) => string;
19
+ };
20
+
21
+ export type DropzoneProps = Omit<ComponentProps<'div'>, 'onDrop' | 'children'> &
22
+ FileRules & {
23
+ /** Allow several files. @default true */
24
+ multiple?: boolean | undefined;
25
+ /** Offer "Choose a folder" too. */
26
+ directory?: boolean | undefined;
27
+ /** Folder name shown on hover: "Release to upload to <target>". */
28
+ target?: string | undefined;
29
+ /** One line, for list views. */
30
+ compact?: boolean | undefined;
31
+ disabled?: boolean | undefined;
32
+ /** Files that passed the checks, with folder paths. */
33
+ onFiles: (files: DroppedFile[]) => void;
34
+ /** Files refused, with the reason. */
35
+ onReject?: ((items: Rejected[]) => void) | undefined;
36
+ labels?: DropzoneLabels | undefined;
37
+ /** Extra actions beside Choose files. */
38
+ actions?: ReactNode;
39
+ };
40
+
41
+ export function refusedText(r: Rejected, maxSize = DEFAULT_MAX_SIZE): string {
42
+ if (r.reason === 'type') {
43
+ const ext = r.file.name.includes('.') ? r.file.name.slice(r.file.name.lastIndexOf('.')) : r.file.name;
44
+ return `${ext} files aren’t supported`;
45
+ }
46
+ if (r.reason === 'size') return `Files over ${formatBytes(maxSize)} aren’t supported`;
47
+ return 'Folders nested this deep aren’t supported';
48
+ }
49
+
50
+ /**
51
+ * Dropzone — drop files or folders here, or choose them (a real button:
52
+ * drag and drop is never the only way). Idle: a dashed edge; while files
53
+ * are dragged into the window: lit; over the zone: the yellow field naming
54
+ * the folder; refused: says why, not just red.
55
+ */
56
+ export function Dropzone({
57
+ accept,
58
+ maxSize = DEFAULT_MAX_SIZE,
59
+ maxDepth,
60
+ multiple = true,
61
+ directory = false,
62
+ target,
63
+ compact = false,
64
+ disabled = false,
65
+ onFiles,
66
+ onReject,
67
+ labels,
68
+ actions,
69
+ className,
70
+ ...rest
71
+ }: DropzoneProps) {
72
+ const { dragging, setHoverTarget } = useWindowDrop();
73
+ const [over, setOver] = useState(false);
74
+ const [refused, setRefused] = useState<Rejected | null>(null);
75
+ const files = useRef<HTMLInputElement | null>(null);
76
+ const folder = useRef<HTMLInputElement | null>(null);
77
+ const depth = useRef(0);
78
+
79
+ const take = (items: DroppedFile[]) => {
80
+ const { accepted, rejected } = checkFiles(items, { accept, maxSize, maxDepth });
81
+ const kept = multiple ? accepted : accepted.slice(0, 1);
82
+ setRefused(kept.length === 0 && rejected[0] ? rejected[0] : null);
83
+ if (kept.length) onFiles(kept);
84
+ if (rejected.length) onReject?.(rejected);
85
+ };
86
+
87
+ const release = labels?.release ?? ((t: string) => `Release to upload to ${t}`);
88
+ const state = disabled ? 'disabled' : over ? 'over' : refused ? 'refused' : dragging ? 'dragging' : 'idle';
89
+
90
+ return (
91
+ <div
92
+ data-state={state}
93
+ onDragEnter={(e) => {
94
+ if (disabled || !hasFiles(e.dataTransfer)) return;
95
+ depth.current += 1;
96
+ setOver(true);
97
+ setRefused(null);
98
+ if (target) setHoverTarget(target);
99
+ }}
100
+ onDragOver={(e) => {
101
+ if (disabled || !hasFiles(e.dataTransfer)) return;
102
+ e.preventDefault();
103
+ e.dataTransfer.dropEffect = 'copy';
104
+ }}
105
+ onDragLeave={(e) => {
106
+ if (!hasFiles(e.dataTransfer)) return;
107
+ depth.current = Math.max(0, depth.current - 1);
108
+ if (depth.current === 0) {
109
+ setOver(false);
110
+ setHoverTarget(null);
111
+ }
112
+ }}
113
+ onDrop={(e) => {
114
+ if (disabled || !hasFiles(e.dataTransfer)) return;
115
+ e.preventDefault();
116
+ e.stopPropagation(); // the window layer must not also take it
117
+ depth.current = 0;
118
+ setOver(false);
119
+ setHoverTarget(null);
120
+ void readDataTransfer(e.dataTransfer).then(take);
121
+ }}
122
+ className={cn(
123
+ 'flex rounded-lg border font-sans transition-[background-color,border-color] duration-[120ms] ease-standard',
124
+ compact ? 'items-center justify-between gap-3 px-4 py-2.5' : 'flex-col items-center justify-center gap-2 px-6 py-8 text-center',
125
+ state === 'idle' && 'border-dashed border-rule bg-transparent',
126
+ state === 'dragging' && 'border-dashed border-fg bg-yellow-wash-20 ink:bg-sunk',
127
+ state === 'over' && 'border-solid border-ink bg-brand-field text-ink',
128
+ state === 'refused' && 'border-dashed border-error bg-error-wash text-error-text ink:bg-error/20 ink:text-fg',
129
+ state === 'disabled' && 'border-dashed border-rule opacity-40',
130
+ className,
131
+ )}
132
+ {...rest}
133
+ >
134
+ <div className={cn('flex items-center', compact ? 'gap-2' : 'flex-col gap-2')}>
135
+ <Icon icon={state === 'refused' ? FileX : Upload} size={compact ? 16 : 20} />
136
+ <span aria-live="polite" className={cn('font-medium', compact ? 'text-small' : 'text-ui')}>
137
+ {state === 'over' && target
138
+ ? release(target)
139
+ : state === 'refused' && refused
140
+ ? (labels?.refused ?? ((r: Rejected) => refusedText(r, maxSize)))(refused)
141
+ : state === 'dragging' || state === 'over'
142
+ ? (labels?.dragging ?? 'Drop here')
143
+ : compact
144
+ ? (labels?.compact ?? 'Drop files or choose')
145
+ : (labels?.idle ?? (directory ? 'Drop files or folders' : 'Drop files'))}
146
+ </span>
147
+ {!compact && state === 'idle' && <span className="text-meta text-fg-secondary">{(labels?.limit ?? ((n: number) => `Up to ${formatBytes(n)} each`))(maxSize)}</span>}
148
+ </div>
149
+ <div className={cn('flex items-center gap-2', !compact && 'mt-2')}>
150
+ <Button size="sm" variant={compact ? 'secondary' : 'primary'} disabled={disabled} onClick={() => files.current?.click()}>
151
+ {labels?.choose ?? 'Choose files'}
152
+ </Button>
153
+ {directory && (
154
+ <Button size="sm" variant="ghost" disabled={disabled} onClick={() => folder.current?.click()}>
155
+ {labels?.chooseFolder ?? 'Choose a folder'}
156
+ </Button>
157
+ )}
158
+ {actions}
159
+ </div>
160
+ <input
161
+ ref={files}
162
+ type="file"
163
+ hidden
164
+ tabIndex={-1}
165
+ multiple={multiple}
166
+ accept={accept}
167
+ onChange={(e) => {
168
+ take(fromInput(e.target.files));
169
+ e.target.value = '';
170
+ }}
171
+ />
172
+ {directory && (
173
+ <input
174
+ ref={folder}
175
+ type="file"
176
+ hidden
177
+ tabIndex={-1}
178
+ {...({ webkitdirectory: '' } as object)}
179
+ onChange={(e) => {
180
+ take(fromInput(e.target.files));
181
+ e.target.value = '';
182
+ }}
183
+ />
184
+ )}
185
+ </div>
186
+ );
187
+ }
@@ -0,0 +1,233 @@
1
+ import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
2
+ import { Check, FileText, FileX, Folder, Link2, MessageSquare, Star, type LucideIcon } from 'lucide-react';
3
+ import type { ComponentProps, MouseEvent, ReactNode } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { Badge, type BadgeStatus } from './Badge';
7
+ import { Spinner } from './Spinner';
8
+ import { Tag } from './Tag';
9
+
10
+ export type FileCardState = 'ready' | 'converting' | 'failed';
11
+
12
+ export type FileCardProps = Omit<ComponentProps<'div'>, 'title' | 'onClick'> & {
13
+ title: ReactNode;
14
+ /** file · folder · link (a card that points elsewhere: dashed edge). @default "file" */
15
+ kind?: 'file' | 'folder' | 'link' | undefined;
16
+ /** The thumbnail. Without one the icon sits on linen. */
17
+ preview?: ReactNode;
18
+ /** Icon when there is no preview. Defaults by kind. */
19
+ icon?: LucideIcon | undefined;
20
+ /** Format tag: PDF, Word. */
21
+ type?: ReactNode;
22
+ /** Status in words; `status` picks the badge. */
23
+ statusLabel?: ReactNode;
24
+ status?: BadgeStatus | undefined;
25
+ /** The last line: "Updated 2 hours ago", "in Harbour / Site / 2026". */
26
+ meta?: ReactNode;
27
+ /** Comment count after the status; hidden at zero. */
28
+ comments?: number | undefined;
29
+ /** A small source label on the preview: "Dropbox". */
30
+ source?: ReactNode;
31
+ /** ready · converting (spinner and label) · failed (red wash, a Re-render action). */
32
+ state?: FileCardState | undefined;
33
+ stateLabel?: ReactNode;
34
+ onRerender?: (() => void) | undefined;
35
+ /** @default "Re-render" */
36
+ rerenderLabel?: string | undefined;
37
+ /** Link cards: the Remove link action beside the target. */
38
+ onRemoveLink?: (() => void) | undefined;
39
+ /** @default "Remove link" */
40
+ removeLinkLabel?: string | undefined;
41
+
42
+ selected?: boolean | undefined;
43
+ /** In selection mode the whole card toggles instead of opening. */
44
+ selectionMode?: boolean | undefined;
45
+ onSelectedChange?: ((selected: boolean) => void) | undefined;
46
+ /** Accessible name of the tick box. @default "Select" */
47
+ selectLabel?: string | undefined;
48
+
49
+ starred?: boolean | undefined;
50
+ onStarredChange?: ((starred: boolean) => void) | undefined;
51
+ /** @default "Star" */
52
+ starLabel?: string | undefined;
53
+
54
+ /** Opens the item (not in selection mode). */
55
+ onOpen?: (() => void) | undefined;
56
+ /** Every press of the card, with its modifiers — wire useSelection.handleClick here; replaces the default open / toggle. */
57
+ onPress?: ((event: MouseEvent<HTMLButtonElement>) => void) | undefined;
58
+ };
59
+
60
+ const KIND_ICON: Record<NonNullable<FileCardProps['kind']>, LucideIcon> = { file: FileText, folder: Folder, link: Link2 };
61
+
62
+ /**
63
+ * FileCard — the library card shared by every grid. The title is the one
64
+ * real button, stretched over the card; the tick box, the star and inline
65
+ * actions sit above it and never open the card. In selection mode the
66
+ * whole card is the checkbox.
67
+ */
68
+ export function FileCard({
69
+ title,
70
+ kind = 'file',
71
+ preview,
72
+ icon,
73
+ type,
74
+ statusLabel,
75
+ status,
76
+ meta,
77
+ comments,
78
+ source,
79
+ state = 'ready',
80
+ stateLabel,
81
+ onRerender,
82
+ rerenderLabel = 'Re-render',
83
+ onRemoveLink,
84
+ removeLinkLabel = 'Remove link',
85
+ selected = false,
86
+ selectionMode = false,
87
+ onSelectedChange,
88
+ selectLabel = 'Select',
89
+ starred,
90
+ onStarredChange,
91
+ starLabel = 'Star',
92
+ onOpen,
93
+ onPress,
94
+ className,
95
+ ...rest
96
+ }: FileCardProps) {
97
+ const Glyph = icon ?? KIND_ICON[kind];
98
+ const press = (e: MouseEvent<HTMLButtonElement>) => {
99
+ if (onPress) onPress(e);
100
+ else if (selectionMode) onSelectedChange?.(!selected);
101
+ else onOpen?.();
102
+ };
103
+ const showTick = Boolean(onSelectedChange) && (selectionMode || selected);
104
+
105
+ return (
106
+ <div
107
+ data-selected={selected || undefined}
108
+ className={cn(
109
+ 'group relative flex flex-col overflow-hidden rounded-lg bg-page text-fg',
110
+ 'transition-[border-color] duration-[120ms] ease-standard',
111
+ kind === 'link' ? 'border border-dashed border-rule' : 'border border-rule',
112
+ selected ? 'border-2 border-fg' : 'hover:border-fg focus-within:border-fg',
113
+ className,
114
+ )}
115
+ {...rest}
116
+ >
117
+ {/* Preview */}
118
+ <div
119
+ className={cn(
120
+ 'relative grid h-[180px] place-items-center text-fg',
121
+ selected ? 'bg-brand-field text-ink' : state === 'failed' ? 'bg-error-wash text-ink ink:bg-error/20 ink:text-fg' : kind === 'link' ? 'bg-page' : 'bg-sunk',
122
+ )}
123
+ >
124
+ {state === 'converting' ? (
125
+ <div className="flex flex-col items-center gap-2 text-small text-fg-secondary">
126
+ <Spinner size={16} />
127
+ {stateLabel}
128
+ </div>
129
+ ) : state === 'failed' ? (
130
+ <div className="flex flex-col items-center gap-2 text-small">
131
+ <FileX size={20} strokeWidth={1.75} aria-hidden />
132
+ <span>{stateLabel}</span>
133
+ {onRerender && (
134
+ <button
135
+ type="button"
136
+ onClick={onRerender}
137
+ className="relative z-10 cursor-pointer font-semibold underline underline-offset-2 outline-none focus-visible:shadow-(--focus-ring)"
138
+ >
139
+ {rerenderLabel}
140
+ </button>
141
+ )}
142
+ </div>
143
+ ) : (
144
+ (preview ?? <Glyph size={32} strokeWidth={1.5} aria-hidden />)
145
+ )}
146
+
147
+ {source && !showTick && (
148
+ <span className="absolute top-2.5 left-2.5 inline-flex items-center gap-1.5 rounded-sm bg-page px-1.5 py-1 text-[11px] font-semibold text-fg">
149
+ <span aria-hidden className="size-1.5 rounded-pill bg-brand-mark" />
150
+ {source}
151
+ </span>
152
+ )}
153
+
154
+ {onSelectedChange && (
155
+ <CheckboxPrimitive.Root
156
+ checked={selected}
157
+ onCheckedChange={(v) => onSelectedChange(v === true)}
158
+ aria-label={selectLabel}
159
+ className={cn(
160
+ 'absolute top-2.5 left-2.5 z-10 grid size-5 cursor-pointer place-items-center rounded-xs border border-rule bg-page outline-none',
161
+ 'data-[state=checked]:border-fg data-[state=checked]:bg-fg data-[state=checked]:text-page focus-visible:shadow-(--focus-ring)',
162
+ showTick ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
163
+ )}
164
+ >
165
+ <CheckboxPrimitive.Indicator>
166
+ <Check size={13} strokeWidth={2.5} aria-hidden />
167
+ </CheckboxPrimitive.Indicator>
168
+ </CheckboxPrimitive.Root>
169
+ )}
170
+
171
+ {onStarredChange && (
172
+ <button
173
+ type="button"
174
+ aria-label={starLabel}
175
+ aria-pressed={Boolean(starred)}
176
+ onClick={() => onStarredChange(!starred)}
177
+ className={cn(
178
+ 'absolute top-2 right-2 z-10 grid size-7 cursor-pointer place-items-center rounded-md text-fg outline-none hover:bg-hover focus-visible:shadow-(--focus-ring)',
179
+ starred ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
180
+ )}
181
+ >
182
+ <Star size={15} strokeWidth={1.75} aria-hidden className={starred ? 'fill-brand-mark text-fg' : undefined} />
183
+ </button>
184
+ )}
185
+ </div>
186
+
187
+ {/* Body */}
188
+ <div className="flex min-w-0 flex-col gap-1.5 px-3.5 pt-3 pb-3.5">
189
+ <button
190
+ type="button"
191
+ onClick={press}
192
+ aria-pressed={selectionMode ? selected : undefined}
193
+ className={cn(
194
+ 'min-w-0 cursor-pointer truncate text-left text-ui font-medium text-fg outline-none',
195
+ // Stretched: the whole card is this button's target.
196
+ 'after:absolute after:inset-0 after:content-[""] focus-visible:after:rounded-lg focus-visible:after:shadow-(--focus-ring)',
197
+ )}
198
+ >
199
+ {title}
200
+ </button>
201
+ {(type || statusLabel || (comments ?? 0) > 0) && (
202
+ <div className="flex min-w-0 items-center gap-2">
203
+ {type && <Tag className="px-1.5 py-0.5 font-semibold text-fg">{type}</Tag>}
204
+ {statusLabel && <Badge status={status}>{statusLabel}</Badge>}
205
+ {(comments ?? 0) > 0 && (
206
+ <span className="ml-auto inline-flex items-center gap-1 text-meta text-fg-secondary">
207
+ <MessageSquare size={13} strokeWidth={1.75} aria-hidden />
208
+ <span className="tabular-nums">{comments}</span>
209
+ </span>
210
+ )}
211
+ </div>
212
+ )}
213
+ {(meta || onRemoveLink) && (
214
+ <div className="flex min-w-0 items-center gap-1 text-meta text-fg-secondary">
215
+ {meta && <span className="truncate">{meta}</span>}
216
+ {onRemoveLink && (
217
+ <>
218
+ {meta && <span aria-hidden>·</span>}
219
+ <button
220
+ type="button"
221
+ onClick={onRemoveLink}
222
+ className="relative z-10 cursor-pointer text-fg-secondary outline-none hover:text-fg hover:underline focus-visible:shadow-(--focus-ring)"
223
+ >
224
+ {removeLinkLabel}
225
+ </button>
226
+ </>
227
+ )}
228
+ </div>
229
+ )}
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -0,0 +1,159 @@
1
+ import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
2
+ import { Check, FileText, Star, type LucideIcon } from 'lucide-react';
3
+ import type { ComponentProps, MouseEvent, ReactNode } from 'react';
4
+
5
+ import { cn } from '../lib/cn';
6
+ import { Badge, type BadgeStatus } from './Badge';
7
+
8
+ /** The shared column layout of a file list: [tick] icon · name · state · updated · [actions]. */
9
+ const GRID = 'grid grid-cols-[20px_minmax(0,1fr)_minmax(96px,120px)_minmax(96px,160px)_32px] items-center gap-x-3';
10
+ const GRID_SELECTABLE = 'grid grid-cols-[20px_20px_minmax(0,1fr)_minmax(96px,120px)_minmax(96px,160px)_32px] items-center gap-x-3';
11
+
12
+ export type FileListProps = ComponentProps<'div'> & {
13
+ /** Column headings: name, state, updated. Omit for a headless list. */
14
+ columns?: { name: ReactNode; state: ReactNode; updated: ReactNode } | undefined;
15
+ /** Leave room for the tick column. */
16
+ selectable?: boolean | undefined;
17
+ /** Accessible name of the list. */
18
+ label: string;
19
+ };
20
+
21
+ /** FileList — the row container: optional headings, then FileRows. */
22
+ export function FileList({ columns, selectable = false, label, className, children, ...rest }: FileListProps) {
23
+ return (
24
+ <div className={cn('flex flex-col', className)} {...rest}>
25
+ {columns && (
26
+ <div aria-hidden className={cn(selectable ? GRID_SELECTABLE : GRID, 'border-b border-rule px-3 pb-2 text-meta font-medium text-fg-secondary')}>
27
+ {selectable && <span />}
28
+ <span />
29
+ <span>{columns.name}</span>
30
+ <span>{columns.state}</span>
31
+ <span>{columns.updated}</span>
32
+ <span />
33
+ </div>
34
+ )}
35
+ <ul aria-label={label} className="m-0 flex list-none flex-col p-0">
36
+ {children}
37
+ </ul>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ export type FileRowProps = Omit<ComponentProps<'li'>, 'title' | 'onClick'> & {
43
+ name: ReactNode;
44
+ icon?: LucideIcon | undefined;
45
+ statusLabel?: ReactNode;
46
+ status?: BadgeStatus | undefined;
47
+ /** "2 hours ago" — or, in search results, the location: "in Legal / 2026". */
48
+ updated?: ReactNode;
49
+ /** A wash for rows that need someone (attention) or are overdue (error). */
50
+ tone?: 'attention' | 'error' | undefined;
51
+ /** Match the FileList's selectable. */
52
+ selectable?: boolean | undefined;
53
+ selected?: boolean | undefined;
54
+ selectionMode?: boolean | undefined;
55
+ onSelectedChange?: ((selected: boolean) => void) | undefined;
56
+ /** @default "Select" */
57
+ selectLabel?: string | undefined;
58
+ starred?: boolean | undefined;
59
+ onStarredChange?: ((starred: boolean) => void) | undefined;
60
+ /** @default "Star" */
61
+ starLabel?: string | undefined;
62
+ onOpen?: (() => void) | undefined;
63
+ /** Every press with its modifiers — wire useSelection.handleClick; replaces the default open / toggle. */
64
+ onPress?: ((event: MouseEvent<HTMLButtonElement>) => void) | undefined;
65
+ };
66
+
67
+ /**
68
+ * FileRow — a list row: icon, name, state, updated or location, a star on
69
+ * hover. Like FileCard, the name is the one button stretched over the row;
70
+ * the tick box and the star sit above it and never open the row.
71
+ */
72
+ export function FileRow({
73
+ name,
74
+ icon: Glyph = FileText,
75
+ statusLabel,
76
+ status,
77
+ updated,
78
+ tone,
79
+ selectable = false,
80
+ selected = false,
81
+ selectionMode = false,
82
+ onSelectedChange,
83
+ selectLabel = 'Select',
84
+ starred,
85
+ onStarredChange,
86
+ starLabel = 'Star',
87
+ onOpen,
88
+ onPress,
89
+ className,
90
+ ...rest
91
+ }: FileRowProps) {
92
+ const press = (e: MouseEvent<HTMLButtonElement>) => {
93
+ if (onPress) onPress(e);
94
+ else if (selectionMode) onSelectedChange?.(!selected);
95
+ else onOpen?.();
96
+ };
97
+ return (
98
+ <li
99
+ data-selected={selected || undefined}
100
+ className={cn(
101
+ selectable ? GRID_SELECTABLE : GRID,
102
+ 'group relative min-h-[42px] border-b border-rule-soft px-3 py-2 text-ui text-fg',
103
+ selected
104
+ ? 'bg-brand-field text-ink'
105
+ : tone === 'attention'
106
+ ? 'bg-yellow-wash-32 ink:bg-yellow-accent/15'
107
+ : tone === 'error'
108
+ ? 'bg-error-wash ink:bg-error/20'
109
+ : 'hover:bg-hover',
110
+ className,
111
+ )}
112
+ {...rest}
113
+ >
114
+ {selectable && (
115
+ <CheckboxPrimitive.Root
116
+ checked={selected}
117
+ onCheckedChange={(v) => onSelectedChange?.(v === true)}
118
+ aria-label={selectLabel}
119
+ className={cn(
120
+ 'relative z-10 grid size-4 cursor-pointer place-items-center rounded-xs border border-rule bg-page outline-none',
121
+ 'data-[state=checked]:border-fg data-[state=checked]:bg-fg data-[state=checked]:text-page focus-visible:shadow-(--focus-ring)',
122
+ selectionMode || selected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
123
+ )}
124
+ >
125
+ <CheckboxPrimitive.Indicator>
126
+ <Check size={12} strokeWidth={2.5} aria-hidden />
127
+ </CheckboxPrimitive.Indicator>
128
+ </CheckboxPrimitive.Root>
129
+ )}
130
+ <Glyph size={16} strokeWidth={1.75} aria-hidden className="text-fg" />
131
+ <button
132
+ type="button"
133
+ onClick={press}
134
+ aria-pressed={selectionMode ? selected : undefined}
135
+ className="min-w-0 cursor-pointer truncate text-left outline-none after:absolute after:inset-0 after:content-[''] focus-visible:after:shadow-(--focus-ring)"
136
+ >
137
+ {name}
138
+ </button>
139
+ <span className="min-w-0">{statusLabel ? <Badge status={status}>{statusLabel}</Badge> : null}</span>
140
+ <span className={cn('min-w-0 truncate text-small', selected ? 'text-fg-on-yellow-secondary' : 'text-fg-secondary')}>{updated}</span>
141
+ <span className="flex justify-end">
142
+ {onStarredChange && (
143
+ <button
144
+ type="button"
145
+ aria-label={starLabel}
146
+ aria-pressed={Boolean(starred)}
147
+ onClick={() => onStarredChange(!starred)}
148
+ className={cn(
149
+ 'relative z-10 grid size-7 cursor-pointer place-items-center rounded-md outline-none hover:bg-hover focus-visible:shadow-(--focus-ring)',
150
+ starred ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
151
+ )}
152
+ >
153
+ <Star size={15} strokeWidth={1.75} aria-hidden className={starred ? 'fill-brand-mark' : undefined} />
154
+ </button>
155
+ )}
156
+ </span>
157
+ </li>
158
+ );
159
+ }