@podoba/react 0.0.34 → 0.0.36
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 +33 -0
- package/src/components/asset-selection-surface.tsx +184 -0
- package/src/components/brand-page-header.tsx +60 -29
- 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 +50 -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/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/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 +179 -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 +19 -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.36",
|
|
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.36",
|
|
32
|
+
"@podoba/tailwind": "^0.0.36",
|
|
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,33 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, useState } from 'react'
|
|
2
|
+
import { AssetSelectionSurface, AssetSelectionGrid, AssetSelectionEmpty, AssetLibraryPreview } from './asset-selection-surface'
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
import { Subtle } from './subtle'
|
|
5
|
+
import { SectionTabs } from './section-tabs'
|
|
6
|
+
|
|
7
|
+
function Example({ linked = false, pending = false, error = false }) {
|
|
8
|
+
const [open, setOpen] = useState(false)
|
|
9
|
+
const [selectedId, setSelectedId] = useState<string | null>('landscape')
|
|
10
|
+
return <>
|
|
11
|
+
<Button onPress={() => setOpen(true)}>Open asset selection</Button>
|
|
12
|
+
<AssetSelectionSurface isOpen={open} onOpenChange={setOpen} closeLabel="Close" isPending={pending}
|
|
13
|
+
title={linked ? <>Review linked assets <Subtle>and add more</Subtle><br />to this <Subtle>project.</Subtle></> : <>Choose assets <Subtle>from storage</Subtle><br />and add them to <Subtle>this project.</Subtle></>}
|
|
14
|
+
filters={<SectionTabs active="all" onChange={() => {}} tabs={[{key:'all',label:'All'},{key:'print',label:'Print'}]} />}
|
|
15
|
+
error={error ? 'The selected asset could not be added. Please try again.' : undefined}
|
|
16
|
+
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></>}
|
|
17
|
+
>
|
|
18
|
+
{linked ? <AssetSelectionGrid selectedId={selectedId} onSelect={setSelectedId} onContextMenu={() => {}} items={[
|
|
19
|
+
{ id: 'landscape', label: 'Landscape sample', tags: ['Social', 'Campaign'], width: 600, height: 400, preview: <div className="h-full bg-surface-muted" /> },
|
|
20
|
+
{ id: 'portrait', label: 'Portrait sample', tags: ['Print'], width: 400, height: 600, preview: <div className="h-full bg-surface-card" /> },
|
|
21
|
+
]} /> : <AssetSelectionEmpty title="No linked assets yet." description="Add the first asset from storage to start production." />}
|
|
22
|
+
</AssetSelectionSurface>
|
|
23
|
+
</>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const examples = {
|
|
27
|
+
default: () => <Example />,
|
|
28
|
+
variants: () => <Example linked />,
|
|
29
|
+
states: () => <><Example pending /><Example error /></>,
|
|
30
|
+
libraryEmpty: () => <><AssetSelectionEmpty variant="library" title="No assets found" /><AssetSelectionEmpty variant="library" title="No assets found" description="Try removing filters to see more assets" /></>,
|
|
31
|
+
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>,
|
|
32
|
+
}
|
|
33
|
+
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
|
+
}
|
|
@@ -33,17 +33,22 @@ export type BrandPageHeaderCtaRenderProps = {
|
|
|
33
33
|
toggle: () => void
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
export type BrandPageHeaderVariant = 'dashboard' | 'default' | 'navigation'
|
|
37
|
+
|
|
36
38
|
export type BrandPageHeaderProps = {
|
|
37
39
|
/** The large greeting / page-title slot (e.g. "Good morning Jonas 👋"). */
|
|
38
40
|
greeting: ReactNode
|
|
41
|
+
/** Header geometry preset. Dashboard uses the source 20px desktop gutter. */
|
|
42
|
+
variant?: BrandPageHeaderVariant
|
|
39
43
|
/** Semantic heading level. Use 2 or 3 when the header is nested below a page title. */
|
|
40
44
|
headingLevel?: 1 | 2 | 3
|
|
41
45
|
/**
|
|
42
46
|
* gs-style "title to go back": a muted, clickable PARENT link rendered as the
|
|
43
47
|
* line ABOVE the title (so the title reads "Parent ⏎ Current"). Pass a real
|
|
44
48
|
* router `<Link>` so it's an anchor (cmd/middle-click, deep-link-safe), exactly
|
|
45
|
-
* like gs's `parentLink`.
|
|
46
|
-
*
|
|
49
|
+
* like gs's `parentLink`. Rendered in `fg-muted` with a hover→fg affordance —
|
|
50
|
+
* the source's decorative `fg-subtle` is 2.10:1 on `surface`, and this row is a
|
|
51
|
+
* navigation link, not ornament (#25).
|
|
47
52
|
*/
|
|
48
53
|
parentLink?: ReactNode
|
|
49
54
|
/** Optional breadcrumb trail rendered above the greeting. */
|
|
@@ -76,6 +81,7 @@ export type BrandPageHeaderProps = {
|
|
|
76
81
|
|
|
77
82
|
export function BrandPageHeader({
|
|
78
83
|
greeting,
|
|
84
|
+
variant = 'default',
|
|
79
85
|
headingLevel = 1,
|
|
80
86
|
parentLink,
|
|
81
87
|
breadcrumbs,
|
|
@@ -115,6 +121,11 @@ export function BrandPageHeader({
|
|
|
115
121
|
toggle: () => setExpanded(!expanded),
|
|
116
122
|
})
|
|
117
123
|
: cta
|
|
124
|
+
// Render the WHOLE trail the caller passed. Collapsing it to `breadcrumbs[-2]`
|
|
125
|
+
// silently dropped every other crumb (a three-crumb trail rendered one) and
|
|
126
|
+
// dropped a single crumb entirely unless it happened to be pressable. A caller
|
|
127
|
+
// that wants only the parent passes only the parent.
|
|
128
|
+
const visibleBreadcrumbs = breadcrumbs?.filter(Boolean) ?? []
|
|
118
129
|
|
|
119
130
|
useEffect(() => {
|
|
120
131
|
if (!expanded || !hasExpandable || typeof window === 'undefined') return
|
|
@@ -236,25 +247,41 @@ export function BrandPageHeader({
|
|
|
236
247
|
return (
|
|
237
248
|
<div
|
|
238
249
|
className={[
|
|
239
|
-
'mb-6 w-
|
|
240
|
-
sticky
|
|
250
|
+
'mb-6 box-border w-auto min-w-0',
|
|
251
|
+
sticky
|
|
252
|
+
? [
|
|
253
|
+
'relative -mx-3 px-3',
|
|
254
|
+
'md:sticky md:top-0 md:z-30 md:-mx-6 md:bg-surface md:px-6 md:pb-6 md:mb-0',
|
|
255
|
+
expanded ? 'md:pb-0' : '',
|
|
256
|
+
].join(' ')
|
|
257
|
+
: '',
|
|
241
258
|
className,
|
|
242
259
|
]
|
|
243
260
|
.filter(Boolean)
|
|
244
261
|
.join(' ')}
|
|
245
262
|
>
|
|
246
|
-
<div
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
<div
|
|
264
|
+
className={`grid grid-cols-1 grid-rows-[auto_auto] items-start gap-y-2 ${
|
|
265
|
+
variant === 'navigation' ? 'md:grid-cols-2' : 'md:grid-cols-[2fr_1fr]'
|
|
266
|
+
} ${variant === 'dashboard' ? 'md:gap-x-5' : 'md:gap-x-4'}`}
|
|
267
|
+
>
|
|
268
|
+
<div className="col-start-1 row-[1/-1] flex w-full min-w-0 flex-col gap-0">
|
|
269
|
+
{/* EXACTLY ONE Breadcrumb landmark. Rendering the trail and `parentLink`
|
|
270
|
+
as siblings produced two <nav aria-label="Breadcrumb"> in one header —
|
|
271
|
+
duplicate landmarks sharing an accessible name. `breadcrumbs` is the
|
|
272
|
+
richer API, so it wins when a caller passes both.
|
|
273
|
+
Ink is `fg-muted` (5.98:1), NOT `fg-subtle` (2.10:1 on surface): a
|
|
274
|
+
crumb is a navigation link, which #25 keeps on the readable token. */}
|
|
275
|
+
{visibleBreadcrumbs.length > 0 ? (
|
|
276
|
+
<nav aria-label="Breadcrumb" className="m-0 flex w-full min-w-0 flex-wrap items-center gap-1 text-display-large font-medium tracking-wide text-fg-muted">
|
|
277
|
+
{visibleBreadcrumbs.map((crumb, i) => (
|
|
251
278
|
<span key={i} className="inline-flex items-center gap-1">
|
|
252
279
|
{i > 0 ? <span aria-hidden="true">/</span> : null}
|
|
253
280
|
{crumb.onPress ? (
|
|
254
281
|
<Button
|
|
255
282
|
variant="ghost"
|
|
256
283
|
onPress={crumb.onPress}
|
|
257
|
-
className="h-auto rounded-sm p-0 text-
|
|
284
|
+
className="h-auto rounded-sm p-0 text-display-large font-medium tracking-wide text-fg-muted data-[hovered]:bg-transparent data-[hovered]:text-fg"
|
|
258
285
|
>
|
|
259
286
|
{crumb.label}
|
|
260
287
|
</Button>
|
|
@@ -264,23 +291,27 @@ export function BrandPageHeader({
|
|
|
264
291
|
</span>
|
|
265
292
|
))}
|
|
266
293
|
</nav>
|
|
294
|
+
) : parentLink ? (
|
|
295
|
+
// gs "title to go back". MERGE NOTE: the breadcrumb is a NAV LANDMARK,
|
|
296
|
+
// not part of the <h1> — a page title should not contain its own parent
|
|
297
|
+
// link. #25 still applies to the colour: the source's decorative grey is
|
|
298
|
+
// 2.10:1 on `surface`, and this row is a navigation LINK, so it keeps the
|
|
299
|
+
// readable `fg-muted` (5.98:1) rather than `fg-subtle`.
|
|
300
|
+
<nav
|
|
301
|
+
aria-label="Breadcrumb"
|
|
302
|
+
className="h-8 text-display-large font-medium leading-8 tracking-wide text-fg-muted transition-colors [&_a:hover]:text-fg [&_a]:block [&_a]:h-8 [&_a]:text-fg-muted [&_a]:no-underline [&_a]:outline-none [&_a:focus-visible]:underline"
|
|
303
|
+
>
|
|
304
|
+
{parentLink}
|
|
305
|
+
</nav>
|
|
267
306
|
) : null}
|
|
268
|
-
<
|
|
269
|
-
<
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{parentLink}
|
|
277
|
-
</span>
|
|
278
|
-
<br />
|
|
279
|
-
</>
|
|
280
|
-
) : null}
|
|
281
|
-
{greeting}
|
|
282
|
-
</HeadingTag>
|
|
283
|
-
</DisplayHeading>
|
|
307
|
+
<div className="flex w-full min-w-0 items-start overflow-visible">
|
|
308
|
+
<DisplayHeading
|
|
309
|
+
asChild
|
|
310
|
+
className="m-0 min-w-0 flex-1 overflow-visible break-words pb-0.5 tracking-[0]"
|
|
311
|
+
>
|
|
312
|
+
<HeadingTag>{greeting}</HeadingTag>
|
|
313
|
+
</DisplayHeading>
|
|
314
|
+
</div>
|
|
284
315
|
</div>
|
|
285
316
|
|
|
286
317
|
{cta ? (
|
|
@@ -289,8 +320,8 @@ export function BrandPageHeader({
|
|
|
289
320
|
className={
|
|
290
321
|
[
|
|
291
322
|
mobileCtaDocked
|
|
292
|
-
? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:z-auto md:h-full md:p-0'
|
|
293
|
-
: 'h-full
|
|
323
|
+
? 'fixed inset-x-0 bottom-0 z-40 min-w-0 px-3 pb-mobile-cta-bottom md:static md:inset-auto md:col-start-2 md:row-[1/-1] md:z-auto md:flex md:h-full md:max-w-full md:items-stretch md:justify-end md:self-stretch md:p-0'
|
|
324
|
+
: 'min-w-0 md:col-start-2 md:row-[1/-1] md:flex md:h-full md:max-w-full md:items-stretch md:justify-end md:self-stretch',
|
|
294
325
|
expanded ? 'hidden' : '',
|
|
295
326
|
].join(' ')
|
|
296
327
|
}
|
|
@@ -298,7 +329,7 @@ export function BrandPageHeader({
|
|
|
298
329
|
{renderedCta}
|
|
299
330
|
</div>
|
|
300
331
|
) : ctaLabel ? (
|
|
301
|
-
<div className="shrink-0">
|
|
332
|
+
<div className="shrink-0 md:col-start-2 md:row-[1/-1] md:flex md:items-start md:justify-end">
|
|
302
333
|
{hasExpandable ? (
|
|
303
334
|
<Button
|
|
304
335
|
onPress={() => setExpanded(!expanded)}
|
|
@@ -15,15 +15,29 @@ export const Button = uic(RACButton, {
|
|
|
15
15
|
baseClass:
|
|
16
16
|
'inline-flex items-center justify-center gap-2 rounded-full text-compact leading-4 transition-all duration-200 ease-in-out ' +
|
|
17
17
|
'outline-none data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Inactive states (disabled / pending) soften the LABEL ONLY and leave every
|
|
19
|
+
// variant on its own surface. Two reasons:
|
|
20
|
+
// - fading the whole control (`opacity-*`) blends the label into the parent
|
|
21
|
+
// surface and drops it below WCAG AA;
|
|
22
|
+
// - repainting every variant onto one muted surface makes a disabled button
|
|
23
|
+
// indistinguishable from a hovered one (`secondary`/`ghost` both hover to
|
|
24
|
+
// `surface-muted`) and strips the brand fill off a submitting primary CTA.
|
|
25
|
+
// `text-fg/60` is measured over surface / surface-card / surface-muted in both
|
|
26
|
+
// themes by packages/tokens `contrast.test.ts`; variants whose ink or surface
|
|
27
|
+
// differs override it below.
|
|
28
|
+
'data-[disabled]:text-fg/60 data-[disabled]:pointer-events-none ' +
|
|
29
|
+
'data-[pending]:text-fg/60 data-[pending]:cursor-progress',
|
|
20
30
|
variants: {
|
|
21
31
|
variant: {
|
|
22
32
|
// primary (gs `_primary`): base brand-primary (#0d0d0d light / brand-green on
|
|
23
33
|
// dark) → hover neutral-600 (#333333) + `0 0 5px rgba(0,0,0,.5)` shadow →
|
|
24
34
|
// active back to brand-primary (no shadow).
|
|
35
|
+
// Keeps the brand fill while inactive — a submitting CTA has to stay the
|
|
36
|
+
// primary action next to a disabled `secondary` Cancel — so it softens the
|
|
37
|
+
// INVERTED ink instead of the base one.
|
|
25
38
|
primary:
|
|
26
|
-
'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none'
|
|
39
|
+
'bg-brand-primary text-fg-inverted hover:bg-neutral-600 hover:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:bg-brand-primary data-[pressed]:shadow-none ' +
|
|
40
|
+
'data-[disabled]:text-fg-inverted/70 data-[pending]:text-fg-inverted/70',
|
|
27
41
|
// secondary: #f7f6f2 (surface-card) + 1px #eceae1 (border); hover bg #eceae1
|
|
28
42
|
// (surface-muted) + border #aba89c (fg-subtle); active bg #eceae1.
|
|
29
43
|
secondary:
|
|
@@ -31,7 +45,16 @@ export const Button = uic(RACButton, {
|
|
|
31
45
|
// ghost: transparent → hover/active #eceae1 (surface-muted).
|
|
32
46
|
ghost: 'bg-transparent text-fg hover:bg-surface-muted data-[pressed]:bg-surface-muted',
|
|
33
47
|
// destructive: #ef4444 → danger token; hover/active dim via opacity (gs parity).
|
|
34
|
-
|
|
48
|
+
// Enabled white-on-danger is only ~4.8:1, so softening that ink would fail AA
|
|
49
|
+
// — an inactive destructive drops the red (it no longer affords the action)
|
|
50
|
+
// and falls back to the base muted ink on `surface-muted`. That pill is
|
|
51
|
+
// invisible on a `surface-muted` panel, so it outlines itself with an INSET
|
|
52
|
+
// shadow — a real border would resize the enabled button, and a `ring-*`
|
|
53
|
+
// would fight the focus ring a pending (still focusable) button can show.
|
|
54
|
+
destructive:
|
|
55
|
+
'bg-danger text-danger-fg hover:opacity-90 data-[pressed]:opacity-80 ' +
|
|
56
|
+
'data-[disabled]:bg-surface-muted data-[disabled]:shadow-[inset_0_0_0_1px_var(--color-border-muted)] ' +
|
|
57
|
+
'data-[pending]:bg-surface-muted data-[pending]:shadow-[inset_0_0_0_1px_var(--color-border-muted)]',
|
|
35
58
|
},
|
|
36
59
|
size: {
|
|
37
60
|
// gs sizes: PADDING ONLY (spacing-2/4, 3/6, 4/8) — text is a fixed 13px/400
|
|
@@ -91,7 +91,7 @@ export const ComboBox = <T extends object>({
|
|
|
91
91
|
<SearchField aria-label={typeof label === 'string' ? label : 'Search'}>
|
|
92
92
|
<RACInput
|
|
93
93
|
placeholder={placeholder}
|
|
94
|
-
className="w-full border-0 bg-transparent p-0 text-display font-medium text-fg outline-none placeholder:text-fg-
|
|
94
|
+
className="w-full border-0 bg-transparent p-0 text-display font-medium text-fg outline-none placeholder:text-fg-muted"
|
|
95
95
|
/>
|
|
96
96
|
</SearchField>
|
|
97
97
|
<ListBox
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { CompactActionButton } from './compact-action-button'
|
|
2
|
+
export const examples = {
|
|
3
|
+
default: () => <CompactActionButton>Create Task</CompactActionButton>,
|
|
4
|
+
variants: () => <><CompactActionButton variant="linear">Cancel</CompactActionButton><CompactActionButton>Mark done</CompactActionButton></>,
|
|
5
|
+
states: () => <><CompactActionButton isDisabled>Create Task</CompactActionButton><CompactActionButton variant="linear" isDisabled>Cancel</CompactActionButton><CompactActionButton isPending>Marking...</CompactActionButton></>,
|
|
6
|
+
}
|
|
7
|
+
export const meta = { category: 'Primitives', description: 'Compact source Manager actions with primary/linear emphasis, keyboard focus and disabled states.' }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
2
|
+
import { uic } from '../utils/uic'
|
|
3
|
+
|
|
4
|
+
export const COMPACT_ACTION_VARIANTS = ['primary', 'linear'] as const
|
|
5
|
+
export type CompactActionVariant = (typeof COMPACT_ACTION_VARIANTS)[number]
|
|
6
|
+
|
|
7
|
+
/** Compact Manager action; separate from the larger primitive dialog actions.
|
|
8
|
+
* Same semantic palette as Button / DialogActionButton. The hover shadow is the
|
|
9
|
+
* literal Button already uses — no shadow token covers a button lift, and the two
|
|
10
|
+
* must match. */
|
|
11
|
+
export const CompactActionButton = uic(AriaButton, {
|
|
12
|
+
displayName: 'CompactActionButton',
|
|
13
|
+
baseClass: 'inline-flex items-center justify-center rounded-full px-3.5 py-1 text-compact font-normal leading-[normal] cursor-pointer transition-all duration-120 ease-[ease] motion-reduce:transition-none outline-none ' +
|
|
14
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
15
|
+
'data-[hovered]:shadow-[0_0_5px_0_rgba(0,0,0,0.5)] data-[pressed]:shadow-none data-[disabled]:shadow-none data-[disabled]:cursor-not-allowed',
|
|
16
|
+
variants: { variant: {
|
|
17
|
+
primary: 'border-0 bg-brand-primary text-fg-inverted data-[hovered]:bg-neutral-600 data-[pressed]:bg-brand-primary data-[disabled]:bg-fg-subtle',
|
|
18
|
+
linear: 'border border-brand-primary bg-transparent text-brand-primary data-[hovered]:bg-surface-muted data-[pressed]:bg-transparent data-[disabled]:border-fg-subtle data-[disabled]:text-fg-subtle',
|
|
19
|
+
} },
|
|
20
|
+
defaultVariants: { variant: 'primary' },
|
|
21
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { CompactSettingsDialog } from './compact-settings-dialog'
|
|
4
|
+
|
|
5
|
+
function Example({ pending = false }: { pending?: boolean }) {
|
|
6
|
+
const [open, setOpen] = useState(false)
|
|
7
|
+
return <>
|
|
8
|
+
<Button onPress={() => setOpen(true)}>Manage members</Button>
|
|
9
|
+
<CompactSettingsDialog isOpen={open} onOpenChange={setOpen} isPending={pending} title="Manage project members" description="Add or remove collaborators for this project." closeLabel="Close">
|
|
10
|
+
<p>No members in this category.</p>
|
|
11
|
+
</CompactSettingsDialog>
|
|
12
|
+
</>
|
|
13
|
+
}
|
|
14
|
+
export const examples = { default: () => <Example />, pending: () => <Example pending /> }
|
|
15
|
+
export const meta = { category: 'Composite', description: 'Intrinsic-height form modal with stacked title and description, responsive width, and pending dismissal lock.' }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useId, type ReactNode } from 'react'
|
|
2
|
+
import { Button } from './button'
|
|
3
|
+
import { ModalDialog, ModalOverlay, ModalSurface } from './dialog'
|
|
4
|
+
import { Heading } from './text'
|
|
5
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
6
|
+
import { uic } from '../utils/uic'
|
|
7
|
+
|
|
8
|
+
export const MemberSelectionGrid = uic('ul', { displayName: 'MemberSelectionGrid', baseClass: 'm-0 mt-8 grid list-none grid-cols-1 gap-3 p-0 max-[767px]:gap-2 min-[640px]:grid-cols-2 min-[768px]:grid-cols-3 min-[1024px]:grid-cols-4' })
|
|
9
|
+
export const TeamSelectionGrid = uic('ul', { displayName: 'TeamSelectionGrid', baseClass: 'm-0 mt-8 grid list-none grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-4 p-0' })
|
|
10
|
+
export const MemberSelectionCard = uic(AriaButton, {
|
|
11
|
+
displayName: 'MemberSelectionCard',
|
|
12
|
+
baseClass: 'flex w-full items-center gap-3 rounded-lg border border-border bg-surface-card p-4 text-start text-fg outline-none transition-all duration-200 data-[hovered]:-translate-y-0.5 data-[hovered]:border-brand-primary data-[hovered]:shadow-[0_4px_12px_rgba(0,0,0,0.1)] aria-pressed:border-brand-primary aria-pressed:bg-surface-muted data-[disabled]:cursor-default data-[disabled]:opacity-60 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
13
|
+
})
|
|
14
|
+
export const TeamSelectionCard = uic(AriaButton, {
|
|
15
|
+
displayName: 'TeamSelectionCard',
|
|
16
|
+
baseClass: 'block w-full rounded-md border-2 border-border bg-transparent p-4 text-start text-fg outline-none transition-all duration-200 data-[hovered]:border-brand-primary aria-pressed:border-brand-primary aria-pressed:bg-surface-muted data-[disabled]:cursor-default data-[disabled]:opacity-60 data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring',
|
|
17
|
+
})
|
|
18
|
+
export const MemberSelectionInfo = uic('span', { displayName: 'MemberSelectionInfo', baseClass: 'min-w-0 flex-1' })
|
|
19
|
+
export const MemberSelectionName = uic('span', { displayName: 'MemberSelectionName', baseClass: 'mb-1 block text-heading5 font-medium text-fg' })
|
|
20
|
+
// #25: an email address and a team's meta line are CONTENT — `fg-muted` (5.98:1 on
|
|
21
|
+
// surface, 5.53:1 on surface-card), never the ornamental `fg-subtle` (2.10 / 1.94:1).
|
|
22
|
+
export const MemberSelectionEmail = uic('span', { displayName: 'MemberSelectionEmail', baseClass: 'block text-compact font-normal text-fg-muted' })
|
|
23
|
+
export const TeamSelectionName = uic('span', { displayName: 'TeamSelectionName', baseClass: 'mb-1 block font-medium text-fg' })
|
|
24
|
+
export const TeamSelectionMeta = uic('span', { displayName: 'TeamSelectionMeta', baseClass: 'block text-small font-normal text-fg-muted' })
|
|
25
|
+
|
|
26
|
+
/** Intrinsic-height form surface. The tall settings and catalog surfaces remain separate. */
|
|
27
|
+
export function CompactSettingsDialog({ isOpen, onOpenChange, isPending = false, title, description, closeLabel, children }: {
|
|
28
|
+
isOpen: boolean
|
|
29
|
+
onOpenChange: (open: boolean) => void
|
|
30
|
+
isPending?: boolean
|
|
31
|
+
title: string
|
|
32
|
+
description: string
|
|
33
|
+
closeLabel: string
|
|
34
|
+
children: ReactNode
|
|
35
|
+
}) {
|
|
36
|
+
const titleId = useId()
|
|
37
|
+
const descriptionId = useId()
|
|
38
|
+
return <ModalOverlay className="p-0" isOpen={isOpen} onOpenChange={open => { if (!isPending) onOpenChange(open) }} isDismissable={!isPending} isKeyboardDismissDisabled={isPending}>
|
|
39
|
+
<ModalSurface className="relative w-[90vw] max-w-180 max-h-[85vh] overflow-y-auto p-5 min-[768px]:max-[1023px]:w-[85vw] max-[767px]:w-[calc(100vw-6*var(--spacing))] max-[767px]:max-w-[calc(100vw-6*var(--spacing))] max-[767px]:max-h-[calc(100vh-6*var(--spacing))]">
|
|
40
|
+
<ModalDialog aria-labelledby={titleId} aria-describedby={descriptionId} className="outline-none">
|
|
41
|
+
<Heading id={titleId} level="1" className="m-0 mb-4 max-w-5/6 tracking-normal">{title}</Heading>
|
|
42
|
+
<p id={descriptionId} className="m-0 mb-6 max-w-5/6 text-small font-normal text-fg-workflow-muted">{description}</p>
|
|
43
|
+
<div className="mt-4">{children}</div>
|
|
44
|
+
<Button variant="ghost" aria-label={closeLabel} isDisabled={isPending} onPress={() => onOpenChange(false)} className="absolute end-4 top-3.5 h-8 w-8 rounded-md p-0 text-fg-subtle max-[767px]:end-3">
|
|
45
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18" /></svg>
|
|
46
|
+
</Button>
|
|
47
|
+
</ModalDialog>
|
|
48
|
+
</ModalSurface>
|
|
49
|
+
</ModalOverlay>
|
|
50
|
+
}
|