@podoba/react 0.0.32 → 0.0.35
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/package.json +3 -3
- package/src/components/asset-masonry-grid.examples.tsx +9 -0
- package/src/components/asset-masonry-grid.tsx +62 -0
- package/src/components/asset-selection-surface.examples.tsx +32 -0
- package/src/components/asset-selection-surface.tsx +184 -0
- package/src/components/brand-page-header.tsx +297 -67
- package/src/components/button.tsx +27 -4
- package/src/components/combobox.tsx +1 -1
- package/src/components/compact-action-button.examples.tsx +7 -0
- package/src/components/compact-action-button.tsx +21 -0
- package/src/components/compact-settings-dialog.examples.tsx +15 -0
- package/src/components/compact-settings-dialog.tsx +48 -0
- package/src/components/context-action-glyph.examples.tsx +9 -0
- package/src/components/context-action-glyph.tsx +61 -0
- package/src/components/context-menu.tsx +191 -107
- package/src/components/context-search-panel.examples.tsx +9 -0
- package/src/components/context-search-panel.tsx +50 -0
- package/src/components/csv-binding-presentation.examples.tsx +14 -0
- package/src/components/csv-binding-presentation.tsx +40 -0
- package/src/components/cta-pill.tsx +19 -4
- package/src/components/dashboard-grid.tsx +1 -1
- package/src/components/date-picker.tsx +5 -3
- package/src/components/date-selection-calendar.examples.tsx +8 -0
- package/src/components/date-selection-calendar.tsx +47 -0
- package/src/components/delivery/send-to-print-modal.tsx +351 -84
- package/src/components/dialog-action-button.examples.tsx +7 -0
- package/src/components/dialog-action-button.tsx +22 -0
- package/src/components/dialog.tsx +32 -12
- package/src/components/document-upload-panel.examples.tsx +12 -0
- package/src/components/document-upload-panel.tsx +48 -0
- package/src/components/dropdown-menu.tsx +5 -3
- package/src/components/empty-panel-action.examples.tsx +7 -0
- package/src/components/empty-panel-action.tsx +8 -0
- package/src/components/field-appearance.ts +14 -0
- package/src/components/focus-field.tsx +3 -3
- package/src/components/icons.tsx +30 -0
- package/src/components/input.examples.tsx +8 -0
- package/src/components/input.tsx +13 -5
- package/src/components/media-asset-panel.examples.tsx +11 -0
- package/src/components/media-asset-panel.tsx +58 -0
- package/src/components/media-gallery.examples.tsx +12 -0
- package/src/components/media-gallery.tsx +53 -0
- package/src/components/media-settings-dialog.examples.tsx +21 -0
- package/src/components/media-settings-dialog.tsx +83 -0
- package/src/components/preview-info-card.examples.tsx +18 -0
- package/src/components/preview-info-card.tsx +27 -0
- package/src/components/reload-icon.examples.tsx +7 -0
- package/src/components/reload-icon.tsx +6 -0
- package/src/components/request-changes-modal.examples.tsx +26 -0
- package/src/components/request-changes-modal.tsx +36 -19
- package/src/components/rich-text-editor.tsx +1 -1
- package/src/components/select.examples.tsx +17 -0
- package/src/components/select.tsx +78 -22
- package/src/components/settings-dialog-surface.examples.tsx +44 -0
- package/src/components/settings-dialog-surface.tsx +240 -0
- package/src/components/side-panel.examples.tsx +80 -0
- package/src/components/side-panel.tsx +140 -0
- package/src/components/subtle.tsx +64 -8
- package/src/components/table.examples.tsx +9 -0
- package/src/components/table.tsx +29 -12
- package/src/components/template-catalog-card.examples.tsx +25 -0
- package/src/components/template-catalog-card.tsx +178 -0
- package/src/components/text.tsx +14 -1
- package/src/components/textarea.examples.tsx +8 -0
- package/src/components/textarea.tsx +16 -6
- package/src/components/tile.tsx +16 -17
- package/src/editor/block-editor.tsx +56 -14
- package/src/index.ts +20 -0
- package/src/layout/app-shell.tsx +13 -9
- package/src/layout/topbar.tsx +9 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.35",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
|
|
6
6
|
"repository": {
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"typecheck": "tsc --build"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@podoba/tokens": "^0.0.
|
|
32
|
-
"@podoba/tailwind": "^0.0.
|
|
31
|
+
"@podoba/tokens": "^0.0.35",
|
|
32
|
+
"@podoba/tailwind": "^0.0.35",
|
|
33
33
|
"react-aria-components": "1.18.0",
|
|
34
34
|
"class-variance-authority": "0.7.1",
|
|
35
35
|
"clsx": "2.1.1",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { AssetMasonryGrid } from './asset-masonry-grid'
|
|
2
|
+
|
|
3
|
+
const cards = [160, 300, 200, 240, 180, 260].map((height, index) => ({ id: String(index), aspectRatio: 1, content: <div className="rounded-lg bg-surface-muted p-4" style={{ height }}>Asset {index + 1}</div> }))
|
|
4
|
+
export const examples = {
|
|
5
|
+
default: () => <AssetMasonryGrid items={cards} label="Assets" />,
|
|
6
|
+
variants: () => <div className="max-w-md"><AssetMasonryGrid items={cards} label="Narrow assets" /></div>,
|
|
7
|
+
states: () => <AssetMasonryGrid items={[]} label="Empty assets" />,
|
|
8
|
+
}
|
|
9
|
+
export const meta = { category: 'Layout', description: 'Measured asset masonry with source responsive widths and shortest-column placement.' }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export function assetMasonryColumns(width: number) {
|
|
4
|
+
const gap = 12
|
|
5
|
+
if (width <= 0) return { count: 1, width: 200, gap }
|
|
6
|
+
const minimum = width < 768 ? 200 : width < 1024 ? 250 : 280
|
|
7
|
+
const count = Math.max(width < 768 ? 1 : width < 1024 ? 2 : 3, Math.floor((width + gap) / (minimum + gap)))
|
|
8
|
+
return { count, width: (width - (count - 1) * gap) / count, gap }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function assetMasonryPositions(heights: number[], count: number, gap = 12) {
|
|
12
|
+
const bottoms = Array.from({ length: Math.max(1, count) }, () => 0)
|
|
13
|
+
const positions = heights.map(height => {
|
|
14
|
+
const column = bottoms.indexOf(Math.min(...bottoms))
|
|
15
|
+
const top = bottoms[column] ?? 0
|
|
16
|
+
bottoms[column] = top + height + gap
|
|
17
|
+
return { column, top }
|
|
18
|
+
})
|
|
19
|
+
return { positions, height: heights.length ? Math.max(...bottoms) - gap : 0 }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Source shortest-column order, measuring children so metadata cannot overlap. */
|
|
23
|
+
export function AssetMasonryGrid({ items, label }: {
|
|
24
|
+
items: { id: string; aspectRatio: number; content: ReactNode }[]
|
|
25
|
+
label: string
|
|
26
|
+
}) {
|
|
27
|
+
const container = useRef<HTMLUListElement>(null)
|
|
28
|
+
const [width, setWidth] = useState(0)
|
|
29
|
+
const [measured, setMeasured] = useState<Record<string, { width: number; height: number }>>({})
|
|
30
|
+
const columns = assetMasonryColumns(width)
|
|
31
|
+
// Callers build `items` inline, so depending on the ARRAY identity tore down the
|
|
32
|
+
// ResizeObserver, rebuilt it and re-observed every child on each parent render.
|
|
33
|
+
// The observer only cares about which children exist — key on the id list.
|
|
34
|
+
const itemsKey = items.map(item => item.id).join('\u0000')
|
|
35
|
+
useLayoutEffect(() => {
|
|
36
|
+
const node = container.current
|
|
37
|
+
if (!node) return
|
|
38
|
+
const measure = () => {
|
|
39
|
+
setWidth(node.clientWidth)
|
|
40
|
+
const next: typeof measured = {}
|
|
41
|
+
for (const child of node.children) {
|
|
42
|
+
if (!(child instanceof HTMLElement) || !child.dataset.masonryId) continue
|
|
43
|
+
next[child.dataset.masonryId] = { width: child.offsetWidth, height: child.offsetHeight }
|
|
44
|
+
}
|
|
45
|
+
setMeasured(previous => JSON.stringify(previous) === JSON.stringify(next) ? previous : next)
|
|
46
|
+
}
|
|
47
|
+
measure()
|
|
48
|
+
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure)
|
|
49
|
+
observer?.observe(node)
|
|
50
|
+
for (const child of node.children) observer?.observe(child)
|
|
51
|
+
return () => observer?.disconnect()
|
|
52
|
+
}, [itemsKey, columns.width])
|
|
53
|
+
const heights = items.map(item => {
|
|
54
|
+
const size = measured[item.id]
|
|
55
|
+
if (size && Math.abs(size.width - columns.width) < 1 && size.height > 0) return size.height
|
|
56
|
+
return Math.min(800, Math.max(150, columns.width / (item.aspectRatio > 0 ? item.aspectRatio : 1)))
|
|
57
|
+
})
|
|
58
|
+
const layout = assetMasonryPositions(heights, columns.count, columns.gap)
|
|
59
|
+
return <ul ref={container} aria-label={label} data-testid="asset-grid" className="relative m-0 w-full list-none p-0" style={{ height: layout.height }}>
|
|
60
|
+
{items.map((item, index) => <li key={item.id} data-masonry-id={item.id} className="absolute" style={{ width: columns.width, insetInlineStart: (layout.positions[index]?.column ?? 0) * (columns.width + columns.gap), top: layout.positions[index]?.top ?? 0 }}>{item.content}</li>)}
|
|
61
|
+
</ul>
|
|
62
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { AssetSelectionSurface, AssetSelectionGrid, AssetSelectionEmpty, AssetLibraryPreview } from './asset-selection-surface'
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
import { SectionTabs } from './section-tabs'
|
|
5
|
+
|
|
6
|
+
function Example({ linked = false, pending = false, error = false }) {
|
|
7
|
+
const [open, setOpen] = useState(false)
|
|
8
|
+
const [selectedId, setSelectedId] = useState<string | null>('landscape')
|
|
9
|
+
return <>
|
|
10
|
+
<Button onPress={() => setOpen(true)}>Open asset selection</Button>
|
|
11
|
+
<AssetSelectionSurface isOpen={open} onOpenChange={setOpen} closeLabel="Close" isPending={pending}
|
|
12
|
+
title={linked ? <>Review linked assets <span className="text-fg-subtle">and add more</span><br />to this <span className="text-fg-subtle">project.</span></> : <>Choose assets <span className="text-fg-subtle">from storage</span><br />and add them to <span className="text-fg-subtle">this project.</span></>}
|
|
13
|
+
filters={<SectionTabs active="all" onChange={() => {}} tabs={[{key:'all',label:'All'},{key:'print',label:'Print'}]} />}
|
|
14
|
+
error={error ? 'The selected asset could not be added. Please try again.' : undefined}
|
|
15
|
+
footer={<><Button variant="secondary" isDisabled={pending} onPress={() => setOpen(false)}>Close</Button><Button isDisabled={pending} isPending={pending}>{pending ? 'Adding…' : linked ? 'Add another asset' : 'Add selected asset'}</Button></>}
|
|
16
|
+
>
|
|
17
|
+
{linked ? <AssetSelectionGrid selectedId={selectedId} onSelect={setSelectedId} onContextMenu={() => {}} items={[
|
|
18
|
+
{ id: 'landscape', label: 'Landscape sample', tags: ['Social', 'Campaign'], width: 600, height: 400, preview: <div className="h-full bg-surface-muted" /> },
|
|
19
|
+
{ id: 'portrait', label: 'Portrait sample', tags: ['Print'], width: 400, height: 600, preview: <div className="h-full bg-surface-card" /> },
|
|
20
|
+
]} /> : <AssetSelectionEmpty title="No linked assets yet." description="Add the first asset from storage to start production." />}
|
|
21
|
+
</AssetSelectionSurface>
|
|
22
|
+
</>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const examples = {
|
|
26
|
+
default: () => <Example />,
|
|
27
|
+
variants: () => <Example linked />,
|
|
28
|
+
states: () => <><Example pending /><Example error /></>,
|
|
29
|
+
libraryEmpty: () => <><AssetSelectionEmpty variant="library" title="No assets found" /><AssetSelectionEmpty variant="library" title="No assets found" description="Try removing filters to see more assets" /></>,
|
|
30
|
+
libraryPreviews: () => <div className="grid max-w-3xl grid-cols-3 gap-3">{[1, 1.5, 0.75].map((ratio, index) => <AssetLibraryPreview key={ratio} label={`Preview ${index + 1}`} selected={index === 1} aspectRatio={ratio} tags={index ? ['Campaign', 'Print', 'Approved', 'Archive'] : []} onPress={() => {}}><div className="h-full min-h-[150px] bg-surface-muted" /></AssetLibraryPreview>)}</div>,
|
|
31
|
+
}
|
|
32
|
+
export const meta = { category: 'Composite', description: 'Scrollable asset selection with pinned filters and confirmation controls.' }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactNode, type RefObject, type MouseEventHandler } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
4
|
+
import { ModalDialog, ModalOverlay, ModalSurface } from './dialog'
|
|
5
|
+
import { DisplayHeading } from './text'
|
|
6
|
+
import { uic } from '../utils/uic'
|
|
7
|
+
import { assetMasonryColumns, assetMasonryPositions } from './asset-masonry-grid'
|
|
8
|
+
|
|
9
|
+
const Body = uic('div', {
|
|
10
|
+
displayName: 'AssetSelectionBody',
|
|
11
|
+
baseClass: 'min-h-0 flex-1 overflow-y-auto',
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const BrowsableAsset = uic('div', {
|
|
15
|
+
displayName: 'BrowsableAsset',
|
|
16
|
+
baseClass: 'outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
type AssetMenuEvent = Pick<React.MouseEvent, 'preventDefault' | 'stopPropagation' | 'clientX' | 'clientY'> & {
|
|
20
|
+
currentTarget?: EventTarget | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AssetSelectionSurfaceProps {
|
|
24
|
+
isOpen: boolean
|
|
25
|
+
onOpenChange: (open: boolean) => void
|
|
26
|
+
isPending?: boolean
|
|
27
|
+
closeLabel: string
|
|
28
|
+
title: ReactNode
|
|
29
|
+
filters?: ReactNode
|
|
30
|
+
children: ReactNode
|
|
31
|
+
footer: ReactNode
|
|
32
|
+
error?: ReactNode
|
|
33
|
+
overlays?: ReactNode
|
|
34
|
+
bodyRef?: RefObject<HTMLDivElement | null>
|
|
35
|
+
onContextMenuCapture?: MouseEventHandler<HTMLDivElement>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Linked-assets/storage envelope. Domain state and attachment stay in the app. */
|
|
39
|
+
export function AssetSelectionSurface({
|
|
40
|
+
isOpen, onOpenChange, isPending = false, closeLabel, title, filters,
|
|
41
|
+
children, footer, error, overlays, bodyRef, onContextMenuCapture,
|
|
42
|
+
}: AssetSelectionSurfaceProps) {
|
|
43
|
+
const titleId = useId()
|
|
44
|
+
const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia('(max-width: 900px)').matches)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const media = window.matchMedia('(max-width: 900px)')
|
|
47
|
+
const change = () => setCompact(media.matches)
|
|
48
|
+
change()
|
|
49
|
+
media.addEventListener('change', change)
|
|
50
|
+
return () => media.removeEventListener('change', change)
|
|
51
|
+
}, [])
|
|
52
|
+
const close = () => { if (!isPending) onOpenChange(false) }
|
|
53
|
+
return (
|
|
54
|
+
<ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
|
|
55
|
+
<ModalSurface className={`flex max-h-324 max-w-432 flex-col overflow-hidden p-0 ${compact ? 'h-23/25 w-24/25' : 'h-9/10 w-9/10'}`}>
|
|
56
|
+
<ModalDialog aria-labelledby={titleId} style={{ letterSpacing: 0 }} className={`relative flex min-h-0 flex-1 flex-col gap-8 outline-none ${compact ? 'px-5 py-6' : 'px-6 py-10'}`} onContextMenuCapture={onContextMenuCapture} onContextMenu={event => event.stopPropagation()}>
|
|
57
|
+
<header className="flex shrink-0 flex-col gap-3">
|
|
58
|
+
<DisplayHeading id={titleId} className="mb-4 pb-0.5" style={{ maxWidth: '28ch', letterSpacing: 0 }}>{title}</DisplayHeading>
|
|
59
|
+
<Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={close} className="absolute end-4 top-3.5 h-8 w-8 rounded-md p-0 text-fg-subtle">
|
|
60
|
+
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true"><path d="M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd" /></svg>
|
|
61
|
+
</Button>
|
|
62
|
+
{filters ? <div className="max-w-full pt-1">{filters}</div> : null}
|
|
63
|
+
</header>
|
|
64
|
+
<Body ref={bodyRef} style={{ scrollbarGutter: 'stable' }}>{children}</Body>
|
|
65
|
+
{error ? <div role="alert" className="shrink-0 rounded-md border border-danger bg-danger/10 px-4 py-3 text-small text-danger">{error}</div> : null}
|
|
66
|
+
<footer className="flex shrink-0 justify-end gap-2 pt-3 [&>button]:text-body [&>button]:leading-5 [&>button]:font-medium">{footer}</footer>
|
|
67
|
+
{overlays}
|
|
68
|
+
</ModalDialog>
|
|
69
|
+
</ModalSurface>
|
|
70
|
+
</ModalOverlay>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function AssetSelectionEmpty({ title, description, variant = 'selection' }: { title: string; description?: string; variant?: 'selection' | 'library' }) {
|
|
75
|
+
if (variant === 'library') return <div role="status" className="px-8 py-16 text-center text-fg-muted">
|
|
76
|
+
<p className="my-2">{title}</p>
|
|
77
|
+
{description ? <p className="my-2 text-small">{description}</p> : null}
|
|
78
|
+
</div>
|
|
79
|
+
return <div role="status" className="flex min-h-96 flex-col items-center justify-center text-center text-fg-muted">
|
|
80
|
+
<p className="m-0 text-panel-heading font-medium text-fg">{title}</p>
|
|
81
|
+
{description ? <p className="mt-1 text-small" style={{ maxWidth: '32ch' }}>{description}</p> : null}
|
|
82
|
+
</div>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Library preview: source accent selection and hover-only curated tags. */
|
|
86
|
+
export function AssetLibraryPreview({ label, selected, aspectRatio, tags, onPress, children }: {
|
|
87
|
+
label: string; selected: boolean; aspectRatio: number; tags: string[]
|
|
88
|
+
onPress: () => void; children: ReactNode
|
|
89
|
+
}) {
|
|
90
|
+
return <AriaButton aria-label={label} aria-pressed={selected} onPress={onPress}
|
|
91
|
+
style={{ aspectRatio }}
|
|
92
|
+
className={`group relative block min-h-[150px] max-h-[800px] w-full overflow-hidden rounded-lg border p-0 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-xs hover:border-border-muted active:translate-y-0 focus-visible:ring-2 focus-visible:ring-accent-blue focus-visible:ring-offset-2 motion-reduce:transition-none ${selected ? 'border-accent-blue bg-accent-blue/10' : 'border-border bg-surface-card'}`}>
|
|
93
|
+
{children}
|
|
94
|
+
{tags.length ? <span data-testid="asset-card-overlay" className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-wrap items-center gap-1 bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-250 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
|
|
95
|
+
{tags.slice(0, 3).map(tag => <span key={tag} className="ms-3 inline-flex h-6 items-center rounded-full bg-surface-muted px-2.5 text-label font-medium leading-5 text-(--color-neutral-600)">{tag}</span>)}
|
|
96
|
+
{tags.length > 3 ? <span className="text-label font-medium text-white/90">+{tags.length - 3}</span> : null}
|
|
97
|
+
</span> : null}
|
|
98
|
+
</AriaButton>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function AssetSelectionFilters({ items, onChange, disabled = false, autoFocus = false }: {
|
|
102
|
+
items: { id: string; label: string; selected: boolean }[]
|
|
103
|
+
onChange: (id: string) => void
|
|
104
|
+
disabled?: boolean; autoFocus?: boolean
|
|
105
|
+
}) {
|
|
106
|
+
return <div role="group" className="mb-4 flex min-h-9 w-full flex-wrap content-start items-center gap-2 lg:w-2/3">
|
|
107
|
+
{items.map((item, index) => <Button key={item.id} autoFocus={autoFocus && index === 0} variant="ghost" isDisabled={disabled} aria-pressed={item.selected}
|
|
108
|
+
onPress={() => onChange(item.id)} className={`h-7 rounded-sm px-nav-x py-1.5 text-compact font-normal ${item.selected ? 'bg-surface-muted text-fg' : 'text-fg-muted'}`}>{item.label}</Button>)}
|
|
109
|
+
</div>
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface AssetSelectionGridItem {
|
|
113
|
+
id: string
|
|
114
|
+
label: string
|
|
115
|
+
tags: string[]
|
|
116
|
+
width?: number
|
|
117
|
+
height?: number
|
|
118
|
+
preview: ReactNode
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Source masonry geometry: responsive columns, shortest-column placement, 12px gaps. */
|
|
122
|
+
export function AssetSelectionGrid({ items, selectedId, onSelect, onContextMenu }: {
|
|
123
|
+
items: AssetSelectionGridItem[]
|
|
124
|
+
selectedId: string | null
|
|
125
|
+
onSelect?: (id: string) => void
|
|
126
|
+
onContextMenu: (id: string, event: AssetMenuEvent) => void
|
|
127
|
+
}) {
|
|
128
|
+
const container = useRef<HTMLDivElement>(null)
|
|
129
|
+
const [width, setWidth] = useState(0)
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const element = container.current
|
|
132
|
+
if (!element) return
|
|
133
|
+
const measure = () => setWidth(element.clientWidth)
|
|
134
|
+
measure()
|
|
135
|
+
const observer = new ResizeObserver(measure)
|
|
136
|
+
observer.observe(element)
|
|
137
|
+
return () => observer.disconnect()
|
|
138
|
+
}, [])
|
|
139
|
+
// Shared with AssetMasonryGrid rather than restated: the two had already drifted.
|
|
140
|
+
// Only the HEIGHT rule is local — this grid gets explicit width/height per item
|
|
141
|
+
// (square fallback when either is missing), the other derives from aspectRatio.
|
|
142
|
+
const columns = assetMasonryColumns(width)
|
|
143
|
+
const columnWidth = columns.width
|
|
144
|
+
const heights = items.map(item =>
|
|
145
|
+
item.width && item.height
|
|
146
|
+
? Math.min(800, Math.max(150, columnWidth * item.height / item.width))
|
|
147
|
+
: columnWidth,
|
|
148
|
+
)
|
|
149
|
+
const layout = assetMasonryPositions(heights, columns.count, columns.gap)
|
|
150
|
+
const positioned = items.map((item, index) => ({
|
|
151
|
+
item,
|
|
152
|
+
top: layout.positions[index]?.top ?? 0,
|
|
153
|
+
left: (layout.positions[index]?.column ?? 0) * (columnWidth + columns.gap),
|
|
154
|
+
height: heights[index] ?? columnWidth,
|
|
155
|
+
}))
|
|
156
|
+
return <div ref={container} className="relative w-full" style={{ height: layout.height }}>
|
|
157
|
+
{positioned.map(({ item, top, left, height }) => {
|
|
158
|
+
const className = `group relative block h-full w-full overflow-hidden rounded-lg border bg-surface-card p-0 transition-transform duration-200 hover:-translate-y-0.5 active:translate-y-0 motion-reduce:transition-none ${onSelect ? 'hover:shadow-xs hover:border-border-muted' : ''} ${selectedId === item.id ? 'border-accent-blue' : 'border-border'}`
|
|
159
|
+
const contents = <>{item.preview}
|
|
160
|
+
{item.tags.length ? <span className="pointer-events-none absolute inset-x-0 bottom-0 flex flex-wrap items-center gap-1 bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-250 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
|
|
161
|
+
{item.tags.slice(0, 3).map(tag => <span key={tag} className="ms-3 inline-flex h-6 items-center rounded-full bg-surface-muted px-2.5 text-label font-medium leading-5 text-(--color-neutral-600)">{tag}</span>)}
|
|
162
|
+
{item.tags.length > 3 ? <span className="text-label font-medium text-white/90">+{item.tags.length - 3}</span> : null}
|
|
163
|
+
</span> : null}
|
|
164
|
+
</>
|
|
165
|
+
return <div key={item.id} data-cloud-asset-id={item.id}
|
|
166
|
+
onContextMenu={event => onContextMenu(item.id, event)} className="absolute" style={{ top, insetInlineStart: left, width: columnWidth, height }}>
|
|
167
|
+
<AssetCardKeyboard onContextMenu={event => onContextMenu(item.id, event)}>
|
|
168
|
+
{onSelect ? <Button variant="ghost" aria-label={item.label} aria-pressed={selectedId === item.id}
|
|
169
|
+
onPress={() => onSelect(item.id)} className={className}>{contents}</Button>
|
|
170
|
+
: <BrowsableAsset role="img" aria-label={item.label} tabIndex={0} aria-haspopup="menu" className={className}>{contents}</BrowsableAsset>}
|
|
171
|
+
</AssetCardKeyboard>
|
|
172
|
+
</div>})}
|
|
173
|
+
</div>
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Browsing is not selection. Keep keyboard access to the same context actions. */
|
|
177
|
+
function AssetCardKeyboard({ children, onContextMenu }: { children: ReactNode; onContextMenu: (event: AssetMenuEvent) => void }) {
|
|
178
|
+
return <div className="h-full w-full" onKeyDown={event => { if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) {
|
|
179
|
+
const rect = event.currentTarget.getBoundingClientRect()
|
|
180
|
+
event.preventDefault()
|
|
181
|
+
event.stopPropagation()
|
|
182
|
+
onContextMenu({ preventDefault: () => event.preventDefault(), stopPropagation: () => event.stopPropagation(), clientX: rect.left + 16, clientY: rect.top + 16, currentTarget: event.currentTarget })
|
|
183
|
+
} }}>{children}</div>
|
|
184
|
+
}
|