@lovett/ui 0.0.1
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 +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- package/src/tokens.css +579 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FolderCard — drive-style folder tile.
|
|
3
|
+
*
|
|
4
|
+
* A horizontal tile: a colour-keyed folder-icon swatch, the folder
|
|
5
|
+
* name, an asset / subfolder count line, a hover-revealed selection
|
|
6
|
+
* checkbox, and an optional hover-revealed menu slot. Clicking the
|
|
7
|
+
* body opens the folder.
|
|
8
|
+
*
|
|
9
|
+
* Promoted to @lovett/ui in ADR-035 (Assets lens redesign). It is a
|
|
10
|
+
* primitive — not lens-local — because a folder card is a card
|
|
11
|
+
* surface and needs the internal surface tokens (`--bg-card`,
|
|
12
|
+
* `--border-card`); CLAUDE.md §5 says that is exactly when a
|
|
13
|
+
* component belongs in `packages/ui/`. Reusable by the v1.1 Swipes /
|
|
14
|
+
* Collections lenses (PRD §5.6) without a fork.
|
|
15
|
+
*
|
|
16
|
+
* Folder colour is a KEY into the `--folder-*` token palette
|
|
17
|
+
* (tokens.css — ADR-035 Decision 6), never a hex. The primitive maps
|
|
18
|
+
* the key to `rgb(var(--folder-<key>))`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
22
|
+
import { Check, Folder } from 'lucide-react'
|
|
23
|
+
import { cn } from './lib/utils'
|
|
24
|
+
|
|
25
|
+
export type FolderColorKey =
|
|
26
|
+
| 'red'
|
|
27
|
+
| 'amber'
|
|
28
|
+
| 'emerald'
|
|
29
|
+
| 'teal'
|
|
30
|
+
| 'blue'
|
|
31
|
+
| 'violet'
|
|
32
|
+
| 'pink'
|
|
33
|
+
| 'slate'
|
|
34
|
+
| 'ink'
|
|
35
|
+
|
|
36
|
+
/** All folder colour keys, in palette order — for swatch pickers. */
|
|
37
|
+
export const FOLDER_COLOR_KEYS: FolderColorKey[] = [
|
|
38
|
+
'red',
|
|
39
|
+
'amber',
|
|
40
|
+
'emerald',
|
|
41
|
+
'teal',
|
|
42
|
+
'blue',
|
|
43
|
+
'violet',
|
|
44
|
+
'pink',
|
|
45
|
+
'slate',
|
|
46
|
+
'ink',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/** Resolve a colour key to its `rgb(var(--folder-*))` CSS value. */
|
|
50
|
+
export function folderColorVar(key: FolderColorKey): string {
|
|
51
|
+
return `rgb(var(--folder-${key}))`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FolderCardProps {
|
|
55
|
+
name: string
|
|
56
|
+
colorKey: FolderColorKey
|
|
57
|
+
assetCount: number
|
|
58
|
+
/** Subfolder count — omitted or 0 → not shown. */
|
|
59
|
+
folderCount?: number
|
|
60
|
+
selected?: boolean
|
|
61
|
+
/** When true, a selection checkbox reveals on hover and stays
|
|
62
|
+
* visible while selected. */
|
|
63
|
+
selectable?: boolean
|
|
64
|
+
onOpen: () => void
|
|
65
|
+
onSelect?: () => void
|
|
66
|
+
/** Optional menu affordance (e.g. a kebab trigger), revealed on
|
|
67
|
+
* hover at the row end. */
|
|
68
|
+
menuSlot?: ReactNode
|
|
69
|
+
className?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function FolderCard({
|
|
73
|
+
name,
|
|
74
|
+
colorKey,
|
|
75
|
+
assetCount,
|
|
76
|
+
folderCount = 0,
|
|
77
|
+
selected = false,
|
|
78
|
+
selectable = false,
|
|
79
|
+
onOpen,
|
|
80
|
+
onSelect,
|
|
81
|
+
menuSlot,
|
|
82
|
+
className,
|
|
83
|
+
}: FolderCardProps) {
|
|
84
|
+
const [hover, setHover] = useState(false)
|
|
85
|
+
const color = folderColorVar(colorKey)
|
|
86
|
+
const showCheckbox = selectable && (hover || selected)
|
|
87
|
+
|
|
88
|
+
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
89
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
onOpen()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div
|
|
97
|
+
role="button"
|
|
98
|
+
tabIndex={0}
|
|
99
|
+
onClick={onOpen}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
onMouseEnter={() => setHover(true)}
|
|
102
|
+
onMouseLeave={() => setHover(false)}
|
|
103
|
+
className={cn(
|
|
104
|
+
'group/folder relative flex items-center gap-3 px-3 py-2.5 cursor-pointer transition-shadow text-left',
|
|
105
|
+
className,
|
|
106
|
+
)}
|
|
107
|
+
style={{
|
|
108
|
+
background: selected
|
|
109
|
+
? 'rgb(var(--accent-subtle))'
|
|
110
|
+
: 'rgb(var(--bg-card))',
|
|
111
|
+
border: `1px solid ${
|
|
112
|
+
selected ? 'rgb(var(--accent))' : 'rgb(var(--border-card))'
|
|
113
|
+
}`,
|
|
114
|
+
borderRadius: 'var(--radius-md)',
|
|
115
|
+
boxShadow: hover ? 'var(--shadow-sm)' : 'none',
|
|
116
|
+
}}
|
|
117
|
+
>
|
|
118
|
+
{/* Selection checkbox — top-left, revealed on hover / selected. */}
|
|
119
|
+
{showCheckbox && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={(e) => {
|
|
123
|
+
e.stopPropagation()
|
|
124
|
+
onSelect?.()
|
|
125
|
+
}}
|
|
126
|
+
aria-label={selected ? `Deselect ${name}` : `Select ${name}`}
|
|
127
|
+
aria-pressed={selected}
|
|
128
|
+
className="absolute top-2 left-2 z-[1] inline-flex h-[18px] w-[18px] items-center justify-center cursor-pointer text-white"
|
|
129
|
+
style={{
|
|
130
|
+
borderRadius: 'var(--radius-xs)',
|
|
131
|
+
background: selected
|
|
132
|
+
? 'rgb(var(--accent))'
|
|
133
|
+
: 'rgb(var(--bg-card))',
|
|
134
|
+
border: `1.5px solid ${
|
|
135
|
+
selected ? 'rgb(var(--accent))' : 'rgb(var(--border-strong))'
|
|
136
|
+
}`,
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
{selected && <Check className="h-3 w-3" strokeWidth={3} />}
|
|
140
|
+
</button>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Colour swatch + folder icon. */}
|
|
144
|
+
<span
|
|
145
|
+
className="inline-flex h-[38px] w-[38px] shrink-0 items-center justify-center"
|
|
146
|
+
style={{
|
|
147
|
+
borderRadius: 'var(--radius-sm)',
|
|
148
|
+
background: `rgb(var(--folder-${colorKey}) / 0.12)`,
|
|
149
|
+
color,
|
|
150
|
+
}}
|
|
151
|
+
>
|
|
152
|
+
<Folder className="h-5 w-5" strokeWidth={1.6} aria-hidden="true" />
|
|
153
|
+
</span>
|
|
154
|
+
|
|
155
|
+
{/* Name + count. */}
|
|
156
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
157
|
+
<span
|
|
158
|
+
className="truncate text-[13.5px] font-semibold"
|
|
159
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
160
|
+
>
|
|
161
|
+
{name}
|
|
162
|
+
</span>
|
|
163
|
+
<span
|
|
164
|
+
className="text-[11.5px] tabular-nums"
|
|
165
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
166
|
+
>
|
|
167
|
+
{assetCount} {assetCount === 1 ? 'asset' : 'assets'}
|
|
168
|
+
{folderCount > 0 && (
|
|
169
|
+
<>
|
|
170
|
+
{' · '}
|
|
171
|
+
{folderCount} {folderCount === 1 ? 'folder' : 'folders'}
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</span>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
{/* Menu slot — revealed on hover. */}
|
|
178
|
+
{menuSlot && (
|
|
179
|
+
<span
|
|
180
|
+
className="shrink-0 opacity-0 transition-opacity group-hover/folder:opacity-100"
|
|
181
|
+
onClick={(e) => e.stopPropagation()}
|
|
182
|
+
>
|
|
183
|
+
{menuSlot}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FolderTreePicker — dropdown picker for a nestable folder tree.
|
|
3
|
+
*
|
|
4
|
+
* Behavior:
|
|
5
|
+
* • Trigger renders the selected folder's name (with parent breadcrumb
|
|
6
|
+
* when nested) or a placeholder when none.
|
|
7
|
+
* • Popover shows a "(No folder)" option + DFS-ordered tree with
|
|
8
|
+
* indent by depth.
|
|
9
|
+
* • Optional inline "+ New folder" path — when the consumer passes
|
|
10
|
+
* `onCreateFolder`, the popover footer exposes a create form whose
|
|
11
|
+
* parent defaults to the currently selected folder (or root).
|
|
12
|
+
*
|
|
13
|
+
* Generic — accepts a `folders: Array<{ id, name, parentId }>` shape so
|
|
14
|
+
* any folder-like data structure can use it. First consumer: Keywords
|
|
15
|
+
* lens NewSetPage / NewGroupPage.
|
|
16
|
+
*
|
|
17
|
+
* Token discipline: tokens only (no hex). Trigger inherits the same
|
|
18
|
+
* `.btn .btn-secondary` chrome as our other dropdown triggers for
|
|
19
|
+
* visual consistency.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
useCallback,
|
|
24
|
+
useEffect,
|
|
25
|
+
useId,
|
|
26
|
+
useLayoutEffect,
|
|
27
|
+
useMemo,
|
|
28
|
+
useRef,
|
|
29
|
+
useState,
|
|
30
|
+
type ChangeEvent,
|
|
31
|
+
type KeyboardEvent,
|
|
32
|
+
} from 'react'
|
|
33
|
+
import { createPortal } from 'react-dom'
|
|
34
|
+
import { Check, ChevronDown, FolderClosed, FolderPlus, X } from 'lucide-react'
|
|
35
|
+
import { cn } from './lib/utils'
|
|
36
|
+
|
|
37
|
+
export interface FolderTreeNode {
|
|
38
|
+
id: string
|
|
39
|
+
name: string
|
|
40
|
+
parentId: string | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface FolderTreePickerProps {
|
|
44
|
+
/** Flat list of folder nodes (parentId references id). */
|
|
45
|
+
folders: FolderTreeNode[]
|
|
46
|
+
/** Currently selected folder id, or null for "no folder" / root. */
|
|
47
|
+
value: string | null
|
|
48
|
+
onChange: (next: string | null) => void
|
|
49
|
+
/** Optional inline-create handler. When omitted, the create UI is
|
|
50
|
+
* hidden — the picker is read-only over the given folders. The
|
|
51
|
+
* returned id is selected on success. */
|
|
52
|
+
onCreateFolder?: (input: {
|
|
53
|
+
name: string
|
|
54
|
+
parentId: string | null
|
|
55
|
+
}) => Promise<{ id: string }>
|
|
56
|
+
/** Placeholder when no folder is selected. */
|
|
57
|
+
placeholder?: string
|
|
58
|
+
/** Disable everything. */
|
|
59
|
+
disabled?: boolean
|
|
60
|
+
/** id for the trigger — supports label-for binding. */
|
|
61
|
+
id?: string
|
|
62
|
+
/** Optional className for the trigger button. */
|
|
63
|
+
className?: string
|
|
64
|
+
/** Apply error styling to the trigger. */
|
|
65
|
+
error?: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface OrderedNode extends FolderTreeNode {
|
|
69
|
+
depth: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildOrderedTree(folders: FolderTreeNode[]): OrderedNode[] {
|
|
73
|
+
// Children index keyed by parentId (null bucket = root).
|
|
74
|
+
const byParent = new Map<string | null, FolderTreeNode[]>()
|
|
75
|
+
for (const f of folders) {
|
|
76
|
+
const list = byParent.get(f.parentId) ?? []
|
|
77
|
+
list.push(f)
|
|
78
|
+
byParent.set(f.parentId, list)
|
|
79
|
+
}
|
|
80
|
+
for (const list of byParent.values()) {
|
|
81
|
+
list.sort((a, b) => a.name.localeCompare(b.name))
|
|
82
|
+
}
|
|
83
|
+
const out: OrderedNode[] = []
|
|
84
|
+
const walk = (parentId: string | null, depth: number) => {
|
|
85
|
+
const children = byParent.get(parentId) ?? []
|
|
86
|
+
for (const child of children) {
|
|
87
|
+
out.push({ ...child, depth })
|
|
88
|
+
walk(child.id, depth + 1)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
walk(null, 0)
|
|
92
|
+
return out
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function buildBreadcrumb(
|
|
96
|
+
folders: FolderTreeNode[],
|
|
97
|
+
id: string | null,
|
|
98
|
+
): string {
|
|
99
|
+
if (!id) return ''
|
|
100
|
+
const byId = new Map(folders.map((f) => [f.id, f]))
|
|
101
|
+
const chain: string[] = []
|
|
102
|
+
let cur: FolderTreeNode | undefined = byId.get(id)
|
|
103
|
+
while (cur) {
|
|
104
|
+
chain.unshift(cur.name)
|
|
105
|
+
cur = cur.parentId ? byId.get(cur.parentId) : undefined
|
|
106
|
+
}
|
|
107
|
+
return chain.join(' / ')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function FolderTreePicker({
|
|
111
|
+
folders,
|
|
112
|
+
value,
|
|
113
|
+
onChange,
|
|
114
|
+
onCreateFolder,
|
|
115
|
+
placeholder = 'No folder',
|
|
116
|
+
disabled,
|
|
117
|
+
id,
|
|
118
|
+
className,
|
|
119
|
+
error,
|
|
120
|
+
}: FolderTreePickerProps) {
|
|
121
|
+
const [open, setOpen] = useState(false)
|
|
122
|
+
const [creating, setCreating] = useState(false)
|
|
123
|
+
const [draft, setDraft] = useState('')
|
|
124
|
+
const [pending, setPending] = useState(false)
|
|
125
|
+
const [createError, setCreateError] = useState<string | null>(null)
|
|
126
|
+
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
127
|
+
const popoverRef = useRef<HTMLDivElement | null>(null)
|
|
128
|
+
const draftInputRef = useRef<HTMLInputElement | null>(null)
|
|
129
|
+
const generatedId = useId()
|
|
130
|
+
const popoverId = `folder-picker-${generatedId.replace(/:/g, '')}`
|
|
131
|
+
|
|
132
|
+
const orderedTree = useMemo(() => buildOrderedTree(folders), [folders])
|
|
133
|
+
const selectedLabel = useMemo(
|
|
134
|
+
() => buildBreadcrumb(folders, value),
|
|
135
|
+
[folders, value],
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const closeAndReset = useCallback(() => {
|
|
139
|
+
setOpen(false)
|
|
140
|
+
setCreating(false)
|
|
141
|
+
setDraft('')
|
|
142
|
+
setCreateError(null)
|
|
143
|
+
}, [])
|
|
144
|
+
|
|
145
|
+
// Outside-click + Escape.
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!open) return
|
|
148
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
149
|
+
const target = event.target as Node | null
|
|
150
|
+
if (!target) return
|
|
151
|
+
if (triggerRef.current?.contains(target)) return
|
|
152
|
+
if (popoverRef.current?.contains(target)) return
|
|
153
|
+
closeAndReset()
|
|
154
|
+
}
|
|
155
|
+
const onKeyDown = (event: KeyboardEvent | globalThis.KeyboardEvent) => {
|
|
156
|
+
if (event.key === 'Escape') {
|
|
157
|
+
event.stopPropagation()
|
|
158
|
+
closeAndReset()
|
|
159
|
+
triggerRef.current?.focus()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
document.addEventListener('pointerdown', onPointerDown)
|
|
163
|
+
document.addEventListener('keydown', onKeyDown as EventListener)
|
|
164
|
+
return () => {
|
|
165
|
+
document.removeEventListener('pointerdown', onPointerDown)
|
|
166
|
+
document.removeEventListener('keydown', onKeyDown as EventListener)
|
|
167
|
+
}
|
|
168
|
+
}, [open, closeAndReset])
|
|
169
|
+
|
|
170
|
+
// Position the popover under the trigger. Same two-phase approach as
|
|
171
|
+
// <DropdownMenuContent>: render off-screen at first, measure, then
|
|
172
|
+
// reposition + reveal.
|
|
173
|
+
const [popoverPos, setPopoverPos] = useState<{
|
|
174
|
+
left: number
|
|
175
|
+
top: number
|
|
176
|
+
width: number
|
|
177
|
+
} | null>(null)
|
|
178
|
+
useLayoutEffect(() => {
|
|
179
|
+
if (!open) {
|
|
180
|
+
setPopoverPos(null)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
const measure = () => {
|
|
184
|
+
const trig = triggerRef.current
|
|
185
|
+
if (!trig) return
|
|
186
|
+
const rect = trig.getBoundingClientRect()
|
|
187
|
+
// Width: at least 240px, capped at viewport.
|
|
188
|
+
const width = Math.max(
|
|
189
|
+
240,
|
|
190
|
+
Math.min(rect.width, window.innerWidth - 16),
|
|
191
|
+
)
|
|
192
|
+
const gutter = 8
|
|
193
|
+
let left = rect.left
|
|
194
|
+
const maxLeft = window.innerWidth - width - gutter
|
|
195
|
+
left = Math.max(gutter, Math.min(left, maxLeft))
|
|
196
|
+
const top = rect.bottom + 6
|
|
197
|
+
setPopoverPos({ left, top, width })
|
|
198
|
+
}
|
|
199
|
+
measure()
|
|
200
|
+
window.addEventListener('resize', measure)
|
|
201
|
+
window.addEventListener('scroll', measure, true)
|
|
202
|
+
return () => {
|
|
203
|
+
window.removeEventListener('resize', measure)
|
|
204
|
+
window.removeEventListener('scroll', measure, true)
|
|
205
|
+
}
|
|
206
|
+
}, [open])
|
|
207
|
+
|
|
208
|
+
// Focus the draft input when the create row opens.
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (creating) {
|
|
211
|
+
// Delay one frame so the input renders before focus.
|
|
212
|
+
const handle = requestAnimationFrame(() => {
|
|
213
|
+
draftInputRef.current?.focus()
|
|
214
|
+
})
|
|
215
|
+
return () => cancelAnimationFrame(handle)
|
|
216
|
+
}
|
|
217
|
+
return undefined
|
|
218
|
+
}, [creating])
|
|
219
|
+
|
|
220
|
+
const handleCreate = async () => {
|
|
221
|
+
if (!onCreateFolder) return
|
|
222
|
+
const name = draft.trim()
|
|
223
|
+
if (!name) return
|
|
224
|
+
setPending(true)
|
|
225
|
+
setCreateError(null)
|
|
226
|
+
try {
|
|
227
|
+
const created = await onCreateFolder({
|
|
228
|
+
name,
|
|
229
|
+
parentId: value,
|
|
230
|
+
})
|
|
231
|
+
onChange(created.id)
|
|
232
|
+
closeAndReset()
|
|
233
|
+
} catch (err) {
|
|
234
|
+
setCreateError(
|
|
235
|
+
err instanceof Error ? err.message : 'Could not create folder.',
|
|
236
|
+
)
|
|
237
|
+
} finally {
|
|
238
|
+
setPending(false)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleDraftKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
|
243
|
+
if (event.key === 'Enter') {
|
|
244
|
+
event.preventDefault()
|
|
245
|
+
handleCreate()
|
|
246
|
+
}
|
|
247
|
+
if (event.key === 'Escape') {
|
|
248
|
+
event.preventDefault()
|
|
249
|
+
setCreating(false)
|
|
250
|
+
setDraft('')
|
|
251
|
+
setCreateError(null)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const isPositioned = popoverPos !== null
|
|
256
|
+
|
|
257
|
+
return (
|
|
258
|
+
<>
|
|
259
|
+
<button
|
|
260
|
+
ref={triggerRef}
|
|
261
|
+
id={id}
|
|
262
|
+
type="button"
|
|
263
|
+
aria-haspopup="listbox"
|
|
264
|
+
aria-expanded={open}
|
|
265
|
+
aria-controls={open ? popoverId : undefined}
|
|
266
|
+
disabled={disabled}
|
|
267
|
+
onClick={() => setOpen((o) => !o)}
|
|
268
|
+
className={cn(
|
|
269
|
+
'btn btn-secondary justify-between w-full',
|
|
270
|
+
error && 'is-error',
|
|
271
|
+
className,
|
|
272
|
+
)}
|
|
273
|
+
style={{
|
|
274
|
+
borderColor: error ? 'rgb(var(--ring-error))' : undefined,
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
<span className="inline-flex items-center gap-2 min-w-0">
|
|
278
|
+
<FolderClosed
|
|
279
|
+
className="h-4 w-4 shrink-0"
|
|
280
|
+
style={{
|
|
281
|
+
color: value
|
|
282
|
+
? 'rgb(var(--text-secondary))'
|
|
283
|
+
: 'rgb(var(--text-tertiary))',
|
|
284
|
+
}}
|
|
285
|
+
aria-hidden="true"
|
|
286
|
+
/>
|
|
287
|
+
<span
|
|
288
|
+
className="truncate"
|
|
289
|
+
style={{
|
|
290
|
+
color: value
|
|
291
|
+
? 'rgb(var(--foreground))'
|
|
292
|
+
: 'rgb(var(--text-tertiary))',
|
|
293
|
+
}}
|
|
294
|
+
>
|
|
295
|
+
{selectedLabel || placeholder}
|
|
296
|
+
</span>
|
|
297
|
+
</span>
|
|
298
|
+
<ChevronDown
|
|
299
|
+
className="h-4 w-4 shrink-0"
|
|
300
|
+
aria-hidden="true"
|
|
301
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
302
|
+
/>
|
|
303
|
+
</button>
|
|
304
|
+
|
|
305
|
+
{open && typeof document !== 'undefined'
|
|
306
|
+
? createPortal(
|
|
307
|
+
<div
|
|
308
|
+
ref={popoverRef}
|
|
309
|
+
id={popoverId}
|
|
310
|
+
role="listbox"
|
|
311
|
+
className="fixed z-[100] overflow-hidden flex flex-col"
|
|
312
|
+
style={{
|
|
313
|
+
left: popoverPos?.left ?? -99999,
|
|
314
|
+
top: popoverPos?.top ?? -99999,
|
|
315
|
+
width: popoverPos?.width,
|
|
316
|
+
opacity: isPositioned ? undefined : 0,
|
|
317
|
+
visibility: isPositioned ? 'visible' : 'hidden',
|
|
318
|
+
pointerEvents: isPositioned ? undefined : 'none',
|
|
319
|
+
background: 'rgb(var(--popover))',
|
|
320
|
+
color: 'rgb(var(--popover-foreground))',
|
|
321
|
+
border: '1px solid rgb(var(--border))',
|
|
322
|
+
borderRadius: 'var(--radius-md)',
|
|
323
|
+
boxShadow:
|
|
324
|
+
'var(--shadow-lg, 0 10px 32px rgba(0,0,0,0.18))',
|
|
325
|
+
maxHeight: 'min(60vh, 28rem)',
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
<div className="overflow-y-auto flex-1 p-1">
|
|
329
|
+
<PickerOption
|
|
330
|
+
label={placeholder}
|
|
331
|
+
selected={value === null}
|
|
332
|
+
depth={0}
|
|
333
|
+
showIcon={false}
|
|
334
|
+
onClick={() => {
|
|
335
|
+
onChange(null)
|
|
336
|
+
closeAndReset()
|
|
337
|
+
}}
|
|
338
|
+
/>
|
|
339
|
+
{orderedTree.length > 0 && (
|
|
340
|
+
<div
|
|
341
|
+
className="my-1 h-px"
|
|
342
|
+
style={{ background: 'rgb(var(--border))' }}
|
|
343
|
+
/>
|
|
344
|
+
)}
|
|
345
|
+
{orderedTree.map((node) => (
|
|
346
|
+
<PickerOption
|
|
347
|
+
key={node.id}
|
|
348
|
+
label={node.name}
|
|
349
|
+
selected={value === node.id}
|
|
350
|
+
depth={node.depth}
|
|
351
|
+
showIcon
|
|
352
|
+
onClick={() => {
|
|
353
|
+
onChange(node.id)
|
|
354
|
+
closeAndReset()
|
|
355
|
+
}}
|
|
356
|
+
/>
|
|
357
|
+
))}
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
{onCreateFolder && (
|
|
361
|
+
<div
|
|
362
|
+
className="p-1 shrink-0"
|
|
363
|
+
style={{
|
|
364
|
+
borderTop: '1px solid rgb(var(--border))',
|
|
365
|
+
background: 'rgb(var(--surface-overlay-soft))',
|
|
366
|
+
}}
|
|
367
|
+
>
|
|
368
|
+
{creating ? (
|
|
369
|
+
<div className="flex flex-col gap-1 px-1.5 py-1">
|
|
370
|
+
<div className="flex items-center gap-1.5">
|
|
371
|
+
<FolderPlus
|
|
372
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
373
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
374
|
+
aria-hidden="true"
|
|
375
|
+
/>
|
|
376
|
+
<input
|
|
377
|
+
ref={draftInputRef}
|
|
378
|
+
type="text"
|
|
379
|
+
value={draft}
|
|
380
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
|
381
|
+
setDraft(e.target.value)
|
|
382
|
+
}
|
|
383
|
+
onKeyDown={handleDraftKeyDown}
|
|
384
|
+
placeholder={
|
|
385
|
+
value
|
|
386
|
+
? 'New sub-folder name'
|
|
387
|
+
: 'New folder name'
|
|
388
|
+
}
|
|
389
|
+
maxLength={80}
|
|
390
|
+
disabled={pending}
|
|
391
|
+
className="flex-1 min-w-0 bg-transparent text-[13px] outline-none"
|
|
392
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
393
|
+
/>
|
|
394
|
+
<button
|
|
395
|
+
type="button"
|
|
396
|
+
aria-label="Cancel new folder"
|
|
397
|
+
onClick={() => {
|
|
398
|
+
setCreating(false)
|
|
399
|
+
setDraft('')
|
|
400
|
+
setCreateError(null)
|
|
401
|
+
}}
|
|
402
|
+
className="inline-flex h-5 w-5 items-center justify-center rounded-[var(--radius-xs)] hover:bg-[rgb(var(--surface-overlay))]"
|
|
403
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
404
|
+
>
|
|
405
|
+
<X className="h-3 w-3" aria-hidden="true" />
|
|
406
|
+
</button>
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
onClick={handleCreate}
|
|
410
|
+
disabled={pending || draft.trim().length === 0}
|
|
411
|
+
className="btn btn-primary size-sm"
|
|
412
|
+
>
|
|
413
|
+
Create
|
|
414
|
+
</button>
|
|
415
|
+
</div>
|
|
416
|
+
{value && (
|
|
417
|
+
<span
|
|
418
|
+
className="text-[11px] pl-5"
|
|
419
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
420
|
+
>
|
|
421
|
+
Created inside {selectedLabel}
|
|
422
|
+
</span>
|
|
423
|
+
)}
|
|
424
|
+
{createError && (
|
|
425
|
+
<span
|
|
426
|
+
role="alert"
|
|
427
|
+
className="text-[11px] pl-5"
|
|
428
|
+
style={{ color: 'rgb(var(--destructive))' }}
|
|
429
|
+
>
|
|
430
|
+
{createError}
|
|
431
|
+
</span>
|
|
432
|
+
)}
|
|
433
|
+
</div>
|
|
434
|
+
) : (
|
|
435
|
+
<button
|
|
436
|
+
type="button"
|
|
437
|
+
onClick={() => setCreating(true)}
|
|
438
|
+
className="w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px] rounded-[var(--radius-xs)] hover:bg-[rgb(var(--surface-overlay))]"
|
|
439
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
440
|
+
>
|
|
441
|
+
<FolderPlus
|
|
442
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
443
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
444
|
+
aria-hidden="true"
|
|
445
|
+
/>
|
|
446
|
+
<span>New folder</span>
|
|
447
|
+
</button>
|
|
448
|
+
)}
|
|
449
|
+
</div>
|
|
450
|
+
)}
|
|
451
|
+
</div>,
|
|
452
|
+
document.body,
|
|
453
|
+
)
|
|
454
|
+
: null}
|
|
455
|
+
</>
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
interface PickerOptionProps {
|
|
460
|
+
label: string
|
|
461
|
+
selected: boolean
|
|
462
|
+
depth: number
|
|
463
|
+
showIcon: boolean
|
|
464
|
+
onClick: () => void
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function PickerOption({
|
|
468
|
+
label,
|
|
469
|
+
selected,
|
|
470
|
+
depth,
|
|
471
|
+
showIcon,
|
|
472
|
+
onClick,
|
|
473
|
+
}: PickerOptionProps) {
|
|
474
|
+
return (
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
role="option"
|
|
478
|
+
aria-selected={selected}
|
|
479
|
+
onClick={onClick}
|
|
480
|
+
className={cn(
|
|
481
|
+
'w-full flex items-center gap-2 px-2 py-1.5 text-left text-[13px]',
|
|
482
|
+
'rounded-[var(--radius-xs)] cursor-default',
|
|
483
|
+
selected
|
|
484
|
+
? 'bg-[rgb(var(--surface-overlay))]'
|
|
485
|
+
: 'hover:bg-[rgb(var(--surface-overlay-soft))]',
|
|
486
|
+
)}
|
|
487
|
+
style={{
|
|
488
|
+
color: 'rgb(var(--foreground))',
|
|
489
|
+
paddingLeft: `${0.5 + depth * 0.875}rem`,
|
|
490
|
+
}}
|
|
491
|
+
>
|
|
492
|
+
{showIcon ? (
|
|
493
|
+
<FolderClosed
|
|
494
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
495
|
+
style={{ color: 'rgb(var(--text-muted))' }}
|
|
496
|
+
aria-hidden="true"
|
|
497
|
+
/>
|
|
498
|
+
) : (
|
|
499
|
+
<span className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
|
500
|
+
)}
|
|
501
|
+
<span className="truncate flex-1">{label}</span>
|
|
502
|
+
{selected && (
|
|
503
|
+
<Check
|
|
504
|
+
className="h-3.5 w-3.5 shrink-0"
|
|
505
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
506
|
+
aria-hidden="true"
|
|
507
|
+
/>
|
|
508
|
+
)}
|
|
509
|
+
</button>
|
|
510
|
+
)
|
|
511
|
+
}
|