@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.
Files changed (70) hide show
  1. package/package.json +3 -3
  2. package/src/components/asset-masonry-grid.examples.tsx +9 -0
  3. package/src/components/asset-masonry-grid.tsx +62 -0
  4. package/src/components/asset-selection-surface.examples.tsx +32 -0
  5. package/src/components/asset-selection-surface.tsx +184 -0
  6. package/src/components/brand-page-header.tsx +297 -67
  7. package/src/components/button.tsx +27 -4
  8. package/src/components/combobox.tsx +1 -1
  9. package/src/components/compact-action-button.examples.tsx +7 -0
  10. package/src/components/compact-action-button.tsx +21 -0
  11. package/src/components/compact-settings-dialog.examples.tsx +15 -0
  12. package/src/components/compact-settings-dialog.tsx +48 -0
  13. package/src/components/context-action-glyph.examples.tsx +9 -0
  14. package/src/components/context-action-glyph.tsx +61 -0
  15. package/src/components/context-menu.tsx +191 -107
  16. package/src/components/context-search-panel.examples.tsx +9 -0
  17. package/src/components/context-search-panel.tsx +50 -0
  18. package/src/components/csv-binding-presentation.examples.tsx +14 -0
  19. package/src/components/csv-binding-presentation.tsx +40 -0
  20. package/src/components/cta-pill.tsx +19 -4
  21. package/src/components/dashboard-grid.tsx +1 -1
  22. package/src/components/date-picker.tsx +5 -3
  23. package/src/components/date-selection-calendar.examples.tsx +8 -0
  24. package/src/components/date-selection-calendar.tsx +47 -0
  25. package/src/components/delivery/send-to-print-modal.tsx +351 -84
  26. package/src/components/dialog-action-button.examples.tsx +7 -0
  27. package/src/components/dialog-action-button.tsx +22 -0
  28. package/src/components/dialog.tsx +32 -12
  29. package/src/components/document-upload-panel.examples.tsx +12 -0
  30. package/src/components/document-upload-panel.tsx +48 -0
  31. package/src/components/dropdown-menu.tsx +5 -3
  32. package/src/components/empty-panel-action.examples.tsx +7 -0
  33. package/src/components/empty-panel-action.tsx +8 -0
  34. package/src/components/field-appearance.ts +14 -0
  35. package/src/components/focus-field.tsx +3 -3
  36. package/src/components/icons.tsx +30 -0
  37. package/src/components/input.examples.tsx +8 -0
  38. package/src/components/input.tsx +13 -5
  39. package/src/components/media-asset-panel.examples.tsx +11 -0
  40. package/src/components/media-asset-panel.tsx +58 -0
  41. package/src/components/media-gallery.examples.tsx +12 -0
  42. package/src/components/media-gallery.tsx +53 -0
  43. package/src/components/media-settings-dialog.examples.tsx +21 -0
  44. package/src/components/media-settings-dialog.tsx +83 -0
  45. package/src/components/preview-info-card.examples.tsx +18 -0
  46. package/src/components/preview-info-card.tsx +27 -0
  47. package/src/components/reload-icon.examples.tsx +7 -0
  48. package/src/components/reload-icon.tsx +6 -0
  49. package/src/components/request-changes-modal.examples.tsx +26 -0
  50. package/src/components/request-changes-modal.tsx +36 -19
  51. package/src/components/rich-text-editor.tsx +1 -1
  52. package/src/components/select.examples.tsx +17 -0
  53. package/src/components/select.tsx +78 -22
  54. package/src/components/settings-dialog-surface.examples.tsx +44 -0
  55. package/src/components/settings-dialog-surface.tsx +240 -0
  56. package/src/components/side-panel.examples.tsx +80 -0
  57. package/src/components/side-panel.tsx +140 -0
  58. package/src/components/subtle.tsx +64 -8
  59. package/src/components/table.examples.tsx +9 -0
  60. package/src/components/table.tsx +29 -12
  61. package/src/components/template-catalog-card.examples.tsx +25 -0
  62. package/src/components/template-catalog-card.tsx +178 -0
  63. package/src/components/text.tsx +14 -1
  64. package/src/components/textarea.examples.tsx +8 -0
  65. package/src/components/textarea.tsx +16 -6
  66. package/src/components/tile.tsx +16 -17
  67. package/src/editor/block-editor.tsx +56 -14
  68. package/src/index.ts +20 -0
  69. package/src/layout/app-shell.tsx +13 -9
  70. 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 { ReactNode } from 'react'
1
+ import type { ComponentProps } from 'react'
2
+
3
+ import { uic } from '../utils/uic'
2
4
 
3
5
  /**
4
- * Subtle — inline de-emphasised text (the light `fg-subtle` grey, #b3b3b3). Wraps its
5
- * children in a `text-fg-subtle` span; anything OUTSIDE it stays at the surrounding
6
- * full-contrast `fg`. Compose it freely to two-tone arbitrary runs of a line — unlike
7
- * a fixed-slot header it handles mid-line emphasis in ANY order and ANY language:
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 function Subtle({ children }: { children: ReactNode }) {
20
- return <span className="text-fg-subtle">{children}</span>
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.' }
@@ -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="w-full border-collapse text-small" {...aria}>
107
- <thead>
108
- <tr className="border-b border-border">
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="px-4 py-10 text-center text-small text-fg-muted">
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={`border-b border-border/60 outline-none ${
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
+ }