@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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
|
|
3
|
+
import { Button } from './button'
|
|
4
|
+
import { SidePanel } from './side-panel'
|
|
5
|
+
|
|
6
|
+
function DefaultExample(): React.ReactNode {
|
|
7
|
+
const [open, setOpen] = useState(false)
|
|
8
|
+
return (
|
|
9
|
+
<>
|
|
10
|
+
<Button onPress={() => setOpen(true)}>Open panel</Button>
|
|
11
|
+
<SidePanel
|
|
12
|
+
isOpen={open}
|
|
13
|
+
onOpenChange={setOpen}
|
|
14
|
+
title="Assistant"
|
|
15
|
+
description="Acme Robotics"
|
|
16
|
+
closeLabel="Close assistant"
|
|
17
|
+
footer={<Button className="w-full">Send</Button>}
|
|
18
|
+
>
|
|
19
|
+
<p className="text-small text-fg-muted">Ask a question about the current Brand.</p>
|
|
20
|
+
</SidePanel>
|
|
21
|
+
</>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function SizeExample({ size }: { size: 'sm' | 'md' | 'lg' }): React.ReactNode {
|
|
26
|
+
const [open, setOpen] = useState(false)
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
<Button variant="secondary" onPress={() => setOpen(true)}>
|
|
30
|
+
{size.toUpperCase()}
|
|
31
|
+
</Button>
|
|
32
|
+
<SidePanel
|
|
33
|
+
isOpen={open}
|
|
34
|
+
onOpenChange={setOpen}
|
|
35
|
+
title={`${size.toUpperCase()} panel`}
|
|
36
|
+
closeLabel="Close panel"
|
|
37
|
+
size={size}
|
|
38
|
+
>
|
|
39
|
+
<p className="text-small text-fg">Responsive owned-scroll content.</p>
|
|
40
|
+
</SidePanel>
|
|
41
|
+
</>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function PendingExample(): React.ReactNode {
|
|
46
|
+
const [open, setOpen] = useState(false)
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
<Button variant="secondary" onPress={() => setOpen(true)}>Pending state</Button>
|
|
50
|
+
<SidePanel
|
|
51
|
+
isOpen={open}
|
|
52
|
+
onOpenChange={setOpen}
|
|
53
|
+
title="Saving"
|
|
54
|
+
description="Dismissal is temporarily disabled."
|
|
55
|
+
closeLabel="Close panel"
|
|
56
|
+
isDismissable={false}
|
|
57
|
+
>
|
|
58
|
+
<div role="status" className="text-small text-fg-muted">Working…</div>
|
|
59
|
+
</SidePanel>
|
|
60
|
+
</>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const examples = {
|
|
65
|
+
default: () => <DefaultExample />,
|
|
66
|
+
sizes: () => (
|
|
67
|
+
<div className="flex gap-2">
|
|
68
|
+
<SizeExample size="sm" />
|
|
69
|
+
<SizeExample size="md" />
|
|
70
|
+
<SizeExample size="lg" />
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
states: () => <PendingExample />,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const meta = {
|
|
77
|
+
category: 'Layout',
|
|
78
|
+
description: 'Accessible right-side modal panel with responsive full-screen mobile behavior.',
|
|
79
|
+
}
|
|
80
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button as RACButton,
|
|
4
|
+
Dialog as RACDialog,
|
|
5
|
+
Heading,
|
|
6
|
+
Modal as RACModal,
|
|
7
|
+
ModalOverlay as RACModalOverlay,
|
|
8
|
+
} from 'react-aria-components'
|
|
9
|
+
|
|
10
|
+
import { uic } from '../utils/uic'
|
|
11
|
+
|
|
12
|
+
export type SidePanelSize = 'sm' | 'md' | 'lg'
|
|
13
|
+
|
|
14
|
+
export interface SidePanelProps {
|
|
15
|
+
/** Controlled open state. */
|
|
16
|
+
isOpen: boolean
|
|
17
|
+
/** Called for Escape, backdrop dismissal and the close control. */
|
|
18
|
+
onOpenChange: (isOpen: boolean) => void
|
|
19
|
+
/** Accessible panel title. */
|
|
20
|
+
title: ReactNode
|
|
21
|
+
/** Optional supporting copy below the title. */
|
|
22
|
+
description?: ReactNode
|
|
23
|
+
/** App-supplied localized label for the close control. */
|
|
24
|
+
closeLabel: string
|
|
25
|
+
/** Panel width from the shared responsive scale. */
|
|
26
|
+
size?: SidePanelSize
|
|
27
|
+
/** Disable Escape/backdrop dismissal while a critical operation is pending. */
|
|
28
|
+
isDismissable?: boolean
|
|
29
|
+
/** Owned-scroll panel content. */
|
|
30
|
+
children: ReactNode | ((options: { close: () => void }) => ReactNode)
|
|
31
|
+
/** Optional pinned action area below the scroll region. */
|
|
32
|
+
footer?: ReactNode | ((options: { close: () => void }) => ReactNode)
|
|
33
|
+
/** Optional test id forwarded to the dialog element. */
|
|
34
|
+
'data-testid'?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SidePanelOverlay = uic(RACModalOverlay, {
|
|
38
|
+
displayName: 'SidePanel.Overlay',
|
|
39
|
+
baseClass:
|
|
40
|
+
'fixed inset-0 z-50 flex justify-end bg-modal-backdrop backdrop-blur-modal-backdrop ' +
|
|
41
|
+
'data-[entering]:animate-modal-overlay-in data-[exiting]:animate-modal-overlay-out ' +
|
|
42
|
+
'motion-reduce:data-[entering]:animate-none motion-reduce:data-[exiting]:animate-none',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const SidePanelSurface = uic(RACModal, {
|
|
46
|
+
displayName: 'SidePanel.Surface',
|
|
47
|
+
baseClass:
|
|
48
|
+
'ml-auto flex h-dvh w-full flex-col overflow-hidden bg-surface outline-none shadow-modal-surface ' +
|
|
49
|
+
'sm:rounded-l-lg data-[entering]:animate-side-panel-in data-[exiting]:animate-side-panel-out ' +
|
|
50
|
+
'motion-reduce:data-[entering]:animate-none motion-reduce:data-[exiting]:animate-none',
|
|
51
|
+
variants: {
|
|
52
|
+
size: {
|
|
53
|
+
sm: 'sm:max-w-sm',
|
|
54
|
+
md: 'sm:max-w-md',
|
|
55
|
+
lg: 'sm:max-w-lg',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
defaultVariants: { size: 'md' },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const SidePanelClose = uic(RACButton, {
|
|
62
|
+
displayName: 'SidePanel.Close',
|
|
63
|
+
baseClass:
|
|
64
|
+
'inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-fg-subtle outline-none ' +
|
|
65
|
+
'transition-colors hover:bg-surface-muted hover:text-fg ' +
|
|
66
|
+
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Responsive modal side panel.
|
|
71
|
+
*
|
|
72
|
+
* React Aria owns focus containment, Escape/backdrop dismissal, scroll locking
|
|
73
|
+
* and trigger focus return. The surface is a full-screen owned-scroll sheet on
|
|
74
|
+
* mobile and a right-aligned panel on larger viewports. All copy and product
|
|
75
|
+
* content are supplied by the consuming application.
|
|
76
|
+
*/
|
|
77
|
+
export function SidePanel({
|
|
78
|
+
isOpen,
|
|
79
|
+
onOpenChange,
|
|
80
|
+
title,
|
|
81
|
+
description,
|
|
82
|
+
closeLabel,
|
|
83
|
+
size = 'md',
|
|
84
|
+
isDismissable = true,
|
|
85
|
+
children,
|
|
86
|
+
footer,
|
|
87
|
+
'data-testid': testId,
|
|
88
|
+
}: SidePanelProps): React.ReactNode {
|
|
89
|
+
return (
|
|
90
|
+
<SidePanelOverlay
|
|
91
|
+
isOpen={isOpen}
|
|
92
|
+
onOpenChange={onOpenChange}
|
|
93
|
+
isDismissable={isDismissable}
|
|
94
|
+
>
|
|
95
|
+
<SidePanelSurface size={size}>
|
|
96
|
+
<RACDialog
|
|
97
|
+
data-testid={testId}
|
|
98
|
+
className="flex min-h-0 flex-1 flex-col outline-none"
|
|
99
|
+
>
|
|
100
|
+
{renderProps => (
|
|
101
|
+
<>
|
|
102
|
+
<header className="flex shrink-0 items-start justify-between gap-4 border-b border-border px-5 py-4">
|
|
103
|
+
<div className="min-w-0 pt-1">
|
|
104
|
+
<Heading slot="title" className="text-heading1 font-medium tracking-tight text-fg">
|
|
105
|
+
{title}
|
|
106
|
+
</Heading>
|
|
107
|
+
{description ? (
|
|
108
|
+
<p className="mt-1 text-small text-fg-muted">{description}</p>
|
|
109
|
+
) : null}
|
|
110
|
+
</div>
|
|
111
|
+
<SidePanelClose aria-label={closeLabel} onClick={() => onOpenChange(false)}>
|
|
112
|
+
<svg
|
|
113
|
+
width="20"
|
|
114
|
+
height="20"
|
|
115
|
+
viewBox="0 0 24 24"
|
|
116
|
+
fill="none"
|
|
117
|
+
stroke="currentColor"
|
|
118
|
+
strokeWidth="2"
|
|
119
|
+
strokeLinecap="round"
|
|
120
|
+
aria-hidden="true"
|
|
121
|
+
>
|
|
122
|
+
<path d="M6 6l12 12M18 6 6 18" />
|
|
123
|
+
</svg>
|
|
124
|
+
</SidePanelClose>
|
|
125
|
+
</header>
|
|
126
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
127
|
+
{typeof children === 'function' ? children(renderProps) : children}
|
|
128
|
+
</div>
|
|
129
|
+
{footer ? (
|
|
130
|
+
<footer className="shrink-0 border-t border-border px-5 pt-4 pb-mobile-cta-bottom sm:pb-4">
|
|
131
|
+
{typeof footer === 'function' ? footer(renderProps) : footer}
|
|
132
|
+
</footer>
|
|
133
|
+
) : null}
|
|
134
|
+
</>
|
|
135
|
+
)}
|
|
136
|
+
</RACDialog>
|
|
137
|
+
</SidePanelSurface>
|
|
138
|
+
</SidePanelOverlay>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ComponentProps } from 'react'
|
|
2
|
+
|
|
3
|
+
import { uic } from '../utils/uic'
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
|
-
* Subtle — inline de-emphasised text
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Subtle — inline de-emphasised text. Wraps its children in a de-emphasised span;
|
|
7
|
+
* anything OUTSIDE it stays at the surrounding full-contrast `fg`. Compose it freely
|
|
8
|
+
* to two-tone arbitrary runs of a line — unlike a fixed-slot header it handles
|
|
9
|
+
* mid-line emphasis in ANY order and ANY language:
|
|
8
10
|
*
|
|
9
11
|
* <h1>
|
|
10
12
|
* <Subtle>Good afternoon</Subtle> {name} 👋
|
|
@@ -15,7 +17,61 @@ import type { ReactNode } from 'react'
|
|
|
15
17
|
* With i18n, put the `<subtle>…</subtle>` runs INSIDE the translation string and render
|
|
16
18
|
* it via `<Trans components={{ subtle: <Subtle /> }} />`, so each locale places the
|
|
17
19
|
* emphasis where its own word order needs it. Presentational only (hard rule #1).
|
|
20
|
+
*
|
|
21
|
+
* ## Tone
|
|
22
|
+
*
|
|
23
|
+
* `muted` (the default) is `fg-muted`. It clears WCAG 2.1 AA normal-text contrast on
|
|
24
|
+
* the READING surfaces — `surface` 5.98:1, `surface-card` 5.53:1, `surface-muted`
|
|
25
|
+
* 4.96:1 in light; 6.52:1 / 5.30:1 in dark. Meaningful copy — a greeting, a task
|
|
26
|
+
* count, a caption — belongs here.
|
|
27
|
+
*
|
|
28
|
+
* `muted` covers the reading surfaces and nothing else. Every surface that carries
|
|
29
|
+
* its OWN ink needs the matching tone, because `fg-muted` is below AA on all of
|
|
30
|
+
* them — `Tile.mutedTone()` has always encoded this pairing, and these two tones
|
|
31
|
+
* are the same rule made reachable from a bare run of text:
|
|
32
|
+
*
|
|
33
|
+
* surface-inverted (#242423 — does NOT flip, so it is a dark panel in the LIGHT
|
|
34
|
+
* theme too: a dark `Tile`, `Badge color="dark"`) fg-muted 2.60:1
|
|
35
|
+
* → `inverted`, `fg-inverted/60` — 6.49:1 light, 4.99:1 dark
|
|
36
|
+
*
|
|
37
|
+
* the FIXED brand fills, which never flip: brand-green (fg-muted 3.95:1),
|
|
38
|
+
* accent-yellow (4.01:1), brand-secondary (3.60:1) — a teal or yellow `Tile`,
|
|
39
|
+
* a `CtaPill`, a green/yellow `Badge`
|
|
40
|
+
* → `on-brand`, `fg-on-brand/70` — 6.08:1 / 6.11:1 / 5.78:1, theme-stable
|
|
41
|
+
*
|
|
42
|
+
* All four blends are asserted in @podoba/tokens `contrast.test.ts`.
|
|
43
|
+
*
|
|
44
|
+
* ## Migrating from ≤ 0.0.34
|
|
45
|
+
*
|
|
46
|
+
* BREAKING on `surface-inverted` ONLY. `fg-subtle` is light enough that it happened
|
|
47
|
+
* to PASS on that dark panel (7.41:1) while failing everywhere else, so a run that
|
|
48
|
+
* sat on a dark `Tile` had a working colour by accident and now needs
|
|
49
|
+
* `tone="inverted"` to keep it. Nothing detects this for you: the tone is a prop,
|
|
50
|
+
* and no lint rule knows which surface a run lands on — grep for `<Subtle` and
|
|
51
|
+
* `tone="subtle"` inside dark `Tile`s and `bg-surface-inverted` panels.
|
|
52
|
+
*
|
|
53
|
+
* Everywhere else the new default is an improvement, not a migration. On the
|
|
54
|
+
* reading surfaces it goes 2.10 → 5.98 (and 1.74 → 4.96 on `surface-muted`); on the
|
|
55
|
+
* brand fills 1.39 → 3.95, which is better but still short of AA — those runs were
|
|
56
|
+
* ALWAYS broken and `tone="on-brand"` is the fix, not a regression to undo.
|
|
57
|
+
*
|
|
58
|
+
* `decorative` is the lighter `fg-subtle` (#b3b3b3) this component used to apply
|
|
59
|
+
* unconditionally. It measures 2.10:1 on `surface` and 1.94:1 on `surface-card`, below
|
|
60
|
+
* even the 3:1 large-text floor, so it is ORNAMENTAL ONLY: separators, watermarks,
|
|
61
|
+
* repeated decorative runs that carry no information a reader would miss. Never put
|
|
62
|
+
* text a user has to read behind it.
|
|
18
63
|
*/
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
64
|
+
export const Subtle = uic('span', {
|
|
65
|
+
displayName: 'Subtle',
|
|
66
|
+
variants: {
|
|
67
|
+
tone: {
|
|
68
|
+
muted: 'text-fg-muted',
|
|
69
|
+
inverted: 'text-fg-inverted/60',
|
|
70
|
+
'on-brand': 'text-fg-on-brand/70',
|
|
71
|
+
decorative: 'text-fg-subtle',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
defaultVariants: { tone: 'muted' },
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
export type SubtleProps = ComponentProps<typeof Subtle>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Table } from './table'
|
|
2
|
+
const columns = [{ key: 'name', header: 'Name' }, { key: 'city', header: 'City' }]
|
|
3
|
+
const data = [{ name: 'Annual report', city: 'Prague' }]
|
|
4
|
+
export const examples = {
|
|
5
|
+
default: () => <Table columns={columns} data={data} aria-label="Projects" />,
|
|
6
|
+
worksheet: () => <Table appearance="worksheet" columns={columns} data={data} aria-label="CSV preview" />,
|
|
7
|
+
empty: () => <Table appearance="worksheet" columns={columns} data={[]} emptyMessage="No preview rows available." aria-label="Empty CSV" />,
|
|
8
|
+
}
|
|
9
|
+
export const meta = { category: 'Composite', description: 'Default data table and the source Manager worksheet appearance with monospace column labels.' }
|
package/src/components/table.tsx
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useMemo, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useMemo, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from 'react'
|
|
2
2
|
import { ChevronDownIcon, ChevronUpIcon } from './icons'
|
|
3
|
+
export const TABLE_APPEARANCES = ['default', 'worksheet', 'team'] as const
|
|
4
|
+
export type TableAppearance = (typeof TABLE_APPEARANCES)[number]
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Table — the design-system data table (port of gs-platform's `GSTable`). A light,
|
|
@@ -35,10 +37,15 @@ export type TableColumn<Row> = {
|
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export type TableProps<Row> = {
|
|
40
|
+
/** Worksheet matches Manager's plain GSTable; default preserves existing consumers. */
|
|
41
|
+
appearance?: TableAppearance
|
|
38
42
|
columns: TableColumn<Row>[]
|
|
39
43
|
data: Row[]
|
|
40
44
|
/** Stable per-row key. Defaults to the row index (fine for static lists). */
|
|
41
45
|
getRowKey?: (row: Row, index: number) => string
|
|
46
|
+
/** Additional semantic/event props for each rendered row. */
|
|
47
|
+
getRowProps?: (row: Row) => HTMLAttributes<HTMLTableRowElement>
|
|
48
|
+
columnTemplate?: string
|
|
42
49
|
/** Enable client-side sorting on `sortable` columns. */
|
|
43
50
|
enableSorting?: boolean
|
|
44
51
|
/** Whole-row press handler — renders rows as interactive (hover + keyboard). */
|
|
@@ -69,10 +76,13 @@ export function Table<Row>({
|
|
|
69
76
|
columns,
|
|
70
77
|
data,
|
|
71
78
|
getRowKey,
|
|
79
|
+
getRowProps,
|
|
80
|
+
columnTemplate,
|
|
72
81
|
enableSorting = false,
|
|
73
82
|
onRowClick,
|
|
74
83
|
emptyMessage = 'No rows.',
|
|
75
84
|
className,
|
|
85
|
+
appearance = 'default',
|
|
76
86
|
...aria
|
|
77
87
|
}: TableProps<Row>) {
|
|
78
88
|
const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null)
|
|
@@ -100,12 +110,15 @@ export function Table<Row>({
|
|
|
100
110
|
|
|
101
111
|
const rowKey = getRowKey ?? ((_row: Row, index: number) => String(index))
|
|
102
112
|
const interactive = Boolean(onRowClick)
|
|
113
|
+
const worksheet = appearance === 'worksheet'
|
|
114
|
+
const team = appearance === 'team'
|
|
115
|
+
const gridTemplate = columnTemplate ?? `repeat(${columns.length}, minmax(0, 1fr))`
|
|
103
116
|
|
|
104
117
|
return (
|
|
105
118
|
<div className={['w-full overflow-x-auto', className].filter(Boolean).join(' ')}>
|
|
106
|
-
<table className=
|
|
107
|
-
<thead>
|
|
108
|
-
<tr className=
|
|
119
|
+
<table style={team ? { '--table-columns': gridTemplate } as CSSProperties : undefined} className={`w-full border-collapse text-small ${worksheet ? 'border-b border-border font-normal leading-4.5' : ''} ${team ? 'block border-b border-border font-normal leading-4.5' : ''}`} {...aria}>
|
|
120
|
+
<thead className={team ? 'block' : undefined}>
|
|
121
|
+
<tr className={`${worksheet ? '' : 'border-b border-border'} ${team ? 'grid grid-cols-[var(--table-columns)] gap-3 bg-surface-muted px-4 max-[1023px]:hidden' : ''}`}>
|
|
109
122
|
{columns.map((column) => {
|
|
110
123
|
const align = column.align ?? 'left'
|
|
111
124
|
const canSort = enableSorting && column.sortable
|
|
@@ -118,7 +131,7 @@ export function Table<Row>({
|
|
|
118
131
|
aria-sort={
|
|
119
132
|
canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
|
|
120
133
|
}
|
|
121
|
-
className={`${ALIGN_CLASS[align]} px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase`}
|
|
134
|
+
className={`${ALIGN_CLASS[align]} ${worksheet ? 'border-0 px-3 py-4 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case bg-surface whitespace-nowrap' : team ? 'border-0 px-4 py-3 align-middle font-mono text-small font-normal leading-4.5 tracking-normal text-fg normal-case whitespace-nowrap' : 'px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase'}`}
|
|
122
135
|
>
|
|
123
136
|
{canSort ? (
|
|
124
137
|
<button
|
|
@@ -145,17 +158,20 @@ export function Table<Row>({
|
|
|
145
158
|
})}
|
|
146
159
|
</tr>
|
|
147
160
|
</thead>
|
|
148
|
-
<tbody>
|
|
161
|
+
<tbody className={team ? 'block' : undefined}>
|
|
149
162
|
{sorted.length === 0 ? (
|
|
150
163
|
<tr>
|
|
151
|
-
<td colSpan={columns.length} className=
|
|
164
|
+
<td colSpan={columns.length} className={worksheet ? 'border-y border-border px-6 py-4 text-small font-normal leading-4.5 text-fg' : 'px-4 py-10 text-center text-small text-fg-muted'}>
|
|
152
165
|
{emptyMessage}
|
|
153
166
|
</td>
|
|
154
167
|
</tr>
|
|
155
168
|
) : (
|
|
156
|
-
sorted.map((row, index) =>
|
|
169
|
+
sorted.map((row, index) => {
|
|
170
|
+
const rowProps = getRowProps?.(row)
|
|
171
|
+
return (
|
|
157
172
|
<tr
|
|
158
173
|
key={rowKey(row, index)}
|
|
174
|
+
{...rowProps}
|
|
159
175
|
{...(interactive
|
|
160
176
|
? {
|
|
161
177
|
tabIndex: 0,
|
|
@@ -169,22 +185,23 @@ export function Table<Row>({
|
|
|
169
185
|
},
|
|
170
186
|
}
|
|
171
187
|
: {})}
|
|
172
|
-
className={
|
|
188
|
+
className={`${worksheet ? 'min-h-15' : team ? 'grid grid-cols-[var(--table-columns)] gap-3 border-b border-border px-4 py-3 text-label font-normal leading-4.5 max-[1023px]:grid max-[1023px]:grid-cols-1 max-[1023px]:gap-2 max-[1023px]:p-4' : 'border-b border-border/60'} ${team ? 'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring' : ''} outline-none ${
|
|
173
189
|
interactive
|
|
174
190
|
? 'cursor-pointer transition-colors hover:bg-surface-muted focus-visible:bg-surface-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
|
|
175
191
|
: ''
|
|
176
|
-
}`}
|
|
192
|
+
}${rowProps?.className ? ` ${rowProps.className}` : ''}`}
|
|
177
193
|
>
|
|
178
194
|
{columns.map((column) => {
|
|
179
195
|
const align = column.align ?? 'left'
|
|
180
196
|
return (
|
|
181
|
-
<td key={column.key} className={`${ALIGN_CLASS[align]} px-4 py-3 align-middle text-fg`}>
|
|
197
|
+
<td key={column.key} className={`${ALIGN_CLASS[align]} ${worksheet ? 'border border-border first:border-l-0 last:border-r-0 px-6 py-4 text-small font-normal leading-4.5' : team ? 'px-0 py-0 align-middle text-small font-normal leading-4.5 max-[1023px]:block' : 'px-4 py-3'} align-middle text-fg`}>
|
|
182
198
|
{column.render ? column.render(row) : String(defaultSortValue(column, row))}
|
|
183
199
|
</td>
|
|
184
200
|
)
|
|
185
201
|
})}
|
|
186
202
|
</tr>
|
|
187
|
-
)
|
|
203
|
+
)
|
|
204
|
+
})
|
|
188
205
|
)}
|
|
189
206
|
</tbody>
|
|
190
207
|
</table>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { CatalogMetricCard } from './template-catalog-card'
|
|
3
|
+
import { CatalogLaunchCard, CatalogSummaryBadge, CatalogSummaryGrid, CatalogSummaryCard, TemplateCatalogBrowseCard, TemplateCatalogBrowseGrid } from './template-catalog-card'
|
|
4
|
+
import { TemplateCatalogCard, TemplateCatalogGrid, TemplateReferenceGrid, TemplateReferenceList, TemplateReferenceItem, CatalogMetadataSummary } from './template-catalog-card'
|
|
5
|
+
|
|
6
|
+
function Example({ selected = false, locked = false, title = 'Annual report' }: { selected?: boolean; locked?: boolean; title?: string }) {
|
|
7
|
+
const [value, setValue] = useState(selected)
|
|
8
|
+
return <TemplateCatalogCard title={title} isSelected={value} isSelectionLocked={locked}
|
|
9
|
+
onToggle={() => setValue(!value)} preview={<span className="flex h-full aspect-3/4 items-center justify-center bg-surface text-fg">Aa</span>} />
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const examples = {
|
|
13
|
+
metrics: () => <CatalogSummaryGrid>{['0', '205', '—'].map(value => <CatalogMetricCard key={value} title="Templates" value={value} footer={value === '0' ? undefined : 'Catalog'} />)}</CatalogSummaryGrid>,
|
|
14
|
+
launch: () => <CatalogLaunchCard title="Annual report" description="Prepare the yearly report." details="2 sections · 3 outputs" action={<button type="button" aria-label="Start Annual report" className="absolute inset-0 rounded-lg focus-visible:ring-2 focus-visible:ring-ring" />} />,
|
|
15
|
+
launchWithoutBrief: () => <CatalogLaunchCard title="Annual report" details="2 sections · 3 outputs" action={<button type="button" disabled aria-label="Start Annual report" className="absolute inset-0 rounded-lg" />} />,
|
|
16
|
+
summary: () => <CatalogSummaryGrid>{['Campaign', 'Annual report', 'Social launch'].map(title => <CatalogSummaryCard key={title} title={title} badge={<CatalogSummaryBadge color="green">active</CatalogSummaryBadge>} description="Reusable production scenario" details="2 sections · 4 outputs" />)}</CatalogSummaryGrid>,
|
|
17
|
+
readOnly: () => <TemplateCatalogBrowseGrid><TemplateCatalogBrowseCard title="Annual report" templateId="annual" preview={<span>Aa</span>} /><TemplateCatalogBrowseCard title="A long template title that wraps onto multiple lines" templateId="long" preview={<span>Aa</span>} /></TemplateCatalogBrowseGrid>,
|
|
18
|
+
metadata: () => <CatalogMetadataSummary title="Summary" ariaLabel="Template summary" items={[{ label: 'Name', value: 'Annual report', fullWidth: true }, { label: 'Type', value: 'Print' }, { label: 'Format', value: 'A4' }, { label: 'Reference ID', value: 'long-reference-123456789012345678901234567890', fullWidth: true }]} />,
|
|
19
|
+
browsing: () => <TemplateReferenceGrid columns={2}><TemplateReferenceItem title="Annual report" meta="Template" templateId="report" view="preview_grid" onOpen={() => undefined} preview={<span>Aa</span>} /><TemplateReferenceItem title="Unavailable" meta="Template" templateId="missing" view="preview_grid" isDisabled onOpen={() => undefined} preview={<span>Un</span>} /></TemplateReferenceGrid>,
|
|
20
|
+
list: () => <TemplateReferenceList><TemplateReferenceItem title="Annual report" meta="Template" templateId="report" view="compact_list" onOpen={() => undefined} preview={<span>Aa</span>} /><TemplateReferenceItem title="Unavailable" meta="Template" templateId="missing" view="compact_list" isDisabled onOpen={() => undefined} preview={<span>Un</span>} /></TemplateReferenceList>,
|
|
21
|
+
default: () => <TemplateCatalogGrid><Example /></TemplateCatalogGrid>,
|
|
22
|
+
variants: () => <TemplateCatalogGrid><Example /><Example selected /></TemplateCatalogGrid>,
|
|
23
|
+
states: () => <TemplateCatalogGrid><Example selected locked /><Example title="A long template title that wraps instead of disappearing behind an ellipsis" /></TemplateCatalogGrid>,
|
|
24
|
+
}
|
|
25
|
+
export const meta = { category: 'Composite', description: 'Selection tiles with a contained preview, keyboard focus and locked assignments. Hover and selection share the original green fill.' }
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { CSSProperties, ReactNode } from 'react'
|
|
2
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
3
|
+
import { uic } from '../utils/uic'
|
|
4
|
+
import { Tile } from './tile'
|
|
5
|
+
|
|
6
|
+
export const TEMPLATE_CATALOG_SELECTIONS = ['available', 'selected'] as const
|
|
7
|
+
export type TemplateCatalogSelection = (typeof TEMPLATE_CATALOG_SELECTIONS)[number]
|
|
8
|
+
|
|
9
|
+
/** Legacy StatsCard label-1 value, distinct from the Tile heading ramp. */
|
|
10
|
+
export const CatalogMetricValue = uic('span', {
|
|
11
|
+
displayName: 'CatalogMetricValue',
|
|
12
|
+
baseClass: 'block w-full text-center text-display-large font-medium leading-(--line-height-display-large) tracking-wider',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
export const CATALOG_METRIC_FOOTERS = ['present', 'missing'] as const
|
|
16
|
+
export type CatalogMetricFooter = (typeof CATALOG_METRIC_FOOTERS)[number]
|
|
17
|
+
const MetricContent = uic('div', {
|
|
18
|
+
displayName: 'CatalogMetricContent',
|
|
19
|
+
baseClass: 'flex min-h-0 w-full flex-1 flex-col items-center justify-center text-center',
|
|
20
|
+
variants: { footer: { present: '', missing: 'pb-12' } },
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
/** Compact source statistics: preserve the centered content band without a tail. */
|
|
24
|
+
export function CatalogMetricCard({ title, value, footer, onPress, 'aria-label': ariaLabel }: {
|
|
25
|
+
title: string; value: ReactNode; footer?: string; onPress?: () => void; 'aria-label'?: string
|
|
26
|
+
}) {
|
|
27
|
+
return <Tile theme="light" eyebrow={title} footer={footer || undefined} onPress={onPress}
|
|
28
|
+
aria-label={ariaLabel} className="h-52 min-h-52 max-h-52">
|
|
29
|
+
<MetricContent data-slot="catalog-metric-content" footer={footer ? 'present' : 'missing'}>
|
|
30
|
+
<CatalogMetricValue>{value}</CatalogMetricValue>
|
|
31
|
+
</MetricContent>
|
|
32
|
+
</Tile>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SummaryCard = uic('article', { displayName: 'CatalogSummaryCard', baseClass: 'flex h-95 w-full min-w-0 flex-col gap-6 overflow-hidden rounded-lg bg-surface-card p-4 text-fg' })
|
|
36
|
+
export const CatalogSummaryGrid = uic('div', { displayName: 'CatalogSummaryGrid', baseClass: 'grid w-full min-w-0 grid-cols-1 gap-x-2 gap-y-4 overflow-x-hidden min-[768px]:grid-cols-3 min-[768px]:gap-x-3' })
|
|
37
|
+
const SummaryHead = uic('div', { displayName: 'CatalogSummaryHead', baseClass: 'flex w-full min-w-0 items-center justify-between gap-3' })
|
|
38
|
+
export const CatalogSummaryBadge = uic('span', { displayName: 'CatalogSummaryBadge', baseClass: 'ml-3 flex h-6 shrink-0 items-center rounded-2xl px-2.5 text-micro font-medium leading-5', style: { fontFeatureSettings: '"liga" off, "clig" off' }, variants: { color: { green: 'bg-brand-green text-fg-on-brand', grey: 'bg-surface-muted text-(--color-neutral-600)' } }, defaultVariants: { color: 'grey' } })
|
|
39
|
+
const SummaryTitle = uic('h2', { displayName: 'CatalogSummaryTitle', baseClass: 'm-0 min-w-0 truncate text-heading5 font-medium' })
|
|
40
|
+
const SummaryTail = uic('div', { displayName: 'CatalogSummaryTail', baseClass: 'mt-auto flex flex-col gap-1 text-small font-normal text-fg-workflow-muted' })
|
|
41
|
+
/** Display-only catalog tile. No synthetic imagery when a preview is absent. */
|
|
42
|
+
export function CatalogSummaryCard({ title, badge, description, details }: { title: string; badge: ReactNode; description?: string; details: ReactNode }) {
|
|
43
|
+
return <SummaryCard><SummaryHead><SummaryTitle>{title}</SummaryTitle><span className="shrink-0">{badge}</span></SummaryHead><SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail></SummaryCard>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Compact launcher card. Missing artwork is not replaced by synthetic imagery. */
|
|
47
|
+
export function CatalogLaunchCard({ title, description, details, action }: { title: string; description?: string; details: ReactNode; action: ReactNode }) {
|
|
48
|
+
return <SummaryCard data-testid="scenario-launch-card" className="relative h-70 min-h-70 max-h-70 transition-colors duration-200 focus-within:ring-2 focus-within:ring-ring">
|
|
49
|
+
<SummaryHead><SummaryTitle asChild><h3>{title}</h3></SummaryTitle></SummaryHead>
|
|
50
|
+
<SummaryTail>{description ? <p className="m-0">{description}</p> : null}<span>{details}</span></SummaryTail>
|
|
51
|
+
{action}
|
|
52
|
+
</SummaryCard>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const MetadataSection = uic('section', { displayName: 'CatalogMetadataSection', baseClass: 'm-0 w-full' })
|
|
56
|
+
/** Source metadata layout: full-width identity, paired attributes, wrapping IDs. */
|
|
57
|
+
export function CatalogMetadataSummary({ title, ariaLabel, items }: {
|
|
58
|
+
title: string; ariaLabel: string; items: { label: string; value: ReactNode; fullWidth?: boolean }[]
|
|
59
|
+
}) {
|
|
60
|
+
let column = 0
|
|
61
|
+
return <MetadataSection aria-label={ariaLabel}>
|
|
62
|
+
<h2 className="mb-3 mt-0 text-heading3 font-medium text-fg">{title}</h2>
|
|
63
|
+
<dl className="m-0 grid w-full grid-cols-1 border-t border-border min-[641px]:grid-cols-2">
|
|
64
|
+
{items.map(item => {
|
|
65
|
+
const second = !item.fullWidth && column % 2 === 1
|
|
66
|
+
column = item.fullWidth ? 0 : column + 1
|
|
67
|
+
return <div key={item.label} className={`flex min-w-0 flex-col gap-1 border-b border-border py-3 ${item.fullWidth ? 'col-span-full' : ''} ${second ? 'min-[641px]:border-l min-[641px]:pl-3' : ''}`}>
|
|
68
|
+
<dt className="m-0 text-body font-normal text-fg-subtle">{item.label}</dt>
|
|
69
|
+
<dd className="m-0 text-body font-normal text-fg [overflow-wrap:anywhere]">{item.value}</dd>
|
|
70
|
+
</div>
|
|
71
|
+
})}
|
|
72
|
+
</dl>
|
|
73
|
+
</MetadataSection>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const TEMPLATE_REFERENCE_VIEWS = ['preview_grid', 'compact_list'] as const
|
|
77
|
+
export type TemplateReferenceView = (typeof TEMPLATE_REFERENCE_VIEWS)[number]
|
|
78
|
+
export const TemplateReferenceGrid = uic('div', {
|
|
79
|
+
displayName: 'TemplateReferenceGrid',
|
|
80
|
+
baseClass: 'grid w-full grid-cols-1 gap-3',
|
|
81
|
+
variants: { columns: { 1: 'grid-cols-1', 2: 'min-[761px]:grid-cols-2', 3: 'min-[761px]:grid-cols-3' } },
|
|
82
|
+
defaultVariants: { columns: 1 },
|
|
83
|
+
})
|
|
84
|
+
export const TemplateReferenceList = uic('div', {
|
|
85
|
+
displayName: 'TemplateReferenceList', baseClass: 'flex w-full flex-col gap-2',
|
|
86
|
+
})
|
|
87
|
+
const ReferenceButton = uic(AriaButton, {
|
|
88
|
+
displayName: 'TemplateReferenceButton',
|
|
89
|
+
baseClass: 'w-full cursor-pointer border-0 p-0 text-start outline-none disabled:cursor-default data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
|
|
90
|
+
variants: { view: {
|
|
91
|
+
preview_grid: 'flex aspect-square items-center justify-center overflow-hidden rounded-lg bg-surface-card',
|
|
92
|
+
compact_list: 'grid min-h-14 grid-cols-[3rem_minmax(0,1fr)] items-center gap-3 border-b border-border bg-transparent last:border-b-0',
|
|
93
|
+
} },
|
|
94
|
+
})
|
|
95
|
+
/** Read-only browsing surfaces: activation opens a preview, never selects it. */
|
|
96
|
+
export function TemplateReferenceItem({ title, meta, preview, view, isDisabled, onOpen, templateId }: {
|
|
97
|
+
title: string; meta: string; preview: ReactNode; view: TemplateReferenceView
|
|
98
|
+
isDisabled?: boolean; onOpen: () => void; templateId: string
|
|
99
|
+
}) {
|
|
100
|
+
return <ReferenceButton type="button" view={view} isDisabled={isDisabled} onPress={onOpen} aria-label={title} data-template-id={templateId}>
|
|
101
|
+
<span aria-hidden="true" className={view === 'compact_list' ? 'flex size-12 items-center justify-center overflow-hidden rounded-sm bg-surface-card [&>div]:w-full' : 'flex h-full w-full items-center justify-center [&>div]:w-full'}>{preview}</span>
|
|
102
|
+
{view === 'compact_list' ? <span className="flex min-w-0 flex-col gap-1"><span className="truncate text-small font-medium text-fg">{title}</span><span className="truncate text-label font-medium text-fg-subtle">{meta}</span></span> : null}
|
|
103
|
+
</ReferenceButton>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const TemplateCatalogGrid = uic('div', {
|
|
107
|
+
displayName: 'TemplateCatalogGrid',
|
|
108
|
+
baseClass: 'grid w-full min-w-0 grid-cols-1 items-stretch gap-2 p-0 md:grid-cols-2 md:gap-3 lg:grid-cols-3',
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const Card = uic(AriaButton, {
|
|
112
|
+
displayName: 'TemplateCatalogCardSurface',
|
|
113
|
+
baseClass: 'group box-border flex h-(--template-catalog-height) min-w-0 w-full cursor-pointer flex-col gap-6 overflow-hidden rounded-lg border-0 p-4 text-start outline-none transition-colors duration-200 motion-reduce:transition-none hover:bg-brand-green hover:text-fg-on-brand data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2',
|
|
114
|
+
variants: {
|
|
115
|
+
selection: {
|
|
116
|
+
available: 'bg-surface-card text-fg',
|
|
117
|
+
selected: 'bg-brand-green text-fg-on-brand',
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
defaultVariants: { selection: 'available' },
|
|
121
|
+
})
|
|
122
|
+
const Title = uic('span', {
|
|
123
|
+
displayName: 'TemplateCatalogCardTitle',
|
|
124
|
+
baseClass: 'block min-h-5 min-w-0 shrink-0 text-body font-medium leading-5',
|
|
125
|
+
})
|
|
126
|
+
const Preview = uic('span', {
|
|
127
|
+
displayName: 'TemplateCatalogCardPreview',
|
|
128
|
+
baseClass: 'box-border flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden rounded-md bg-transparent p-3 [&>div]:w-full',
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const geometry: CSSProperties & { '--template-catalog-height': string } = {
|
|
132
|
+
'--template-catalog-height': 'clamp(calc(var(--spacing) * 64), 30vw, calc(var(--spacing) * 76))',
|
|
133
|
+
letterSpacing: 0,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const TemplateCatalogBrowseGrid = uic('div', {
|
|
137
|
+
displayName: 'TemplateCatalogBrowseGrid',
|
|
138
|
+
baseClass: 'grid w-full min-w-0 grid-cols-1 items-stretch gap-2 p-0 md:grid-cols-2 md:gap-3 lg:grid-cols-4',
|
|
139
|
+
})
|
|
140
|
+
const BrowseCard = uic('div', {
|
|
141
|
+
displayName: 'TemplateCatalogBrowseSurface',
|
|
142
|
+
baseClass: 'box-border flex h-(--template-catalog-height) min-w-0 w-full cursor-default flex-col gap-6 overflow-hidden rounded-lg border-0 bg-surface-card p-4 text-start text-fg outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
143
|
+
})
|
|
144
|
+
/** Legacy readOnly catalog: no activation, hover shadow or extra card CTA. */
|
|
145
|
+
export function TemplateCatalogBrowseCard({ title, preview, templateId, id, testId }: {
|
|
146
|
+
title: string; preview: ReactNode; templateId: string; id?: string; testId?: string
|
|
147
|
+
}) {
|
|
148
|
+
return <BrowseCard role="group" tabIndex={0} aria-label={title} id={id} data-catalog-template-id={templateId} data-testid={testId} style={geometry}>
|
|
149
|
+
<Title>{title}</Title><Preview aria-hidden="true" className="pointer-events-none">{preview}</Preview>
|
|
150
|
+
</BrowseCard>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Selection-only catalog tile. No badge, selected ring, nested action, or artwork
|
|
154
|
+
* rounding is added. A locked assignment stays focusable and selected, just as in
|
|
155
|
+
* the original catalog; aria-disabled and the press guard prevent its removal.
|
|
156
|
+
* The caller owns selection and supplies a non-interactive, contain-fit preview.
|
|
157
|
+
*/
|
|
158
|
+
export function TemplateCatalogCard({ title, preview, isSelected = false, isSelectionLocked = false,
|
|
159
|
+
onToggle, onOpen, templateId, testId,
|
|
160
|
+
}: {
|
|
161
|
+
title: string
|
|
162
|
+
preview: ReactNode
|
|
163
|
+
isSelected?: boolean
|
|
164
|
+
isSelectionLocked?: boolean
|
|
165
|
+
onToggle?: () => void
|
|
166
|
+
/** Browsing mode opens a preview, without announcing a toggle state. */
|
|
167
|
+
onOpen?: () => void
|
|
168
|
+
templateId?: string
|
|
169
|
+
testId?: string
|
|
170
|
+
}) {
|
|
171
|
+
return <Card type="button" selection={isSelected ? 'selected' : 'available'}
|
|
172
|
+
aria-label={title} aria-pressed={onOpen ? undefined : isSelected} aria-disabled={isSelectionLocked}
|
|
173
|
+
data-template-id={templateId} data-testid={testId} style={geometry}
|
|
174
|
+
onPress={() => { if (!isSelectionLocked) { if (onOpen) onOpen(); else onToggle?.() } }}>
|
|
175
|
+
<Title>{title}</Title>
|
|
176
|
+
<Preview aria-hidden="true">{preview}</Preview>
|
|
177
|
+
</Card>
|
|
178
|
+
}
|