@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 +3 -3
- package/src/components/stats-card.tsx +23 -9
- package/src/components/tile.tsx +120 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
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
|
-
<
|
|
62
|
-
className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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";
|