@podoba/react 0.0.16 → 0.0.17
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/feature-tile.tsx +89 -42
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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.17",
|
|
29
|
+
"@podoba/tailwind": "^0.0.17",
|
|
30
30
|
"react-aria-components": "1.18.0",
|
|
31
31
|
"class-variance-authority": "0.7.1",
|
|
32
32
|
"clsx": "2.1.1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from 'react'
|
|
2
|
+
import { CloseIcon } from './icons'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* FeatureTile — a full-width media hero tile: a background image with an overlaid
|
|
@@ -6,14 +7,18 @@ import type { ReactNode } from 'react'
|
|
|
6
7
|
* Generic — the common use is a "start here" tutorial banner, but it fits any
|
|
7
8
|
* promo/feature spotlight. Presentational only (hard rule #1): text arrives via props.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
12
15
|
*/
|
|
13
16
|
export interface FeatureTileProps {
|
|
14
|
-
/** Background image URL. Falls back to a dark surface when omitted. */
|
|
17
|
+
/** Background/poster image URL. Falls back to a dark surface when omitted. */
|
|
15
18
|
image?: string
|
|
16
19
|
imageAlt?: string
|
|
20
|
+
/** When set, pressing the tile plays this video INLINE (no modal). */
|
|
21
|
+
video?: string
|
|
17
22
|
/** Small category label, top-left (e.g. "Tutorial"). */
|
|
18
23
|
eyebrow?: ReactNode
|
|
19
24
|
/** Large title, bottom-left. */
|
|
@@ -22,9 +27,11 @@ export interface FeatureTileProps {
|
|
|
22
27
|
action?: ReactNode
|
|
23
28
|
/** Bottom-right badge (e.g. a duration pill). */
|
|
24
29
|
badge?: ReactNode
|
|
25
|
-
/**
|
|
30
|
+
/** Press behaviour when there is NO `video`. `pressLabel` names it for a11y. */
|
|
26
31
|
onPress?: () => void
|
|
27
32
|
pressLabel?: string
|
|
33
|
+
/** Accessible label for the "stop / back to poster" control while playing. */
|
|
34
|
+
closeLabel?: string
|
|
28
35
|
/** Override the default `aspect-[2/1]` ratio / sizing. */
|
|
29
36
|
className?: string
|
|
30
37
|
}
|
|
@@ -32,62 +39,102 @@ export interface FeatureTileProps {
|
|
|
32
39
|
export function FeatureTile({
|
|
33
40
|
image,
|
|
34
41
|
imageAlt,
|
|
42
|
+
video,
|
|
35
43
|
eyebrow,
|
|
36
44
|
title,
|
|
37
45
|
action,
|
|
38
46
|
badge,
|
|
39
47
|
onPress,
|
|
40
48
|
pressLabel,
|
|
49
|
+
closeLabel = 'Close video',
|
|
41
50
|
className,
|
|
42
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
|
+
|
|
43
61
|
return (
|
|
44
62
|
<div
|
|
45
63
|
className={[
|
|
46
|
-
// A large fixed-aspect banner (default ~2:1);
|
|
47
|
-
//
|
|
48
|
-
// viewport, so it stays a predictable min size rather than reflowing.
|
|
64
|
+
// A large fixed-aspect banner (default ~2:1); media covers it. Override the
|
|
65
|
+
// ratio/size via `className` (e.g. `aspect-[1440/700]`).
|
|
49
66
|
'relative isolate aspect-[2/1] w-full overflow-hidden rounded-lg bg-surface-inverted',
|
|
50
67
|
className,
|
|
51
68
|
]
|
|
52
69
|
.filter(Boolean)
|
|
53
70
|
.join(' ')}
|
|
54
71
|
>
|
|
55
|
-
{
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
/>
|
|
67
107
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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}
|
|
77
117
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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}
|
|
83
123
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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-xl 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
|
+
)}
|
|
91
138
|
</div>
|
|
92
139
|
)
|
|
93
140
|
}
|