@podoba/react 0.0.13 → 0.0.14

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.13",
3
+ "version": "0.0.14",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --build"
26
26
  },
27
27
  "dependencies": {
28
- "@podoba/tokens": "^0.0.13",
29
- "@podoba/tailwind": "^0.0.13",
28
+ "@podoba/tokens": "^0.0.14",
29
+ "@podoba/tailwind": "^0.0.14",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -22,6 +22,10 @@ export type StatsCardProps = {
22
22
  value: ReactNode
23
23
  /** Optional supporting footer line (e.g. "+3 this week"). */
24
24
  footer?: ReactNode
25
+ /** Optional status pill shown at the end of the title row (e.g. a `<Badge>`). */
26
+ badge?: ReactNode
27
+ /** Optional muted line under the title (e.g. "Plan sections, tasks and outputs"). */
28
+ description?: ReactNode
25
29
  /** When provided the card becomes a clickable React Aria Button. */
26
30
  onPress?: () => void
27
31
  /**
@@ -54,15 +58,23 @@ function StatsCardBody({
54
58
  title,
55
59
  value,
56
60
  footer,
61
+ badge,
62
+ description,
57
63
  dark,
58
- }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'dark'>) {
64
+ }: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'badge' | 'description' | 'dark'>) {
59
65
  return (
60
66
  <>
61
- <span
62
- className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}
63
- >
64
- {title}
65
- </span>
67
+ <div className="flex items-center justify-between gap-2">
68
+ <span className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}>
69
+ {title}
70
+ </span>
71
+ {badge ?? null}
72
+ </div>
73
+ {description ? (
74
+ <span className={`text-compact leading-4 ${dark ? 'text-white/60' : 'text-fg-muted'}`}>
75
+ {description}
76
+ </span>
77
+ ) : null}
66
78
  {/*
67
79
  * gs Tile `contentAlign="center"`: the value sits in the CENTRE band of the
68
80
  * three-band tile (title top / value centred / footer bottom). The `flex-1`
@@ -108,6 +120,8 @@ export function StatsCard({
108
120
  title,
109
121
  value,
110
122
  footer,
123
+ badge,
124
+ description,
111
125
  onPress,
112
126
  asChild,
113
127
  children,
@@ -122,7 +136,7 @@ export function StatsCard({
122
136
  const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
123
137
  .filter(Boolean)
124
138
  .join(' ')
125
- return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} dark={dark} />)
139
+ return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />)
126
140
  }
127
141
 
128
142
  if (onPress) {
@@ -134,13 +148,13 @@ export function StatsCard({
134
148
  className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
135
149
  aria-label={rest['aria-label']}
136
150
  >
137
- <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
151
+ <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />
138
152
  </PressableCard>
139
153
  )
140
154
  }
141
155
  return (
142
156
  <div className={[baseSurface, dark ? darkSkin : lightSkin, className].filter(Boolean).join(' ')}>
143
- <StatsCardBody title={title} value={value} footer={footer} dark={dark} />
157
+ <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} />
144
158
  </div>
145
159
  )
146
160
  }
@@ -0,0 +1,120 @@
1
+ import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
2
+
3
+ /**
4
+ * Tile — the canonical dashboard CARD (port of gs-platform's `Tile`).
5
+ *
6
+ * Layout: `head` (label row, optional trailing badge) · `children` (the big
7
+ * content band, fills + top-aligned) · `tail` (footer — links, avatars). 8px
8
+ * panel radius, generous padding, row-gap. Four themes mirror gs's Figma palette
9
+ * via the design tokens: light (surface-card), dark (inverted, light text), teal
10
+ * (brand green), yellow (accent).
11
+ *
12
+ * Router-agnostic link (hard rule #1 — podoba never imports a router): pass a
13
+ * link ELEMENT as `link` (e.g. a `<Link/>` from your router, self-closing). The
14
+ * tile clones it, merges the card styling + a hover-lift onto it, and injects the
15
+ * tile body as its children — so the whole tile is a real anchor (cmd/middle-click,
16
+ * open in new tab) without podoba knowing about any router. `children` stays the
17
+ * content band (unlike StatsCard's `asChild`, because a Tile's children is content).
18
+ *
19
+ * Presentational only: all copy via props, no API/i18n.
20
+ */
21
+ export type TileTheme = 'light' | 'dark' | 'teal' | 'yellow'
22
+
23
+ const THEME: Record<TileTheme, string> = {
24
+ light: 'bg-surface-card text-fg',
25
+ dark: 'bg-surface-inverted text-white',
26
+ teal: 'bg-brand-secondary text-fg',
27
+ yellow: 'bg-accent-yellow text-fg',
28
+ }
29
+
30
+ /** Hover-lift + focus ring applied only when the tile is a link. */
31
+ const linkInteractive =
32
+ 'block no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
33
+ 'hover:-translate-y-1 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring'
34
+
35
+ export type TileProps = {
36
+ /** Surface theme (gs's Figma palette). */
37
+ theme?: TileTheme
38
+ /** Label row at the top (optionally a trailing `<Badge>`). */
39
+ head?: ReactNode
40
+ /** The big content band — grows to fill, top-aligned. */
41
+ children?: ReactNode
42
+ /** Footer band (links, avatars, meta). */
43
+ tail?: ReactNode
44
+ /**
45
+ * A link ELEMENT to render the whole tile as (e.g. a router `<Link/>`). The tile
46
+ * styling + hover-lift merge onto it and the tile body becomes its children.
47
+ */
48
+ link?: ReactElement
49
+ className?: string
50
+ }
51
+
52
+ /** The head/content/tail bands, shared by the plain + linked renders. */
53
+ function TileBody({ head, children, tail }: Pick<TileProps, 'head' | 'children' | 'tail'>) {
54
+ return (
55
+ <>
56
+ {head ? (
57
+ <div className="flex items-center justify-between gap-2 text-body font-medium leading-5">
58
+ {head}
59
+ </div>
60
+ ) : null}
61
+ <div className="flex min-h-0 flex-1 flex-col items-start">{children}</div>
62
+ {tail ? <div className="min-w-0">{tail}</div> : null}
63
+ </>
64
+ )
65
+ }
66
+
67
+ const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 rounded-panel p-6'
68
+
69
+ export function Tile({ theme = 'light', head, children, tail, link, className }: TileProps) {
70
+ const surface = [baseSurface, THEME[theme], className].filter(Boolean).join(' ')
71
+
72
+ // Link element supplied → render the tile AS that element (real anchor semantics).
73
+ if (link && isValidElement(link)) {
74
+ const el = link as ReactElement<{ className?: string }>
75
+ const cardClass = [surface, linkInteractive, el.props.className].filter(Boolean).join(' ')
76
+ return cloneElement(el, { className: cardClass } as { className: string }, (
77
+ <TileBody head={head} tail={tail}>
78
+ {children}
79
+ </TileBody>
80
+ ))
81
+ }
82
+
83
+ return (
84
+ <div className={surface}>
85
+ <TileBody head={head} tail={tail}>
86
+ {children}
87
+ </TileBody>
88
+ </div>
89
+ )
90
+ }
91
+
92
+ /**
93
+ * The big "stat" content typography used inside a tile's content band (gs `.content`:
94
+ * 28px / 500 / tight tracking). Apply to the element wrapping a tile's headline value.
95
+ */
96
+ export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
97
+
98
+ /**
99
+ * Badge — a small presentational status pill (gs `GSTag`). NOTE: this is a plain
100
+ * label pill, distinct from podoba's interactive React-Aria `Tag` (tag-group.tsx);
101
+ * named `Badge` to avoid the collision.
102
+ */
103
+ export type BadgeColor = 'green' | 'yellow' | 'grey' | 'dark'
104
+
105
+ const BADGE: Record<BadgeColor, string> = {
106
+ green: 'bg-accent-mint text-fg',
107
+ yellow: 'bg-accent-yellow text-fg',
108
+ grey: 'bg-surface-muted text-fg-muted',
109
+ dark: 'bg-surface-inverted text-white',
110
+ }
111
+
112
+ export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
113
+ return (
114
+ <span
115
+ className={`inline-flex items-center rounded-full px-2 py-0.5 text-caption font-medium leading-none ${BADGE[color]}`}
116
+ >
117
+ {label}
118
+ </span>
119
+ )
120
+ }
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export * from "./layout/persistent-page-shell";
53
53
  // --- product patterns (label-driven; no domain coupling) ---
54
54
  export * from "./components/brand-page-header";
55
55
  export * from "./components/stats-card";
56
+ export * from "./components/tile";
56
57
  export * from "./components/dashboard-grid";
57
58
  export * from "./components/task-approval-modal";
58
59
  export * from "./components/request-changes-modal";