@podoba/react 0.0.13 → 0.0.15
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/brand-page-header.tsx +1 -1
- package/src/components/context-menu.tsx +2 -2
- package/src/components/cta-pill.tsx +36 -0
- package/src/components/stats-card.tsx +24 -10
- package/src/components/subtle.tsx +21 -0
- package/src/components/tile.tsx +120 -0
- package/src/index.ts +3 -0
- package/src/layout/card.tsx +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.15",
|
|
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.15",
|
|
29
|
+
"@podoba/tailwind": "^0.0.15",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -116,7 +116,7 @@ export function BrandPageHeader({
|
|
|
116
116
|
))}
|
|
117
117
|
</nav>
|
|
118
118
|
) : null}
|
|
119
|
-
<h1 className="text-display font-medium leading-[1.12] tracking-
|
|
119
|
+
<h1 className="text-display font-medium leading-[1.12] tracking-wide text-fg">
|
|
120
120
|
{parentLink ? (
|
|
121
121
|
// gs "title to go back": muted clickable parent line above the title.
|
|
122
122
|
// `[&_a]` styles the nested router <Link> (anchor) without @app/ui
|
|
@@ -126,7 +126,7 @@ const MenuItem = uic(RACMenuItem, {
|
|
|
126
126
|
// gs `.panel`: 258px, radius 12px, 8px/6px padding, 180deg #242424→#1f1f1f
|
|
127
127
|
// gradient, 0 12px 28px rgba(0,0,0,.22) shadow. No token covers the gradient/shadow.
|
|
128
128
|
const panelClass =
|
|
129
|
-
'w-[258px] max-w-[calc(100vw-16px)] rounded-
|
|
129
|
+
'w-[258px] max-w-[calc(100vw-16px)] rounded-lg bg-gradient-to-b from-[#242424] to-[#1f1f1f] ' +
|
|
130
130
|
'px-1.5 py-2 shadow-[0_12px_28px_rgba(0,0,0,0.22)] outline-none'
|
|
131
131
|
const menuClass = 'grid max-h-[calc(100vh-16px)] gap-0.5 overflow-y-auto outline-none'
|
|
132
132
|
|
|
@@ -178,7 +178,7 @@ function ContextMenuBody({
|
|
|
178
178
|
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
|
179
179
|
{badge ? (
|
|
180
180
|
// gs `.badge`: 24px pill, #333437 bg / #c8c8ca text, 10px (text-micro).
|
|
181
|
-
<span className="ml-auto inline-flex h-6 items-center justify-center rounded-full bg-[#333437] px-3 py-0.5 text-micro font-medium leading-5 tracking-
|
|
181
|
+
<span className="ml-auto inline-flex h-6 items-center justify-center rounded-full bg-[#333437] px-3 py-0.5 text-micro font-medium leading-5 tracking-tight text-[#c8c8ca]">
|
|
182
182
|
{badge}
|
|
183
183
|
</span>
|
|
184
184
|
) : null}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CtaPill — the gs Brand Core hero CTA: a GS-green bar reading "Let's create
|
|
5
|
+
* something" (the middle word emphasised) with an action control on the right.
|
|
6
|
+
*
|
|
7
|
+
* The copy is three fragments (`lead` · `emphasis` · `tail`) so a consumer can
|
|
8
|
+
* translate each per-locale while keeping the middle-word highlight. Presentational
|
|
9
|
+
* only (hard rule #1): every string arrives via props — no i18n, no domain data.
|
|
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).
|
|
15
|
+
*/
|
|
16
|
+
export interface CtaPillProps {
|
|
17
|
+
/** Regular lead-in before the emphasised word (e.g. "Let's"). */
|
|
18
|
+
lead: ReactNode
|
|
19
|
+
/** The emphasised bold, white middle word (e.g. "create"). */
|
|
20
|
+
emphasis: ReactNode
|
|
21
|
+
/** Regular trailing text after the emphasis (e.g. "something"). */
|
|
22
|
+
tail: ReactNode
|
|
23
|
+
/** Right-side action control (e.g. a Create button). */
|
|
24
|
+
children: ReactNode
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function CtaPill({ lead, emphasis, tail, children }: CtaPillProps) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex min-w-[560px] items-center justify-between gap-4 rounded-lg bg-brand-secondary px-6 py-4">
|
|
30
|
+
<p className="text-subtitle font-medium leading-[22px] tracking-normal text-fg">
|
|
31
|
+
{lead} <span className="font-semibold text-white">{emphasis}</span> {tail}
|
|
32
|
+
</p>
|
|
33
|
+
<div className="shrink-0">{children}</div>
|
|
34
|
+
</div>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
@@ -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`
|
|
@@ -73,7 +85,7 @@ function StatsCardBody({
|
|
|
73
85
|
* `-0.02em` (`AssetsTile.module.scss .count`).
|
|
74
86
|
*/}
|
|
75
87
|
<span
|
|
76
|
-
className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-
|
|
88
|
+
className={`flex flex-1 items-center text-[1.875rem] font-medium leading-[2rem] ${dark ? 'tracking-normal text-white' : 'tracking-wide text-fg'}`}
|
|
77
89
|
>
|
|
78
90
|
{value}
|
|
79
91
|
</span>
|
|
@@ -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,21 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
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:
|
|
8
|
+
*
|
|
9
|
+
* <h1>
|
|
10
|
+
* <Subtle>Good afternoon</Subtle> {name} 👋
|
|
11
|
+
* <br />
|
|
12
|
+
* <Subtle>You have</Subtle> {count} planned tasks <Subtle>today</Subtle>
|
|
13
|
+
* </h1>
|
|
14
|
+
*
|
|
15
|
+
* With i18n, put the `<subtle>…</subtle>` runs INSIDE the translation string and render
|
|
16
|
+
* it via `<Trans components={{ subtle: <Subtle /> }} />`, so each locale places the
|
|
17
|
+
* emphasis where its own word order needs it. Presentational only (hard rule #1).
|
|
18
|
+
*/
|
|
19
|
+
export function Subtle({ children }: { children: ReactNode }) {
|
|
20
|
+
return <span className="text-fg-subtle">{children}</span>
|
|
21
|
+
}
|
|
@@ -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-lg 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
|
@@ -52,7 +52,10 @@ export * from "./layout/persistent-page-shell";
|
|
|
52
52
|
|
|
53
53
|
// --- product patterns (label-driven; no domain coupling) ---
|
|
54
54
|
export * from "./components/brand-page-header";
|
|
55
|
+
export * from "./components/cta-pill";
|
|
56
|
+
export * from "./components/subtle";
|
|
55
57
|
export * from "./components/stats-card";
|
|
58
|
+
export * from "./components/tile";
|
|
56
59
|
export * from "./components/dashboard-grid";
|
|
57
60
|
export * from "./components/task-approval-modal";
|
|
58
61
|
export * from "./components/request-changes-modal";
|
package/src/layout/card.tsx
CHANGED
|
@@ -4,9 +4,8 @@ import { uic } from '../utils/uic'
|
|
|
4
4
|
* Card — surface panel (port of gs-manager's `Card`).
|
|
5
5
|
*
|
|
6
6
|
* gs SCSS → our tokens (Tailwind + `@app/tokens` CSS vars, no SCSS — hard rule #3):
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
* used `--radius-lg`, so this maps to `rounded-lg`.
|
|
7
|
+
* - `rounded-lg` (`--radius-lg` = 0.5rem = 8px) — the single unified card/panel
|
|
8
|
+
* radius across the design system (the old 1.1rem soft-panel radius was retired).
|
|
10
9
|
* - `--color-background-secondary` (the light card fill) → `bg-surface-card`
|
|
11
10
|
* (our `--color-surface-card` = #f7f6f2).
|
|
12
11
|
* - `outlined` → 1px `--color-border` (#eceae1) → `border border-border`.
|