@podoba/react 0.0.27 โ†’ 0.0.29

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "type": "module",
5
5
  "description": "podoba React components โ€” React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -16,17 +16,19 @@
16
16
  ".": "./src/index.ts"
17
17
  },
18
18
  "files": [
19
- "src"
19
+ "src",
20
+ "!src/**/*.test.*"
20
21
  ],
21
22
  "publishConfig": {
22
23
  "access": "public"
23
24
  },
24
25
  "scripts": {
26
+ "test": "bun test src",
25
27
  "typecheck": "tsc --build"
26
28
  },
27
29
  "dependencies": {
28
- "@podoba/tokens": "^0.0.27",
29
- "@podoba/tailwind": "^0.0.27",
30
+ "@podoba/tokens": "^0.0.29",
31
+ "@podoba/tailwind": "^0.0.29",
30
32
  "react-aria-components": "1.18.0",
31
33
  "class-variance-authority": "0.7.1",
32
34
  "clsx": "2.1.1",
@@ -30,6 +30,8 @@ export type BrandPageHeaderCrumb = {
30
30
  export type BrandPageHeaderProps = {
31
31
  /** The large greeting / page-title slot (e.g. "Good morning Jonas ๐Ÿ‘‹"). */
32
32
  greeting: ReactNode
33
+ /** Semantic heading level. Use 2 or 3 when the header is nested below a page title. */
34
+ headingLevel?: 1 | 2 | 3
33
35
  /**
34
36
  * gs-style "title to go back": a muted, clickable PARENT link rendered as the
35
37
  * line ABOVE the title (so the title reads "Parent โŽ Current"). Pass a real
@@ -63,6 +65,7 @@ const PANEL_ID = 'brand-page-header-create-hub'
63
65
 
64
66
  export function BrandPageHeader({
65
67
  greeting,
68
+ headingLevel = 1,
66
69
  parentLink,
67
70
  breadcrumbs,
68
71
  cta,
@@ -78,6 +81,7 @@ export function BrandPageHeader({
78
81
  const isControlled = expandedProp !== undefined
79
82
  const expanded = isControlled ? expandedProp : internalExpanded
80
83
  const hasExpandable = Boolean(createHub)
84
+ const Heading = `h${headingLevel}` as const
81
85
 
82
86
  const setExpanded = (next: boolean) => {
83
87
  if (!isControlled) setInternalExpanded(next)
@@ -116,7 +120,7 @@ export function BrandPageHeader({
116
120
  ))}
117
121
  </nav>
118
122
  ) : null}
119
- <h1 className="text-display font-medium leading-[1.12] tracking-wide text-fg">
123
+ <Heading className="text-display font-medium leading-[1.12] tracking-wide text-fg">
120
124
  {parentLink ? (
121
125
  // gs "title to go back": muted clickable parent line above the title.
122
126
  // `[&_a]` styles the nested router <Link> (anchor) without @app/ui
@@ -129,7 +133,7 @@ export function BrandPageHeader({
129
133
  </>
130
134
  ) : null}
131
135
  {greeting}
132
- </h1>
136
+ </Heading>
133
137
  </div>
134
138
 
135
139
  {cta ? (
@@ -8,15 +8,16 @@ import type { ReactNode } from 'react'
8
8
  * translate each per-locale while keeping the middle-word highlight. Presentational
9
9
  * only (hard rule #1): every string arrives via props โ€” no i18n, no domain data.
10
10
  *
11
- * a11y NOTE: the background is the light brand-green (#6eddb1); the emphasised word is
12
- * rendered WHITE per the gs design โ€” white-on-green is ~1.66:1, below WCAG AA, so it
13
- * reads as a decorative highlight (it also carries a bold weight), not load-bearing
14
- * copy. The surrounding text stays on the dark `fg` (~10:1).
11
+ * a11y NOTE: the background is the FIXED light brand-secondary (#6eddb1) โ€” it does
12
+ * not flip with the theme, so every sentence fragment sits on `fg-on-brand` (stable
13
+ * dark ink, ~11:1 in both themes). The gs design's white emphasis word failed WCAG AA
14
+ * (~1.66:1) and turned the whole sentence white-on-green in a dark shell; the
15
+ * emphasis is now carried by weight alone.
15
16
  */
16
17
  export interface CtaPillProps {
17
18
  /** Regular lead-in before the emphasised word (e.g. "Let's"). */
18
19
  lead: ReactNode
19
- /** The emphasised bold, white middle word (e.g. "create"). */
20
+ /** The emphasised bold middle word (e.g. "create"). */
20
21
  emphasis: ReactNode
21
22
  /** Regular trailing text after the emphasis (e.g. "something"). */
22
23
  tail: ReactNode
@@ -27,8 +28,8 @@ export interface CtaPillProps {
27
28
  export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
28
29
  return (
29
30
  <div className="flex w-full items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
30
- <p className="min-w-0 text-heading4 font-medium leading-[22px] tracking-normal text-fg">
31
- {lead} <span className="font-semibold text-white">{emphasis}</span> {tail}
31
+ <p className="min-w-0 text-heading4 font-medium leading-[22px] tracking-normal text-fg-on-brand">
32
+ {lead} <span className="font-semibold">{emphasis}</span> {tail}
32
33
  </p>
33
34
  <div className="shrink-0">{children}</div>
34
35
  </div>
@@ -0,0 +1,193 @@
1
+ import { useMemo, useState, type ReactNode } from 'react'
2
+ import { ChevronDownIcon, ChevronUpIcon } from './icons'
3
+
4
+ /**
5
+ * Table โ€” the design-system data table (port of gs-platform's `GSTable`). A light,
6
+ * presentational grid: a header row of (optionally sortable) column labels over a
7
+ * body of rows. Rows can be clickable (whole-row press โ†’ `onRowClick`, keyboard
8
+ * accessible). Sorting is CLIENT-SIDE and OPTIONAL: enable it with `enableSorting`
9
+ * and mark the sortable columns; clicking a sortable header cycles asc โ†’ desc.
10
+ *
11
+ * Presentational only (hard rule #3): no data fetching, no domain coupling. The
12
+ * caller supplies `columns` (how to render + sort each cell) and `data` (the rows).
13
+ * Styling = Tailwind + design-token CSS vars, matching the rest of podoba.
14
+ */
15
+ export type TableAlign = 'left' | 'right' | 'center'
16
+
17
+ export type TableColumn<Row> = {
18
+ /** Stable column id (also the default sort key). */
19
+ key: string
20
+ /** Header label. */
21
+ header: ReactNode
22
+ /** Cell renderer. Defaults to `String(row[key])` when omitted. */
23
+ render?: (row: Row) => ReactNode
24
+ /** Whether this column participates in sorting (needs `enableSorting` on the table). */
25
+ sortable?: boolean
26
+ /**
27
+ * Value used to sort this column (string / number). Defaults to the raw
28
+ * `row[key]` when the row is an object, else the stringified render output.
29
+ */
30
+ sortValue?: (row: Row) => string | number
31
+ /** Horizontal alignment of the header + cells (default `left`). */
32
+ align?: TableAlign
33
+ /** Optional fixed width (CSS length, e.g. `'12rem'` or `'30%'`). */
34
+ width?: string
35
+ }
36
+
37
+ export type TableProps<Row> = {
38
+ columns: TableColumn<Row>[]
39
+ data: Row[]
40
+ /** Stable per-row key. Defaults to the row index (fine for static lists). */
41
+ getRowKey?: (row: Row, index: number) => string
42
+ /** Enable client-side sorting on `sortable` columns. */
43
+ enableSorting?: boolean
44
+ /** Whole-row press handler โ€” renders rows as interactive (hover + keyboard). */
45
+ onRowClick?: (row: Row) => void
46
+ /** Message shown in place of the body when `data` is empty. */
47
+ emptyMessage?: ReactNode
48
+ /** Accessible name for the table. */
49
+ 'aria-label'?: string
50
+ className?: string
51
+ }
52
+
53
+ const ALIGN_CLASS: Record<TableAlign, string> = {
54
+ left: 'text-left',
55
+ right: 'text-right',
56
+ center: 'text-center',
57
+ }
58
+
59
+ function defaultSortValue<Row>(column: TableColumn<Row>, row: Row): string | number {
60
+ if (column.sortValue) return column.sortValue(row)
61
+ if (row && typeof row === 'object' && column.key in row) {
62
+ const raw = (row as Record<string, unknown>)[column.key]
63
+ if (typeof raw === 'number' || typeof raw === 'string') return raw
64
+ }
65
+ return ''
66
+ }
67
+
68
+ export function Table<Row>({
69
+ columns,
70
+ data,
71
+ getRowKey,
72
+ enableSorting = false,
73
+ onRowClick,
74
+ emptyMessage = 'No rows.',
75
+ className,
76
+ ...aria
77
+ }: TableProps<Row>) {
78
+ const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null)
79
+
80
+ const toggleSort = (key: string) => {
81
+ setSort((prev) => {
82
+ if (prev?.key !== key) return { key, dir: 'asc' }
83
+ return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
84
+ })
85
+ }
86
+
87
+ const sorted = useMemo(() => {
88
+ if (!enableSorting || !sort) return data
89
+ const column = columns.find((c) => c.key === sort.key)
90
+ if (!column) return data
91
+ const dir = sort.dir === 'asc' ? 1 : -1
92
+ // Copy before sort โ€” never mutate the caller's array.
93
+ return [...data].sort((a, b) => {
94
+ const av = defaultSortValue(column, a)
95
+ const bv = defaultSortValue(column, b)
96
+ if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
97
+ return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir
98
+ })
99
+ }, [data, columns, enableSorting, sort])
100
+
101
+ const rowKey = getRowKey ?? ((_row: Row, index: number) => String(index))
102
+ const interactive = Boolean(onRowClick)
103
+
104
+ return (
105
+ <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">
109
+ {columns.map((column) => {
110
+ const align = column.align ?? 'left'
111
+ const canSort = enableSorting && column.sortable
112
+ const active = sort?.key === column.key
113
+ return (
114
+ <th
115
+ key={column.key}
116
+ scope="col"
117
+ style={column.width ? { width: column.width } : undefined}
118
+ aria-sort={
119
+ canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
120
+ }
121
+ className={`${ALIGN_CLASS[align]} px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase`}
122
+ >
123
+ {canSort ? (
124
+ <button
125
+ type="button"
126
+ onClick={() => toggleSort(column.key)}
127
+ className={`inline-flex items-center gap-1 outline-none transition-colors hover:text-fg focus-visible:text-fg ${
128
+ align === 'right' ? 'flex-row-reverse' : ''
129
+ } ${active ? 'text-fg' : ''}`}
130
+ >
131
+ {column.header}
132
+ {active ? (
133
+ sort?.dir === 'asc' ? (
134
+ <ChevronUpIcon className="h-3.5 w-3.5" />
135
+ ) : (
136
+ <ChevronDownIcon className="h-3.5 w-3.5" />
137
+ )
138
+ ) : null}
139
+ </button>
140
+ ) : (
141
+ column.header
142
+ )}
143
+ </th>
144
+ )
145
+ })}
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ {sorted.length === 0 ? (
150
+ <tr>
151
+ <td colSpan={columns.length} className="px-4 py-10 text-center text-small text-fg-muted">
152
+ {emptyMessage}
153
+ </td>
154
+ </tr>
155
+ ) : (
156
+ sorted.map((row, index) => (
157
+ <tr
158
+ key={rowKey(row, index)}
159
+ {...(interactive
160
+ ? {
161
+ tabIndex: 0,
162
+ role: 'button',
163
+ onClick: () => onRowClick?.(row),
164
+ onKeyDown: (event: React.KeyboardEvent) => {
165
+ if (event.key === 'Enter' || event.key === ' ') {
166
+ event.preventDefault()
167
+ onRowClick?.(row)
168
+ }
169
+ },
170
+ }
171
+ : {})}
172
+ className={`border-b border-border/60 outline-none ${
173
+ interactive
174
+ ? '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
+ : ''
176
+ }`}
177
+ >
178
+ {columns.map((column) => {
179
+ const align = column.align ?? 'left'
180
+ return (
181
+ <td key={column.key} className={`${ALIGN_CLASS[align]} px-4 py-3 align-middle text-fg`}>
182
+ {column.render ? column.render(row) : String(defaultSortValue(column, row))}
183
+ </td>
184
+ )
185
+ })}
186
+ </tr>
187
+ ))
188
+ )}
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ )
193
+ }
@@ -31,10 +31,14 @@ export type TileAlign = 'top' | 'center'
31
31
  const THEME: Record<TileTheme, string> = {
32
32
  // Light tiles carry a visible hairline; dark/colored use a transparent border so
33
33
  // every tile keeps the same box size (no 1px shift between themes).
34
+ // Every surface pairs with ITS semantic foreground (never raw white / bare fg):
35
+ // `surface-inverted` flips with the theme so it needs the flipping `fg-inverted`;
36
+ // teal/yellow are FIXED light surfaces so they need the stable `fg-on-brand` ink
37
+ // (theme-flipping `fg` went white-on-pastel in a dark shell).
34
38
  light: 'border border-border bg-surface-card text-fg',
35
- dark: 'border border-transparent bg-surface-inverted text-white',
36
- teal: 'border border-transparent bg-brand-secondary text-fg',
37
- yellow: 'border border-transparent bg-accent-yellow text-fg',
39
+ dark: 'border border-transparent bg-surface-inverted text-fg-inverted',
40
+ teal: 'border border-transparent bg-brand-secondary text-fg-on-brand',
41
+ yellow: 'border border-transparent bg-accent-yellow text-fg-on-brand',
38
42
  }
39
43
 
40
44
  // The shared inner for EVERY tile (gs `DashboardContentCard`): 16px padding
@@ -95,10 +99,23 @@ export type TileProps = {
95
99
  export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
96
100
 
97
101
  function titleTone(theme: TileTheme, subtle: boolean): string {
98
- if (theme === 'dark') return subtle ? 'text-white/60' : 'text-white'
102
+ if (theme === 'dark') return subtle ? 'text-fg-inverted/60' : 'text-fg-inverted'
103
+ if (theme === 'teal' || theme === 'yellow') return subtle ? 'text-fg-on-brand/70' : 'text-fg-on-brand'
99
104
  return subtle ? 'text-fg-muted' : 'text-fg'
100
105
  }
101
106
 
107
+ /**
108
+ * Muted supporting text / footer tone per surface. `fg-muted` is tuned for white โ€”
109
+ * on the colored surfaces it drops below AA, so those blend their own paired ink
110
+ * instead (/60 ยท /70 keep โ‰ฅ4.5:1 in both themes โ€” verified by the contrast test
111
+ * in @podoba/tokens).
112
+ */
113
+ function mutedTone(theme: TileTheme): string {
114
+ if (theme === 'dark') return 'text-fg-inverted/60'
115
+ if (theme === 'teal' || theme === 'yellow') return 'text-fg-on-brand/70'
116
+ return 'text-fg-muted'
117
+ }
118
+
102
119
  /** head / content / tail bands โ€” the normal (non-media) layout. */
103
120
  function TileBands({
104
121
  theme,
@@ -123,7 +140,7 @@ function TileBands({
123
140
  ) : null
124
141
  const textNode =
125
142
  text != null ? (
126
- <p className={`text-small leading-4 ${theme === 'dark' ? 'text-white/60' : 'text-fg-muted'} ${title != null ? 'mt-3' : ''}`}>
143
+ <p className={`text-small leading-4 ${mutedTone(theme)} ${title != null ? 'mt-3' : ''}`}>
127
144
  {text}
128
145
  </p>
129
146
  ) : null
@@ -153,8 +170,9 @@ function TileBands({
153
170
  }
154
171
 
155
172
  // Footer defaults to 14px muted (gs supporting text); self-styled footer content
156
- // (links, buttons) overrides both size and colour.
157
- const footerTone = theme === 'dark' ? 'text-white/50' : theme === 'light' ? 'text-fg-muted' : 'text-fg/70'
173
+ // (links, buttons) overrides both size and colour. Shares `mutedTone` โ€” the old
174
+ // white/50 (dark) and fg/70 (teal/yellow) both broke AA in a dark shell.
175
+ const footerTone = mutedTone(theme)
158
176
 
159
177
  return (
160
178
  <>
@@ -197,7 +215,7 @@ function MediaHero({
197
215
 
198
216
  return (
199
217
  <div
200
- className={['relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
218
+ className={['@container relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
201
219
  .filter(Boolean)
202
220
  .join(' ')}
203
221
  >
@@ -239,20 +257,36 @@ function MediaHero({
239
257
  />
240
258
  ) : null}
241
259
 
242
- {eyebrow ? (
243
- <span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">{eyebrow}</span>
244
- ) : null}
245
-
246
- {/* Content is click-through so pressing the title/badge falls to the
247
- press overlay; only `footer` re-enables its own pointer events. */}
248
- <div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
249
- {title ? <h2 className="max-w-md text-display font-medium leading-tight text-white">{title}</h2> : null}
250
- {footer != null || badge != null ? (
251
- <div className="flex items-center justify-between gap-4">
252
- <span className="pointer-events-auto">{footer}</span>
253
- <span>{badge}</span>
254
- </div>
260
+ {/* Keep every text overlay in one flex flow. The old independent
261
+ top eyebrow and bottom title bands could overlap when a long title
262
+ wrapped on a narrow card. Compact mobile insets/type preserve the
263
+ 200px floor; the spacious composition returns when the card itself,
264
+ rather than merely the viewport, is wide enough. */}
265
+ <div
266
+ data-slot="media-overlay"
267
+ className="pointer-events-none absolute inset-4 z-10 flex min-h-0 flex-col @sm:inset-6"
268
+ >
269
+ {eyebrow ? (
270
+ <span data-slot="media-eyebrow" className="shrink-0 text-compact font-medium text-white">
271
+ {eyebrow}
272
+ </span>
255
273
  ) : null}
274
+
275
+ {/* Content is click-through so pressing the title/badge falls to the
276
+ press overlay; only `footer` re-enables its own pointer events. */}
277
+ <div data-slot="media-content" className="mt-auto flex min-h-0 flex-col gap-2 @sm:gap-4">
278
+ {title ? (
279
+ <h2 className="max-w-md text-title font-medium leading-tight text-white @sm:text-display">
280
+ {title}
281
+ </h2>
282
+ ) : null}
283
+ {footer != null || badge != null ? (
284
+ <div className="flex items-center justify-between gap-3 @sm:gap-4">
285
+ <span className="pointer-events-auto">{footer}</span>
286
+ <span>{badge}</span>
287
+ </div>
288
+ ) : null}
289
+ </div>
256
290
  </div>
257
291
  </>
258
292
  )}
@@ -350,10 +384,14 @@ export type BadgeColor = 'green' | 'yellow' | 'grey' | 'dark'
350
384
  const BADGE: Record<BadgeColor, string> = {
351
385
  // gs `GSTag` green = the brand green (not the pale accent-mint). One saturated
352
386
  // brand green for every green tag (New, Active, Published), matching gs 1:1.
353
- green: 'bg-brand-green text-fg',
354
- yellow: 'bg-accent-yellow text-fg',
355
- grey: 'bg-surface-muted text-fg-muted',
356
- dark: 'bg-surface-inverted text-white',
387
+ // Pairing rule (same as Tile THEME): fixed light surfaces take the stable
388
+ // `fg-on-brand` ink, the flipping inverted surface takes `fg-inverted`, and grey
389
+ // takes full `fg` โ€” `fg-muted` on `surface-muted` was ~3.7:1, under the 4.5:1
390
+ // AA floor for caption-size text.
391
+ green: 'bg-brand-green text-fg-on-brand',
392
+ yellow: 'bg-accent-yellow text-fg-on-brand',
393
+ grey: 'bg-surface-muted text-fg',
394
+ dark: 'bg-surface-inverted text-fg-inverted',
357
395
  }
358
396
 
359
397
  export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export * from "./components/icons";
60
60
  export * from "./components/subtle";
61
61
  export * from "./components/stats-card";
62
62
  export * from "./components/tile";
63
+ export * from "./components/table";
63
64
  export * from "./components/dashboard-grid";
64
65
  export * from "./components/task-approval-modal";
65
66
  export * from "./components/request-changes-modal";