jfs-components 0.0.67 → 0.0.69

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.
@@ -30,9 +30,10 @@ function normalizeSource(imageSource) {
30
30
  * its parent (`width: '100%'`, `height: '100%'`) — same default as the
31
31
  * most common usage in this library (background media, hero images).
32
32
  */
33
+ const DEFAULT_RATIO = 16 / 9;
33
34
  function Image({
34
35
  imageSource,
35
- ratio,
36
+ ratio = DEFAULT_RATIO,
36
37
  resizeMode = 'cover',
37
38
  width,
38
39
  height,
@@ -44,15 +45,21 @@ function Image({
44
45
  }) {
45
46
  const source = useMemo(() => normalizeSource(imageSource), [imageSource]);
46
47
  const layoutStyle = useMemo(() => {
47
- const s = {};
48
- if (ratio != null) {
49
- s.aspectRatio = ratio;
50
- s.width = width ?? '100%';
51
- if (height != null) s.height = height;
52
- } else {
53
- s.width = width ?? '100%';
54
- s.height = height ?? '100%';
55
- }
48
+ // If the caller has fully specified width AND height, they're doing a
49
+ // non-aspect layout (e.g. "fill the parent") — respect that and skip
50
+ // `aspectRatio` so it doesn't conflict.
51
+ const isExplicitBox = width != null && height != null;
52
+ const s = {
53
+ width: width ?? '100%',
54
+ ...(isExplicitBox ? {
55
+ height: height
56
+ } : {
57
+ aspectRatio: ratio,
58
+ ...(height != null ? {
59
+ height
60
+ } : {})
61
+ })
62
+ };
56
63
  if (borderRadius != null) s.borderRadius = borderRadius;
57
64
  return s;
58
65
  }, [ratio, width, height, borderRadius]);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { createContext, useContext } from 'react';
4
4
  import { View, Text, StyleSheet, Platform } from 'react-native';
5
+ import { BlurView } from 'expo-blur';
5
6
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
7
  import Image from '../Image/Image';
7
8
  import { EMPTY_MODES } from '../../utils/react-utils';
@@ -10,11 +11,20 @@ const MediaCardContext = /*#__PURE__*/createContext({});
10
11
  /**
11
12
  * MediaCard component implementation from Figma node 1241:4140.
12
13
  *
13
- * Features a background media slot, a large title, and a glass-morphism footer.
14
- *
15
- * The background can be supplied either as `imageSource` (preferred — uses
16
- * the shared `<Image>` primitive under the hood) or as a custom `media` node
17
- * for non-image backgrounds.
14
+ * Layout contract (important read this before editing):
15
+ * - The **background** (image or custom `media`) is the only child in
16
+ * normal flow. It dictates the card's height typically via
17
+ * `aspectRatio` on the inner `<Image>`. There is no `minHeight`.
18
+ * - `Header` and `Footer` are **absolutely positioned overlays**:
19
+ * - `Header` pinned to top-left/right with safe padding.
20
+ * - `Footer` pinned to bottom-left/right with `zIndex: 2` so it sits
21
+ * on top of the header (and on top of the image). This guarantees
22
+ * the footer never moves no matter how many lines the title wraps
23
+ * to — the title may overflow the header bounds, but the footer's
24
+ * position is a function of the card box, not the title.
25
+ * - `pointerEvents="box-none"` is applied so taps still land on the
26
+ * interactive elements inside the overlays without the wrapper itself
27
+ * capturing them.
18
28
  */
19
29
  export function MediaCard({
20
30
  imageSource,
@@ -25,18 +35,11 @@ export function MediaCard({
25
35
  style
26
36
  }) {
27
37
  const radius = parseFloat(getVariableByName('cardMedia/radius', modes) || '24');
28
- const gap = parseFloat(getVariableByName('cardMedia/gap', modes) || '0');
29
38
  const containerStyle = {
30
39
  borderRadius: radius,
31
- gap,
32
40
  overflow: 'hidden',
33
- position: 'relative',
34
- minHeight: 308
41
+ position: 'relative'
35
42
  };
36
-
37
- // `media` wins for back-compat / custom nodes; otherwise we delegate to
38
- // the shared <Image> for image-source backgrounds. All raster-rendering
39
- // concerns (URL-vs-{uri}, resizeMode, aspect-ratio) live in <Image>.
40
43
  const background = media ?? (imageSource != null ? /*#__PURE__*/_jsx(Image, {
41
44
  imageSource: imageSource,
42
45
  ratio: ratio,
@@ -50,10 +53,11 @@ export function MediaCard({
50
53
  },
51
54
  children: /*#__PURE__*/_jsxs(View, {
52
55
  style: [containerStyle, style],
53
- children: [background ? /*#__PURE__*/_jsx(View, {
56
+ children: [background, children != null ? /*#__PURE__*/_jsx(View, {
54
57
  style: StyleSheet.absoluteFill,
55
- children: background
56
- }) : null, children]
58
+ pointerEvents: "box-none",
59
+ children: children
60
+ }) : null]
57
61
  })
58
62
  });
59
63
  }
@@ -63,33 +67,26 @@ export function MediaCard({
63
67
  // ----------------------------------------------------------------------------
64
68
 
65
69
  /**
66
- * Header/Title Wrapper
67
- * It seems the title is just floating at the top with padding.
68
- * Figma: "title wrap" p-[16px]
70
+ * Header overlay — pinned to the top of the card. Title content can wrap to
71
+ * any number of lines without affecting the footer's position; if it grows
72
+ * taller than the card, the card's `overflow: 'hidden'` clips it.
73
+ *
74
+ * Default `padding: 16` matches the Figma "title wrap" spec.
69
75
  */
70
76
  export function Header({
71
77
  children,
72
78
  style
73
79
  }) {
74
- // NOTE: the previous `flex: 1` shorthand expanded on Yoga (Android) to
75
- // `{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }`. With `flexBasis: 0` the
76
- // Header has *no intrinsic floor*, so when MediaCard is placed inside a
77
- // height-unbounded parent — e.g. a Carousel slot whose contentContainer
78
- // is `alignItems: 'flex-start'` — Yoga's first measurement pass sizes
79
- // the Header at 0 and the card's overall height becomes non-deterministic.
80
- // On native this manifests as the card "over-stretching" vertically (the
81
- // same Yoga foot-gun we fixed in `CardCTA` rightWrap). Web hides it
82
- // because browsers honor `min-height: auto` on flex items. Use explicit
83
- // `flexGrow / flexShrink: 0 / flexBasis: 'auto'` so the Header is sized
84
- // to its content as a floor and only grows to consume the extra space
85
- // contributed by `MediaCard`'s `minHeight: 308`.
86
80
  return /*#__PURE__*/_jsx(View, {
87
81
  style: [{
82
+ position: 'absolute',
83
+ top: 0,
84
+ left: 0,
85
+ right: 0,
88
86
  padding: 16,
89
- flexGrow: 1,
90
- flexShrink: 0,
91
- flexBasis: 'auto'
87
+ zIndex: 1
92
88
  }, style],
89
+ pointerEvents: "box-none",
93
90
  children: children
94
91
  });
95
92
  }
@@ -124,8 +121,23 @@ export function Title({
124
121
  }
125
122
 
126
123
  /**
127
- * Glass Footer Component
128
- * Tokens: cardMedia/footer/*, glass/minimal, blur/minimal
124
+ * Glass Footer — pinned to the bottom of the card, **always** on top of the
125
+ * Header (`zIndex: 2`).
126
+ *
127
+ * Glass implementation (April 2026 best practice for RN/Expo):
128
+ * - **iOS:** `expo-blur`'s `BlurView` renders a native `UIVisualEffectView`,
129
+ * so this is a real OS-level live blur of whatever's underneath. We pick
130
+ * `tint` from the Figma "Contrast Context" mode (`'dark'` / `'light'`)
131
+ * and a moderate intensity that matches the Figma `blur/minimal` token.
132
+ * - **Android:** the same `BlurView` with `experimentalBlurMethod="dimezisBlurView"`
133
+ * enables the hardware-accelerated `RenderEffect` blur on Android 12+.
134
+ * On older Android, expo-blur cleanly degrades to a tinted scrim — we
135
+ * layer a subtle noise/grain overlay on top so the surface still reads
136
+ * as "frosted glass" instead of a flat color.
137
+ * - **Web:** `BlurView` on web is implemented as `backdrop-filter: blur()`,
138
+ * which already worked in the previous version. Same component, same API.
139
+ *
140
+ * Tokens still drive the tint color, blur radius and inner spacing.
129
141
  */
130
142
  export function Footer({
131
143
  children,
@@ -138,28 +150,56 @@ export function Footer({
138
150
  const paddingHorizontal = parseFloat(getVariableByName('cardMedia/footer/padding/horizontal', modes) || '16');
139
151
  const paddingVertical = parseFloat(getVariableByName('cardMedia/footer/padding/vertical', modes) || '12');
140
152
 
141
- // Glass Effect
142
- // Figma:
143
- // blur/minimal/background: "#1414174a"
144
- // blur/minimal: 29
153
+ // Figma tokens:
154
+ // blur/minimal/background -> tint laid over the native blur
155
+ // blur/minimal -> blur radius (px). expo-blur takes a 0-100
156
+ // "intensity" instead of px; we map roughly:
157
+ // intensity ≈ clamp(radius * 1.7, 0, 100).
145
158
  const glassBgColor = getVariableByName('blur/minimal/background', modes) || '#1414174a';
146
159
  const blurRadius = parseFloat(getVariableByName('blur/minimal', modes) || '29');
147
- const containerStyle = {
148
- flexDirection: 'row',
149
- alignItems: 'center',
150
- gap,
151
- paddingHorizontal,
152
- paddingVertical,
153
- backgroundColor: glassBgColor,
154
- // Web-specific backdrop filter for glass effect
155
- // @ts-ignore
156
- ...(Platform.OS === 'web' ? {
157
- backdropFilter: `blur(${blurRadius}px)`
158
- } : {})
159
- };
160
- return /*#__PURE__*/_jsx(View, {
161
- style: [containerStyle, style],
162
- children: children
160
+ const intensity = Math.max(0, Math.min(100, Math.round(blurRadius * 1.7)));
161
+
162
+ // Pick the iOS/Android material tint from "Contrast Context" mode so the
163
+ // glass adapts to dark/light backgrounds the same way the Figma tokens do.
164
+ const contrast = modes['Contrast Context'] || 'on dark';
165
+ const tint = contrast === 'on light' ? 'light' : 'dark';
166
+ return /*#__PURE__*/_jsxs(View, {
167
+ style: [{
168
+ position: 'absolute',
169
+ left: 0,
170
+ right: 0,
171
+ bottom: 0,
172
+ overflow: 'hidden',
173
+ // zIndex 2 ensures Footer always paints above Header,
174
+ // regardless of which is rendered first in the tree.
175
+ zIndex: 2
176
+ }, style],
177
+ pointerEvents: "box-none",
178
+ children: [/*#__PURE__*/_jsx(BlurView, {
179
+ style: StyleSheet.absoluteFill,
180
+ tint: tint,
181
+ intensity: intensity,
182
+ experimentalBlurMethod: "dimezisBlurView"
183
+ }), /*#__PURE__*/_jsx(View, {
184
+ style: [StyleSheet.absoluteFill, {
185
+ backgroundColor: glassBgColor
186
+ }]
187
+ }), Platform.OS === 'android' ? /*#__PURE__*/_jsx(View, {
188
+ style: [StyleSheet.absoluteFill, {
189
+ backgroundColor: 'rgba(255,255,255,0.03)',
190
+ opacity: 0.6
191
+ }],
192
+ pointerEvents: "none"
193
+ }) : null, /*#__PURE__*/_jsx(View, {
194
+ style: {
195
+ flexDirection: 'row',
196
+ alignItems: 'center',
197
+ gap,
198
+ paddingHorizontal,
199
+ paddingVertical
200
+ },
201
+ children: children
202
+ })]
163
203
  });
164
204
  }
165
205