@podoba/react 0.0.22 → 0.0.24
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 +35 -144
- package/src/components/tile.tsx +295 -57
- package/src/index.ts +0 -1
- package/src/components/feature-tile.tsx +0 -140
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.24",
|
|
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.24",
|
|
29
|
+
"@podoba/tailwind": "^0.0.24",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -1,140 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { uic } from '../utils/uic'
|
|
1
|
+
import { type ReactElement, type ReactNode, isValidElement } from 'react'
|
|
2
|
+
import { Tile } from './tile'
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
|
-
* StatsCard —
|
|
5
|
+
* StatsCard — DEPRECATED. Kept as a thin wrapper over the unified {@link Tile} so
|
|
6
|
+
* existing call sites keep working while every tile shares ONE inner. Prefer `<Tile>`
|
|
7
|
+
* directly for new code:
|
|
8
|
+
* title → eyebrow · value → title · description → text · dark → theme="dark" ·
|
|
9
|
+
* align → titleAlign · subtleValue → subtleTitle · asChild+<Link> → link.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
* (small body) inside a spacious cream `Tile` with panel radius. The `dark`
|
|
10
|
-
* variant mirrors gs's dark "Cloud" tile (inverted surface, light text).
|
|
11
|
-
*
|
|
12
|
-
* Presentational only (hard rule #1): all copy via props, no API/i18n.
|
|
13
|
-
*
|
|
14
|
-
* When `onPress` is set the whole card becomes a React Aria `Button` — it gets
|
|
15
|
-
* keyboard activation, `data-[focus-visible]` ring and `data-[pressed]` for
|
|
16
|
-
* free. Otherwise it renders as a plain non-interactive `div`.
|
|
11
|
+
* @deprecated use `<Tile>`.
|
|
17
12
|
*/
|
|
18
13
|
export type StatsCardProps = {
|
|
19
|
-
/** Small
|
|
14
|
+
/** Small label above the value → Tile `eyebrow`. */
|
|
20
15
|
title: ReactNode
|
|
21
|
-
/** The large headline number / value
|
|
16
|
+
/** The large headline number / value → Tile `title`. */
|
|
22
17
|
value: ReactNode
|
|
23
|
-
/** Optional supporting footer line
|
|
18
|
+
/** Optional supporting footer line → Tile `footer`. */
|
|
24
19
|
footer?: ReactNode
|
|
25
|
-
/** Optional status pill
|
|
20
|
+
/** Optional status pill → Tile `badge`. */
|
|
26
21
|
badge?: ReactNode
|
|
27
|
-
/** Optional muted line under the title
|
|
22
|
+
/** Optional muted line under the title → Tile `text`. */
|
|
28
23
|
description?: ReactNode
|
|
29
|
-
/**
|
|
24
|
+
/** Clickable via a press handler → Tile `onPress`. */
|
|
30
25
|
onPress?: () => void
|
|
31
|
-
/**
|
|
32
|
-
* Render the card AS the provided child element (e.g. a router `<Link>`) instead
|
|
33
|
-
* of a button — so the whole tile is a real anchor (cmd/middle-click, open in
|
|
34
|
-
* new tab, link semantics). The card styling + hover/focus are merged onto the
|
|
35
|
-
* child and the title/value/footer are injected as its children. Takes
|
|
36
|
-
* precedence over `onPress`.
|
|
37
|
-
*/
|
|
26
|
+
/** Render AS the provided child element (a router `<Link>`) → Tile `link`. */
|
|
38
27
|
asChild?: boolean
|
|
39
|
-
/** The element to render as the card when `asChild` is set
|
|
28
|
+
/** The element to render as the card when `asChild` is set. */
|
|
40
29
|
children?: ReactNode
|
|
41
|
-
/** Dark/inverted surface
|
|
30
|
+
/** Dark/inverted surface → Tile `theme="dark"`. */
|
|
42
31
|
dark?: boolean
|
|
43
|
-
/**
|
|
44
|
-
* Vertical placement of the value band. `center` (default) is gs's
|
|
45
|
-
* `contentAlign="center"` hero-count tile (value fills + centres, footer pinned
|
|
46
|
-
* bottom). `top` sits the value directly under the eyebrow (gs's dark "Summary"
|
|
47
|
-
* data tile) with the footer pushed to the bottom.
|
|
48
|
-
*/
|
|
32
|
+
/** Value placement → Tile `titleAlign` (default `center`). */
|
|
49
33
|
align?: 'center' | 'top'
|
|
50
|
-
/** Mute the value colour
|
|
34
|
+
/** Mute the value colour → Tile `subtleTitle`. */
|
|
51
35
|
subtleValue?: boolean
|
|
52
|
-
/** Accessible label for the clickable card (defaults to nothing — title is read). */
|
|
53
36
|
'aria-label'?: string
|
|
54
37
|
className?: string
|
|
55
38
|
}
|
|
56
39
|
|
|
57
|
-
// gs Tile: `--radius-lg` (8px) corners, 16px padding, min-height 208px (size
|
|
58
|
-
// "compact"), and it FILLS its grid cell (h-full) so a tall dashboard tile is a
|
|
59
|
-
// tall card. The three bands (title top / value centered / footer bottom) are laid
|
|
60
|
-
// out by StatsCardBody. The "skin" (surface + border) is split out so the dark
|
|
61
|
-
// variant can override it cleanly.
|
|
62
|
-
const baseSurface = 'flex h-full min-h-[208px] flex-col gap-2 rounded-lg p-4 text-left'
|
|
63
|
-
const lightSkin = 'border border-border bg-surface-card'
|
|
64
|
-
const darkSkin = 'border border-transparent bg-surface-inverted'
|
|
65
|
-
|
|
66
|
-
function StatsCardBody({
|
|
67
|
-
title,
|
|
68
|
-
value,
|
|
69
|
-
footer,
|
|
70
|
-
badge,
|
|
71
|
-
description,
|
|
72
|
-
dark,
|
|
73
|
-
align = 'center',
|
|
74
|
-
subtleValue = false,
|
|
75
|
-
}: Pick<StatsCardProps, 'title' | 'value' | 'footer' | 'badge' | 'description' | 'dark' | 'align' | 'subtleValue'>) {
|
|
76
|
-
const isTop = align === 'top'
|
|
77
|
-
const valueTone = dark ? (subtleValue ? 'text-white/60' : 'text-white') : subtleValue ? 'text-fg-muted' : 'text-fg'
|
|
78
|
-
return (
|
|
79
|
-
<>
|
|
80
|
-
<div className="flex items-center justify-between gap-2">
|
|
81
|
-
<span className={`text-body font-medium leading-5 ${dark ? 'text-white' : 'text-fg'}`}>
|
|
82
|
-
{title}
|
|
83
|
-
</span>
|
|
84
|
-
{badge ?? null}
|
|
85
|
-
</div>
|
|
86
|
-
{description ? (
|
|
87
|
-
<span className={`text-compact leading-4 ${dark ? 'text-white/60' : 'text-fg-muted'}`}>
|
|
88
|
-
{description}
|
|
89
|
-
</span>
|
|
90
|
-
) : null}
|
|
91
|
-
{/*
|
|
92
|
-
* `center` (gs `contentAlign="center"`): the value sits in the CENTRE band —
|
|
93
|
-
* `flex-1` grows to fill the tile and vertically centres it, pushing any footer
|
|
94
|
-
* to the bottom. `top`: the value sits directly under the eyebrow (no grow) and
|
|
95
|
-
* the footer takes `mt-auto` to stay pinned at the bottom. Value ramp = gs
|
|
96
|
-
* label-1 (1.875rem / 500 / 2rem line). Letter-spacing tracks gs 1:1: light
|
|
97
|
-
* tiles use -0.56px via `tracking-wide`; the dark count tile uses gs's em-based
|
|
98
|
-
* `-0.02em` (`AssetsTile.module.scss .count`).
|
|
99
|
-
*/}
|
|
100
|
-
<span
|
|
101
|
-
className={`flex ${isTop ? 'items-start pt-5' : 'flex-1 items-center'} text-display font-medium leading-[2rem] ${dark ? 'tracking-normal' : 'tracking-wide'} ${valueTone}`}
|
|
102
|
-
>
|
|
103
|
-
{/*
|
|
104
|
-
* Inner wrapper is deliberate: the band is a flex container, and flex
|
|
105
|
-
* TRIMS the leading whitespace of an anonymous text run — so a value of
|
|
106
|
-
* `<CountUp/> {' '} unit` collapses to "1version". Nesting the value in one
|
|
107
|
-
* inline span makes it a single flex item, preserving the internal space.
|
|
108
|
-
*/}
|
|
109
|
-
<span>{value}</span>
|
|
110
|
-
</span>
|
|
111
|
-
{footer ? (
|
|
112
|
-
<span className={`${isTop ? 'mt-auto ' : ''}pt-4 text-small leading-5 ${dark ? 'text-white/50' : 'text-fg-muted'}`}>
|
|
113
|
-
{footer}
|
|
114
|
-
</span>
|
|
115
|
-
) : null}
|
|
116
|
-
</>
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const PressableCard = uic(RACButton, {
|
|
121
|
-
displayName: 'StatsCardButton',
|
|
122
|
-
baseClass:
|
|
123
|
-
`${baseSurface} ${lightSkin} ` +
|
|
124
|
-
'w-full outline-none transition-transform duration-150 ' +
|
|
125
|
-
'data-[hovered]:scale-[1.02] ' +
|
|
126
|
-
'data-[focus-visible]:ring-2 data-[focus-visible]:ring-ring data-[focus-visible]:ring-offset-2 ' +
|
|
127
|
-
'data-[pressed]:scale-100',
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
// Anchor interaction skin: same subtle scale-up as the pressable card (gs Tile
|
|
131
|
-
// `whileHover` scale 1.02, no shadow), but driven by CSS `:hover`/`:focus-visible`
|
|
132
|
-
// (real <a>) rather than RAC `data-` attributes.
|
|
133
|
-
const anchorInteractive =
|
|
134
|
-
'no-underline outline-none transition-transform duration-150 ' +
|
|
135
|
-
'hover:scale-[1.02] ' +
|
|
136
|
-
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
|
137
|
-
|
|
138
40
|
export function StatsCard({
|
|
139
41
|
title,
|
|
140
42
|
value,
|
|
@@ -145,37 +47,26 @@ export function StatsCard({
|
|
|
145
47
|
asChild,
|
|
146
48
|
children,
|
|
147
49
|
dark,
|
|
148
|
-
align,
|
|
50
|
+
align = 'center',
|
|
149
51
|
subtleValue,
|
|
150
52
|
className,
|
|
151
53
|
...rest
|
|
152
54
|
}: StatsCardProps) {
|
|
153
|
-
|
|
154
|
-
// card. The body is injected as its children so callers pass a self-closing link.
|
|
155
|
-
if (asChild && isValidElement(children)) {
|
|
156
|
-
const child = children as ReactElement<{ className?: string }>
|
|
157
|
-
const cardClass = [baseSurface, dark ? darkSkin : lightSkin, anchorInteractive, child.props.className, className]
|
|
158
|
-
.filter(Boolean)
|
|
159
|
-
.join(' ')
|
|
160
|
-
return cloneElement(child, { className: cardClass } as { className: string }, <StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} align={align} subtleValue={subtleValue} />)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (onPress) {
|
|
164
|
-
// uic merges via tailwind-merge, so `darkSkin` cleanly overrides the
|
|
165
|
-
// light surface/border in the base class.
|
|
166
|
-
return (
|
|
167
|
-
<PressableCard
|
|
168
|
-
onPress={onPress}
|
|
169
|
-
className={[dark ? darkSkin : '', className].filter(Boolean).join(' ')}
|
|
170
|
-
aria-label={rest['aria-label']}
|
|
171
|
-
>
|
|
172
|
-
<StatsCardBody title={title} value={value} footer={footer} badge={badge} description={description} dark={dark} align={align} subtleValue={subtleValue} />
|
|
173
|
-
</PressableCard>
|
|
174
|
-
)
|
|
175
|
-
}
|
|
55
|
+
const link = asChild && isValidElement(children) ? (children as ReactElement) : undefined
|
|
176
56
|
return (
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
57
|
+
<Tile
|
|
58
|
+
theme={dark ? 'dark' : 'light'}
|
|
59
|
+
eyebrow={title}
|
|
60
|
+
badge={badge}
|
|
61
|
+
title={value}
|
|
62
|
+
titleAlign={align}
|
|
63
|
+
subtleTitle={subtleValue}
|
|
64
|
+
text={description}
|
|
65
|
+
footer={footer}
|
|
66
|
+
link={link}
|
|
67
|
+
onPress={onPress}
|
|
68
|
+
aria-label={rest['aria-label']}
|
|
69
|
+
className={className}
|
|
70
|
+
/>
|
|
180
71
|
)
|
|
181
72
|
}
|
package/src/components/tile.tsx
CHANGED
|
@@ -1,102 +1,340 @@
|
|
|
1
|
-
import { type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
|
|
1
|
+
import { useState, type ReactElement, type ReactNode, cloneElement, isValidElement } from 'react'
|
|
2
|
+
import { CloseIcon } from './icons'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Tile — the
|
|
5
|
+
* Tile — the ONE dashboard card (the single source of tile "inner"). Every tile in
|
|
6
|
+
* the product is a `<Tile>`: they all share the same inner spec — 16px padding, 24px
|
|
7
|
+
* section gap, 8px radius, `overflow-hidden` — and a common slot set. A given tile
|
|
8
|
+
* just uses the SUBSET of slots it needs (port of gs-platform `Tile` +
|
|
9
|
+
* `DashboardContentCard`, matched 1:1).
|
|
5
10
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Slots (all optional):
|
|
12
|
+
* - `eyebrow` (+ `badge`) — the small label row at the top (gs heading-5).
|
|
13
|
+
* - `title` — the big headline / value (gs heading-1). `titleAlign` places it
|
|
14
|
+
* top (default) or centred; `subtleTitle` mutes its colour.
|
|
15
|
+
* - `text` — supporting copy under the title (gs 14px).
|
|
16
|
+
* - `image` — a side preview in the RIGHT half of the content band.
|
|
17
|
+
* - `footer` — the bottom band.
|
|
18
|
+
* - `backgroundImage` / `video` — turn the tile into a full-bleed MEDIA HERO
|
|
19
|
+
* (image, or click-to-play inline video); `eyebrow`/`title`/`footer`/`badge`
|
|
20
|
+
* become overlays. No modal.
|
|
21
|
+
* - `children` — an escape hatch for the content band (lists, infographics). When
|
|
22
|
+
* present it REPLACES title/text/image.
|
|
11
23
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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.
|
|
24
|
+
* Clickable: pass a router `<Link/>` as `link` (whole tile becomes a real anchor —
|
|
25
|
+
* cmd/middle-click, new tab) or an `onPress` handler (button). Router-agnostic (hard
|
|
26
|
+
* rule #1 — podoba never imports a router). Presentational only: copy via props.
|
|
20
27
|
*/
|
|
21
28
|
export type TileTheme = 'light' | 'dark' | 'teal' | 'yellow'
|
|
29
|
+
export type TileAlign = 'top' | 'center'
|
|
22
30
|
|
|
23
|
-
// Light tiles carry a visible hairline (border-border), matching StatsCard's
|
|
24
|
-
// lightSkin; dark/colored tiles use a transparent border so every tile keeps the
|
|
25
|
-
// same box size (no 1px layout shift between themes).
|
|
26
31
|
const THEME: Record<TileTheme, string> = {
|
|
32
|
+
// Light tiles carry a visible hairline; dark/colored use a transparent border so
|
|
33
|
+
// every tile keeps the same box size (no 1px shift between themes).
|
|
27
34
|
light: 'border border-border bg-surface-card text-fg',
|
|
28
35
|
dark: 'border border-transparent bg-surface-inverted text-white',
|
|
29
36
|
teal: 'border border-transparent bg-brand-secondary text-fg',
|
|
30
37
|
yellow: 'border border-transparent bg-accent-yellow text-fg',
|
|
31
38
|
}
|
|
32
39
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
// The shared inner for EVERY tile (gs `DashboardContentCard`): 16px padding
|
|
41
|
+
// (`--spacing-4`), 24px section gap, 8px radius, clip overflow. `h-full` fills the
|
|
42
|
+
// grid cell; `min-h` floors it.
|
|
43
|
+
const baseSurface = 'flex h-full min-h-[200px] flex-col gap-6 overflow-hidden rounded-lg p-4'
|
|
44
|
+
|
|
45
|
+
/** Hover-lift + focus ring applied only when the tile is interactive. */
|
|
46
|
+
const interactive =
|
|
47
|
+
'no-underline outline-none transition-[transform,box-shadow] duration-150 ' +
|
|
36
48
|
'hover:-translate-y-1 hover:shadow-lg focus-visible:ring-2 focus-visible:ring-ring'
|
|
37
49
|
|
|
38
50
|
export type TileProps = {
|
|
39
51
|
/** Surface theme (gs's Figma palette). */
|
|
40
52
|
theme?: TileTheme
|
|
41
|
-
/**
|
|
53
|
+
/** Small label row at the top (gs eyebrow / heading-5). */
|
|
54
|
+
eyebrow?: ReactNode
|
|
55
|
+
/** @deprecated alias for `eyebrow`. */
|
|
42
56
|
head?: ReactNode
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
/**
|
|
57
|
+
/** Status pill shown next to the eyebrow. */
|
|
58
|
+
badge?: ReactNode
|
|
59
|
+
/** Big headline / value (gs heading-1). */
|
|
60
|
+
title?: ReactNode
|
|
61
|
+
/** Vertical placement of `title` in the content band (default `top`). */
|
|
62
|
+
titleAlign?: TileAlign
|
|
63
|
+
/** Mute the `title` colour (a descriptive title, not a hero count). */
|
|
64
|
+
subtleTitle?: boolean
|
|
65
|
+
/** Supporting text under the title (gs 14px). */
|
|
66
|
+
text?: ReactNode
|
|
67
|
+
/** Footer band (links, meta, actions). */
|
|
68
|
+
footer?: ReactNode
|
|
69
|
+
/** @deprecated alias for `footer`. */
|
|
46
70
|
tail?: ReactNode
|
|
71
|
+
/** A side preview element in the right half of the content band. */
|
|
72
|
+
image?: ReactNode
|
|
47
73
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
74
|
+
* Custom content band — escape hatch for lists / infographics. When present it
|
|
75
|
+
* REPLACES `title`/`text`/`image`.
|
|
50
76
|
*/
|
|
77
|
+
children?: ReactNode
|
|
78
|
+
/** Full-bleed background image → the tile becomes a media hero. */
|
|
79
|
+
backgroundImage?: string
|
|
80
|
+
imageAlt?: string
|
|
81
|
+
/** When set (media hero), pressing the tile plays this video INLINE (no modal). */
|
|
82
|
+
video?: string
|
|
83
|
+
/** Accessible label for the "stop / back to poster" control while playing. */
|
|
84
|
+
closeLabel?: string
|
|
85
|
+
/** A link ELEMENT to render the whole tile as (e.g. a router `<Link/>`). */
|
|
51
86
|
link?: ReactElement
|
|
87
|
+
/** A whole-tile press handler (renders a button). `pressLabel` names it for a11y. */
|
|
88
|
+
onPress?: () => void
|
|
89
|
+
pressLabel?: string
|
|
52
90
|
className?: string
|
|
91
|
+
'aria-label'?: string
|
|
53
92
|
}
|
|
54
93
|
|
|
55
|
-
/** The
|
|
56
|
-
|
|
94
|
+
/** The big "stat" content typography (gs heading-1: 28px / 500 / tight). */
|
|
95
|
+
export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
|
|
96
|
+
|
|
97
|
+
function titleTone(theme: TileTheme, subtle: boolean): string {
|
|
98
|
+
if (theme === 'dark') return subtle ? 'text-white/60' : 'text-white'
|
|
99
|
+
return subtle ? 'text-fg-muted' : 'text-fg'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** head / content / tail bands — the normal (non-media) layout. */
|
|
103
|
+
function TileBands({
|
|
104
|
+
theme,
|
|
105
|
+
eyebrow,
|
|
106
|
+
badge,
|
|
107
|
+
title,
|
|
108
|
+
titleAlign = 'top',
|
|
109
|
+
subtleTitle = false,
|
|
110
|
+
text,
|
|
111
|
+
footer,
|
|
112
|
+
image,
|
|
113
|
+
children,
|
|
114
|
+
}: Pick<
|
|
115
|
+
TileProps,
|
|
116
|
+
'theme' | 'eyebrow' | 'badge' | 'title' | 'titleAlign' | 'subtleTitle' | 'text' | 'footer' | 'image' | 'children'
|
|
117
|
+
> & { theme: TileTheme }) {
|
|
118
|
+
const titleNode =
|
|
119
|
+
title != null ? (
|
|
120
|
+
<p className={`${tileStatClass} ${theme === 'dark' ? 'tracking-normal' : 'tracking-wide'} ${titleTone(theme, subtleTitle)}`}>
|
|
121
|
+
{title}
|
|
122
|
+
</p>
|
|
123
|
+
) : null
|
|
124
|
+
const textNode =
|
|
125
|
+
text != null ? (
|
|
126
|
+
<p className={`text-small leading-4 ${theme === 'dark' ? 'text-white/60' : 'text-fg-muted'} ${title != null ? 'mt-3' : ''}`}>
|
|
127
|
+
{text}
|
|
128
|
+
</p>
|
|
129
|
+
) : null
|
|
130
|
+
|
|
131
|
+
// Content band: custom children win; else a right-side image → two columns; else
|
|
132
|
+
// the title/text stack (top or vertically centred).
|
|
133
|
+
let content: ReactNode
|
|
134
|
+
if (children != null) {
|
|
135
|
+
content = <div className="flex min-h-0 flex-1 flex-col items-start">{children}</div>
|
|
136
|
+
} else if (image != null) {
|
|
137
|
+
content = (
|
|
138
|
+
<div className="grid min-h-0 flex-1 grid-cols-2 grid-rows-[minmax(0,1fr)] gap-4">
|
|
139
|
+
<div className="flex min-h-0 min-w-0 flex-col">
|
|
140
|
+
{titleNode}
|
|
141
|
+
{textNode}
|
|
142
|
+
</div>
|
|
143
|
+
<div className="flex min-h-0 items-end justify-end">{image}</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
} else {
|
|
147
|
+
content = (
|
|
148
|
+
<div className={`flex min-h-0 flex-1 flex-col ${titleAlign === 'center' ? 'justify-center' : ''}`}>
|
|
149
|
+
{titleNode}
|
|
150
|
+
{textNode}
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
57
155
|
return (
|
|
58
156
|
<>
|
|
59
|
-
{
|
|
60
|
-
<div className="flex items-center
|
|
61
|
-
{
|
|
157
|
+
{eyebrow != null || badge != null ? (
|
|
158
|
+
<div className="flex items-center gap-3 text-body font-medium leading-5">
|
|
159
|
+
{eyebrow}
|
|
160
|
+
{badge}
|
|
62
161
|
</div>
|
|
63
162
|
) : null}
|
|
64
|
-
|
|
65
|
-
{
|
|
163
|
+
{content}
|
|
164
|
+
{footer != null ? <div className="min-w-0">{footer}</div> : null}
|
|
66
165
|
</>
|
|
67
166
|
)
|
|
68
167
|
}
|
|
69
168
|
|
|
70
|
-
|
|
169
|
+
/** Full-bleed media hero (background image, or click-to-play inline video). */
|
|
170
|
+
function MediaHero({
|
|
171
|
+
backgroundImage,
|
|
172
|
+
imageAlt,
|
|
173
|
+
video,
|
|
174
|
+
eyebrow,
|
|
175
|
+
title,
|
|
176
|
+
footer,
|
|
177
|
+
badge,
|
|
178
|
+
onPress,
|
|
179
|
+
pressLabel,
|
|
180
|
+
closeLabel = 'Close video',
|
|
181
|
+
className,
|
|
182
|
+
}: Pick<
|
|
183
|
+
TileProps,
|
|
184
|
+
'backgroundImage' | 'imageAlt' | 'video' | 'eyebrow' | 'title' | 'footer' | 'badge' | 'onPress' | 'pressLabel' | 'closeLabel' | 'className'
|
|
185
|
+
>) {
|
|
186
|
+
const [playing, setPlaying] = useState(false)
|
|
187
|
+
const hasVideo = Boolean(video)
|
|
188
|
+
const pressable = hasVideo || Boolean(onPress)
|
|
189
|
+
const handlePress = () => {
|
|
190
|
+
if (hasVideo) setPlaying(true)
|
|
191
|
+
else onPress?.()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
className={['relative isolate h-full min-h-[200px] w-full overflow-hidden rounded-lg bg-surface-inverted', className]
|
|
197
|
+
.filter(Boolean)
|
|
198
|
+
.join(' ')}
|
|
199
|
+
>
|
|
200
|
+
{playing && video ? (
|
|
201
|
+
<>
|
|
202
|
+
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption track is
|
|
203
|
+
supplied by the consumer via a fuller player when needed. */}
|
|
204
|
+
<video
|
|
205
|
+
src={video}
|
|
206
|
+
poster={backgroundImage}
|
|
207
|
+
className="absolute inset-0 z-0 h-full w-full bg-black object-cover"
|
|
208
|
+
controls
|
|
209
|
+
autoPlay
|
|
210
|
+
playsInline
|
|
211
|
+
/>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
aria-label={closeLabel}
|
|
215
|
+
onClick={() => setPlaying(false)}
|
|
216
|
+
className="absolute right-4 top-4 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white outline-none transition-colors hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white/70"
|
|
217
|
+
>
|
|
218
|
+
<CloseIcon className="h-4 w-4" />
|
|
219
|
+
</button>
|
|
220
|
+
</>
|
|
221
|
+
) : (
|
|
222
|
+
<>
|
|
223
|
+
{backgroundImage ? (
|
|
224
|
+
<img src={backgroundImage} alt={imageAlt ?? ''} className="absolute inset-0 h-full w-full object-cover" />
|
|
225
|
+
) : null}
|
|
226
|
+
{/* Legibility scrim — darkens top + bottom where text sits. */}
|
|
227
|
+
<div aria-hidden="true" className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60" />
|
|
228
|
+
|
|
229
|
+
{pressable ? (
|
|
230
|
+
<button
|
|
231
|
+
type="button"
|
|
232
|
+
onClick={handlePress}
|
|
233
|
+
aria-label={pressLabel}
|
|
234
|
+
className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
|
|
235
|
+
/>
|
|
236
|
+
) : null}
|
|
237
|
+
|
|
238
|
+
{eyebrow ? (
|
|
239
|
+
<span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">{eyebrow}</span>
|
|
240
|
+
) : null}
|
|
241
|
+
|
|
242
|
+
{/* Content is click-through so pressing the title/badge falls to the
|
|
243
|
+
press overlay; only `footer` re-enables its own pointer events. */}
|
|
244
|
+
<div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
|
|
245
|
+
{title ? <h2 className="max-w-md text-display font-medium leading-tight text-white">{title}</h2> : null}
|
|
246
|
+
{footer != null || badge != null ? (
|
|
247
|
+
<div className="flex items-center justify-between gap-4">
|
|
248
|
+
<span className="pointer-events-auto">{footer}</span>
|
|
249
|
+
<span>{badge}</span>
|
|
250
|
+
</div>
|
|
251
|
+
) : null}
|
|
252
|
+
</div>
|
|
253
|
+
</>
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function Tile({
|
|
260
|
+
theme = 'light',
|
|
261
|
+
eyebrow,
|
|
262
|
+
head,
|
|
263
|
+
badge,
|
|
264
|
+
title,
|
|
265
|
+
titleAlign,
|
|
266
|
+
subtleTitle,
|
|
267
|
+
text,
|
|
268
|
+
footer,
|
|
269
|
+
tail,
|
|
270
|
+
image,
|
|
271
|
+
children,
|
|
272
|
+
backgroundImage,
|
|
273
|
+
imageAlt,
|
|
274
|
+
video,
|
|
275
|
+
closeLabel,
|
|
276
|
+
link,
|
|
277
|
+
onPress,
|
|
278
|
+
pressLabel,
|
|
279
|
+
className,
|
|
280
|
+
...rest
|
|
281
|
+
}: TileProps) {
|
|
282
|
+
const eb = eyebrow ?? head
|
|
283
|
+
const ft = footer ?? tail
|
|
71
284
|
|
|
72
|
-
|
|
285
|
+
// Media hero: a background image / inline video with overlaid text.
|
|
286
|
+
if (backgroundImage || video) {
|
|
287
|
+
return (
|
|
288
|
+
<MediaHero
|
|
289
|
+
backgroundImage={backgroundImage}
|
|
290
|
+
imageAlt={imageAlt}
|
|
291
|
+
video={video}
|
|
292
|
+
eyebrow={eb}
|
|
293
|
+
title={title}
|
|
294
|
+
footer={ft}
|
|
295
|
+
badge={badge}
|
|
296
|
+
onPress={onPress}
|
|
297
|
+
pressLabel={pressLabel}
|
|
298
|
+
closeLabel={closeLabel}
|
|
299
|
+
className={className}
|
|
300
|
+
/>
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const body = (
|
|
305
|
+
<TileBands
|
|
306
|
+
theme={theme}
|
|
307
|
+
eyebrow={eb}
|
|
308
|
+
badge={badge}
|
|
309
|
+
title={title}
|
|
310
|
+
titleAlign={titleAlign}
|
|
311
|
+
subtleTitle={subtleTitle}
|
|
312
|
+
text={text}
|
|
313
|
+
footer={ft}
|
|
314
|
+
image={image}
|
|
315
|
+
>
|
|
316
|
+
{children}
|
|
317
|
+
</TileBands>
|
|
318
|
+
)
|
|
73
319
|
const surface = [baseSurface, THEME[theme], className].filter(Boolean).join(' ')
|
|
74
320
|
|
|
75
|
-
//
|
|
321
|
+
// A link element → render the tile AS that element (real anchor semantics).
|
|
76
322
|
if (link && isValidElement(link)) {
|
|
77
323
|
const el = link as ReactElement<{ className?: string }>
|
|
78
|
-
const cardClass = [surface,
|
|
79
|
-
return cloneElement(el, { className: cardClass } as { className: string },
|
|
80
|
-
<TileBody head={head} tail={tail}>
|
|
81
|
-
{children}
|
|
82
|
-
</TileBody>
|
|
83
|
-
))
|
|
324
|
+
const cardClass = [surface, 'block', interactive, el.props.className].filter(Boolean).join(' ')
|
|
325
|
+
return cloneElement(el, { className: cardClass } as { className: string }, body)
|
|
84
326
|
}
|
|
85
327
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
<
|
|
89
|
-
{
|
|
90
|
-
</
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
328
|
+
if (onPress) {
|
|
329
|
+
return (
|
|
330
|
+
<button type="button" onClick={onPress} aria-label={rest['aria-label']} className={`${surface} w-full text-left ${interactive}`}>
|
|
331
|
+
{body}
|
|
332
|
+
</button>
|
|
333
|
+
)
|
|
334
|
+
}
|
|
94
335
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
* 28px / 500 / tight tracking). Apply to the element wrapping a tile's headline value.
|
|
98
|
-
*/
|
|
99
|
-
export const tileStatClass = 'text-display font-medium leading-[30px] tracking-wide'
|
|
336
|
+
return <div className={surface}>{body}</div>
|
|
337
|
+
}
|
|
100
338
|
|
|
101
339
|
/**
|
|
102
340
|
* Badge — a small presentational status pill (gs `GSTag`). NOTE: this is a plain
|
|
@@ -117,7 +355,7 @@ const BADGE: Record<BadgeColor, string> = {
|
|
|
117
355
|
export function Badge({ label, color = 'grey' }: { label: ReactNode; color?: BadgeColor }) {
|
|
118
356
|
return (
|
|
119
357
|
<span
|
|
120
|
-
className={`inline-flex items-center rounded-full px-
|
|
358
|
+
className={`inline-flex items-center rounded-full px-3 py-1 text-caption font-medium leading-none ${BADGE[color]}`}
|
|
121
359
|
>
|
|
122
360
|
{label}
|
|
123
361
|
</span>
|
package/src/index.ts
CHANGED
|
@@ -54,7 +54,6 @@ export * from "./layout/persistent-page-shell";
|
|
|
54
54
|
export * from "./components/brand-page-header";
|
|
55
55
|
export * from "./components/count-up";
|
|
56
56
|
export * from "./components/cta-pill";
|
|
57
|
-
export * from "./components/feature-tile";
|
|
58
57
|
export * from "./components/stat-stack";
|
|
59
58
|
export * from "./components/icons";
|
|
60
59
|
export * from "./components/subtle";
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import { useState, type ReactNode } from 'react'
|
|
2
|
-
import { CloseIcon } from './icons'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* FeatureTile — a full-width media hero tile: a background image with an overlaid
|
|
6
|
-
* eyebrow (top-left), title (bottom-left), an optional action control and a badge.
|
|
7
|
-
* Generic — the common use is a "start here" tutorial banner, but it fits any
|
|
8
|
-
* promo/feature spotlight. Presentational only (hard rule #1): text arrives via props.
|
|
9
|
-
*
|
|
10
|
-
* Pressing the tile either PLAYS an inline video (when `video` is set — the poster
|
|
11
|
-
* image + overlays are replaced by a `<video controls autoPlay>` filling the tile, no
|
|
12
|
-
* modal) or fires `onPress` (e.g. navigate). `action` / `badge` sit ABOVE the press
|
|
13
|
-
* target (z-10) so their own handlers still work. Falls back to a dark surface when no
|
|
14
|
-
* `image` is given.
|
|
15
|
-
*/
|
|
16
|
-
export interface FeatureTileProps {
|
|
17
|
-
/** Background/poster image URL. Falls back to a dark surface when omitted. */
|
|
18
|
-
image?: string
|
|
19
|
-
imageAlt?: string
|
|
20
|
-
/** When set, pressing the tile plays this video INLINE (no modal). */
|
|
21
|
-
video?: string
|
|
22
|
-
/** Small category label, top-left (e.g. "Tutorial"). */
|
|
23
|
-
eyebrow?: ReactNode
|
|
24
|
-
/** Large title, bottom-left. */
|
|
25
|
-
title: ReactNode
|
|
26
|
-
/** Bottom-left action control (e.g. a round icon button). */
|
|
27
|
-
action?: ReactNode
|
|
28
|
-
/** Bottom-right badge (e.g. a duration pill). */
|
|
29
|
-
badge?: ReactNode
|
|
30
|
-
/** Press behaviour when there is NO `video`. `pressLabel` names it for a11y. */
|
|
31
|
-
onPress?: () => void
|
|
32
|
-
pressLabel?: string
|
|
33
|
-
/** Accessible label for the "stop / back to poster" control while playing. */
|
|
34
|
-
closeLabel?: string
|
|
35
|
-
/** Override the default `aspect-[2/1]` ratio / sizing. */
|
|
36
|
-
className?: string
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function FeatureTile({
|
|
40
|
-
image,
|
|
41
|
-
imageAlt,
|
|
42
|
-
video,
|
|
43
|
-
eyebrow,
|
|
44
|
-
title,
|
|
45
|
-
action,
|
|
46
|
-
badge,
|
|
47
|
-
onPress,
|
|
48
|
-
pressLabel,
|
|
49
|
-
closeLabel = 'Close video',
|
|
50
|
-
className,
|
|
51
|
-
}: FeatureTileProps) {
|
|
52
|
-
const [playing, setPlaying] = useState(false)
|
|
53
|
-
const hasVideo = Boolean(video)
|
|
54
|
-
const pressable = hasVideo || Boolean(onPress)
|
|
55
|
-
|
|
56
|
-
const handlePress = () => {
|
|
57
|
-
if (hasVideo) setPlaying(true)
|
|
58
|
-
else onPress?.()
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return (
|
|
62
|
-
<div
|
|
63
|
-
className={[
|
|
64
|
-
// A large fixed-aspect banner (default ~2:1); media covers it. Override the
|
|
65
|
-
// ratio/size via `className` (e.g. `aspect-[1440/700]`).
|
|
66
|
-
'relative isolate aspect-[2/1] w-full overflow-hidden rounded-lg bg-surface-inverted',
|
|
67
|
-
className,
|
|
68
|
-
]
|
|
69
|
-
.filter(Boolean)
|
|
70
|
-
.join(' ')}
|
|
71
|
-
>
|
|
72
|
-
{playing && video ? (
|
|
73
|
-
<>
|
|
74
|
-
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- caption track is
|
|
75
|
-
supplied by the consumer via a fuller player when needed. */}
|
|
76
|
-
<video
|
|
77
|
-
src={video}
|
|
78
|
-
poster={image}
|
|
79
|
-
className="absolute inset-0 z-0 h-full w-full bg-black object-cover"
|
|
80
|
-
controls
|
|
81
|
-
autoPlay
|
|
82
|
-
playsInline
|
|
83
|
-
/>
|
|
84
|
-
<button
|
|
85
|
-
type="button"
|
|
86
|
-
aria-label={closeLabel}
|
|
87
|
-
onClick={() => setPlaying(false)}
|
|
88
|
-
className="absolute right-4 top-4 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white outline-none transition-colors hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white/70"
|
|
89
|
-
>
|
|
90
|
-
<CloseIcon className="h-4 w-4" />
|
|
91
|
-
</button>
|
|
92
|
-
</>
|
|
93
|
-
) : (
|
|
94
|
-
<>
|
|
95
|
-
{image ? (
|
|
96
|
-
<img
|
|
97
|
-
src={image}
|
|
98
|
-
alt={imageAlt ?? ''}
|
|
99
|
-
className="absolute inset-0 h-full w-full object-cover"
|
|
100
|
-
/>
|
|
101
|
-
) : null}
|
|
102
|
-
{/* Legibility scrim — darkens the top + bottom where the text sits. */}
|
|
103
|
-
<div
|
|
104
|
-
aria-hidden="true"
|
|
105
|
-
className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60"
|
|
106
|
-
/>
|
|
107
|
-
|
|
108
|
-
{/* Full-bleed press target (behind the content controls). */}
|
|
109
|
-
{pressable ? (
|
|
110
|
-
<button
|
|
111
|
-
type="button"
|
|
112
|
-
onClick={handlePress}
|
|
113
|
-
aria-label={pressLabel}
|
|
114
|
-
className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
|
|
115
|
-
/>
|
|
116
|
-
) : null}
|
|
117
|
-
|
|
118
|
-
{eyebrow ? (
|
|
119
|
-
<span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">
|
|
120
|
-
{eyebrow}
|
|
121
|
-
</span>
|
|
122
|
-
) : null}
|
|
123
|
-
|
|
124
|
-
{/* Content is click-through (pointer-events-none) so pressing the title
|
|
125
|
-
or badge falls through to the play/press overlay; only `action`
|
|
126
|
-
re-enables its own pointer events. */}
|
|
127
|
-
<div className="pointer-events-none absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
|
|
128
|
-
<h2 className="max-w-md text-display font-medium leading-tight text-white">
|
|
129
|
-
{title}
|
|
130
|
-
</h2>
|
|
131
|
-
<div className="flex items-center justify-between gap-4">
|
|
132
|
-
<span className="pointer-events-auto">{action}</span>
|
|
133
|
-
<span>{badge}</span>
|
|
134
|
-
</div>
|
|
135
|
-
</div>
|
|
136
|
-
</>
|
|
137
|
-
)}
|
|
138
|
-
</div>
|
|
139
|
-
)
|
|
140
|
-
}
|