@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@podoba/react",
3
- "version": "0.0.16",
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.16",
29
- "@podoba/tailwind": "^0.0.16",
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 { ReactNode } from 'react'
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
- * The whole tile is pressable via `onPress` (rendered as a full-bleed overlay button
10
- * labelled by `title`); `action` / `badge` sit ABOVE it (z-10) so their own handlers
11
- * still work. Falls back to a dark surface when no `image` is given.
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
- /** Makes the whole tile pressable. `pressLabel` names it for assistive tech. */
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); the image covers it. Override
47
- // the ratio/size via `className` (e.g. `aspect-[1440/700]`). Not tied to the
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
- {image ? (
56
- <img
57
- src={image}
58
- alt={imageAlt ?? ''}
59
- className="absolute inset-0 h-full w-full object-cover"
60
- />
61
- ) : null}
62
- {/* Legibility scrim darkens the top + bottom where the text sits. */}
63
- <div
64
- aria-hidden="true"
65
- className="absolute inset-0 bg-gradient-to-b from-black/30 via-transparent to-black/60"
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
- {/* Full-bleed press target (behind the content controls). */}
69
- {onPress ? (
70
- <button
71
- type="button"
72
- onClick={onPress}
73
- aria-label={pressLabel}
74
- className="absolute inset-0 z-0 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/70"
75
- />
76
- ) : null}
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
- {eyebrow ? (
79
- <span className="pointer-events-none absolute left-6 top-6 z-10 text-compact font-medium text-white">
80
- {eyebrow}
81
- </span>
82
- ) : null}
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
- <div className="absolute inset-x-6 bottom-6 z-10 flex flex-col gap-4">
85
- <h2 className="max-w-md text-display-xl font-medium leading-tight text-white">{title}</h2>
86
- <div className="flex items-center justify-between gap-4">
87
- <span className="pointer-events-auto">{action}</span>
88
- <span className="pointer-events-auto">{badge}</span>
89
- </div>
90
- </div>
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
  }