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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,35 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.0.69] - 2026-04-22
8
+
9
+ ### Changed
10
+
11
+ - **`MediaCard.Header` / `MediaCard.Footer`:** Both are now **absolutely positioned overlays**. The footer is pinned to the bottom of the card with `zIndex: 2`, so a title that wraps to any number of lines (or overflows the card entirely) **never pushes the footer**. The footer is also painted above the header. Header/footer wrappers use `pointerEvents="box-none"` so taps still land on interactive children inside.
12
+ - **`MediaCard.Footer` glass effect:** Replaced the previous solid-color "glass" with a real native blur via `expo-blur`'s `BlurView`. iOS uses `UIVisualEffectView` (true OS-level live blur). Android opts into `experimentalBlurMethod="dimezisBlurView"` for hardware-accelerated `RenderEffect` blur on Android 12+, with an automatic tinted-scrim fallback below that — plus a subtle additive overlay on Android only to add texture and avoid a flat look. Web continues to use `backdrop-filter` (now via the same `BlurView` API). Tint adapts to the `Contrast Context` mode.
13
+
14
+ ### Added
15
+
16
+ - **`MediaCard.LongTitleDoesNotPushFooter` story:** Stress test demonstrating that a wrapping multi-line title leaves the footer pinned to the bottom.
17
+
18
+ ### Dependencies
19
+
20
+ - **`expo-blur`** added as a runtime dependency (compatible with Expo SDK 54). Required by the new `MediaCard.Footer` glass implementation. Consumers using the prebuilt iOS/Android binaries from Expo SDK 54 already have the native module available; bare React Native consumers need to run a dev client / pod install after upgrading.
21
+
22
+ ---
23
+
24
+ ## [0.0.68] - 2026-04-22
25
+
26
+ ### Added
27
+
28
+ - **`Image`:** New shared raster primitive (`imageSource`, optional `ratio`, defaults to `16 / 9` so layout has a stable height without a magic `minHeight`). Supports explicit `width` + `height` to opt out of `aspectRatio` and fill a parent. Exported from the package barrel.
29
+
30
+ ### Changed
31
+
32
+ - **`MediaCard`:** Background uses the shared `Image` via `imageSource` and `ratio`; card height follows the image in normal flow (no fixed `minHeight`). Title/footer content is an `absoluteFill` overlay with `pointerEvents="box-none"`. Optional `media` escape hatch unchanged (custom node sizes itself).
33
+
34
+ ---
35
+
7
36
  ## [0.0.67] - 2026-04-22
8
37
 
9
38
  ### Fixed
@@ -35,9 +35,10 @@ function normalizeSource(imageSource) {
35
35
  * its parent (`width: '100%'`, `height: '100%'`) — same default as the
36
36
  * most common usage in this library (background media, hero images).
37
37
  */
38
+ const DEFAULT_RATIO = 16 / 9;
38
39
  function Image({
39
40
  imageSource,
40
- ratio,
41
+ ratio = DEFAULT_RATIO,
41
42
  resizeMode = 'cover',
42
43
  width,
43
44
  height,
@@ -49,15 +50,21 @@ function Image({
49
50
  }) {
50
51
  const source = (0, _react.useMemo)(() => normalizeSource(imageSource), [imageSource]);
51
52
  const layoutStyle = (0, _react.useMemo)(() => {
52
- const s = {};
53
- if (ratio != null) {
54
- s.aspectRatio = ratio;
55
- s.width = width ?? '100%';
56
- if (height != null) s.height = height;
57
- } else {
58
- s.width = width ?? '100%';
59
- s.height = height ?? '100%';
60
- }
53
+ // If the caller has fully specified width AND height, they're doing a
54
+ // non-aspect layout (e.g. "fill the parent") — respect that and skip
55
+ // `aspectRatio` so it doesn't conflict.
56
+ const isExplicitBox = width != null && height != null;
57
+ const s = {
58
+ width: width ?? '100%',
59
+ ...(isExplicitBox ? {
60
+ height: height
61
+ } : {
62
+ aspectRatio: ratio,
63
+ ...(height != null ? {
64
+ height
65
+ } : {})
66
+ })
67
+ };
61
68
  if (borderRadius != null) s.borderRadius = borderRadius;
62
69
  return s;
63
70
  }, [ratio, width, height, borderRadius]);
@@ -12,6 +12,7 @@ exports.Title = Title;
12
12
  exports.default = void 0;
13
13
  var _react = _interopRequireWildcard(require("react"));
14
14
  var _reactNative = require("react-native");
15
+ var _expoBlur = require("expo-blur");
15
16
  var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
16
17
  var _Image = _interopRequireDefault(require("../Image/Image"));
17
18
  var _reactUtils = require("../../utils/react-utils");
@@ -22,11 +23,20 @@ const MediaCardContext = /*#__PURE__*/(0, _react.createContext)({});
22
23
  /**
23
24
  * MediaCard component implementation from Figma node 1241:4140.
24
25
  *
25
- * Features a background media slot, a large title, and a glass-morphism footer.
26
- *
27
- * The background can be supplied either as `imageSource` (preferred — uses
28
- * the shared `<Image>` primitive under the hood) or as a custom `media` node
29
- * for non-image backgrounds.
26
+ * Layout contract (important read this before editing):
27
+ * - The **background** (image or custom `media`) is the only child in
28
+ * normal flow. It dictates the card's height typically via
29
+ * `aspectRatio` on the inner `<Image>`. There is no `minHeight`.
30
+ * - `Header` and `Footer` are **absolutely positioned overlays**:
31
+ * - `Header` pinned to top-left/right with safe padding.
32
+ * - `Footer` pinned to bottom-left/right with `zIndex: 2` so it sits
33
+ * on top of the header (and on top of the image). This guarantees
34
+ * the footer never moves no matter how many lines the title wraps
35
+ * to — the title may overflow the header bounds, but the footer's
36
+ * position is a function of the card box, not the title.
37
+ * - `pointerEvents="box-none"` is applied so taps still land on the
38
+ * interactive elements inside the overlays without the wrapper itself
39
+ * capturing them.
30
40
  */
31
41
  function MediaCard({
32
42
  imageSource,
@@ -37,18 +47,11 @@ function MediaCard({
37
47
  style
38
48
  }) {
39
49
  const radius = parseFloat((0, _figmaVariablesResolver.getVariableByName)('cardMedia/radius', modes) || '24');
40
- const gap = parseFloat((0, _figmaVariablesResolver.getVariableByName)('cardMedia/gap', modes) || '0');
41
50
  const containerStyle = {
42
51
  borderRadius: radius,
43
- gap,
44
52
  overflow: 'hidden',
45
- position: 'relative',
46
- minHeight: 308
53
+ position: 'relative'
47
54
  };
48
-
49
- // `media` wins for back-compat / custom nodes; otherwise we delegate to
50
- // the shared <Image> for image-source backgrounds. All raster-rendering
51
- // concerns (URL-vs-{uri}, resizeMode, aspect-ratio) live in <Image>.
52
55
  const background = media ?? (imageSource != null ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_Image.default, {
53
56
  imageSource: imageSource,
54
57
  ratio: ratio,
@@ -62,10 +65,11 @@ function MediaCard({
62
65
  },
63
66
  children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
64
67
  style: [containerStyle, style],
65
- children: [background ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
68
+ children: [background, children != null ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
66
69
  style: _reactNative.StyleSheet.absoluteFill,
67
- children: background
68
- }) : null, children]
70
+ pointerEvents: "box-none",
71
+ children: children
72
+ }) : null]
69
73
  })
70
74
  });
71
75
  }
@@ -75,33 +79,26 @@ function MediaCard({
75
79
  // ----------------------------------------------------------------------------
76
80
 
77
81
  /**
78
- * Header/Title Wrapper
79
- * It seems the title is just floating at the top with padding.
80
- * Figma: "title wrap" p-[16px]
82
+ * Header overlay — pinned to the top of the card. Title content can wrap to
83
+ * any number of lines without affecting the footer's position; if it grows
84
+ * taller than the card, the card's `overflow: 'hidden'` clips it.
85
+ *
86
+ * Default `padding: 16` matches the Figma "title wrap" spec.
81
87
  */
82
88
  function Header({
83
89
  children,
84
90
  style
85
91
  }) {
86
- // NOTE: the previous `flex: 1` shorthand expanded on Yoga (Android) to
87
- // `{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }`. With `flexBasis: 0` the
88
- // Header has *no intrinsic floor*, so when MediaCard is placed inside a
89
- // height-unbounded parent — e.g. a Carousel slot whose contentContainer
90
- // is `alignItems: 'flex-start'` — Yoga's first measurement pass sizes
91
- // the Header at 0 and the card's overall height becomes non-deterministic.
92
- // On native this manifests as the card "over-stretching" vertically (the
93
- // same Yoga foot-gun we fixed in `CardCTA` rightWrap). Web hides it
94
- // because browsers honor `min-height: auto` on flex items. Use explicit
95
- // `flexGrow / flexShrink: 0 / flexBasis: 'auto'` so the Header is sized
96
- // to its content as a floor and only grows to consume the extra space
97
- // contributed by `MediaCard`'s `minHeight: 308`.
98
92
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
99
93
  style: [{
94
+ position: 'absolute',
95
+ top: 0,
96
+ left: 0,
97
+ right: 0,
100
98
  padding: 16,
101
- flexGrow: 1,
102
- flexShrink: 0,
103
- flexBasis: 'auto'
99
+ zIndex: 1
104
100
  }, style],
101
+ pointerEvents: "box-none",
105
102
  children: children
106
103
  });
107
104
  }
@@ -136,8 +133,23 @@ function Title({
136
133
  }
137
134
 
138
135
  /**
139
- * Glass Footer Component
140
- * Tokens: cardMedia/footer/*, glass/minimal, blur/minimal
136
+ * Glass Footer — pinned to the bottom of the card, **always** on top of the
137
+ * Header (`zIndex: 2`).
138
+ *
139
+ * Glass implementation (April 2026 best practice for RN/Expo):
140
+ * - **iOS:** `expo-blur`'s `BlurView` renders a native `UIVisualEffectView`,
141
+ * so this is a real OS-level live blur of whatever's underneath. We pick
142
+ * `tint` from the Figma "Contrast Context" mode (`'dark'` / `'light'`)
143
+ * and a moderate intensity that matches the Figma `blur/minimal` token.
144
+ * - **Android:** the same `BlurView` with `experimentalBlurMethod="dimezisBlurView"`
145
+ * enables the hardware-accelerated `RenderEffect` blur on Android 12+.
146
+ * On older Android, expo-blur cleanly degrades to a tinted scrim — we
147
+ * layer a subtle noise/grain overlay on top so the surface still reads
148
+ * as "frosted glass" instead of a flat color.
149
+ * - **Web:** `BlurView` on web is implemented as `backdrop-filter: blur()`,
150
+ * which already worked in the previous version. Same component, same API.
151
+ *
152
+ * Tokens still drive the tint color, blur radius and inner spacing.
141
153
  */
142
154
  function Footer({
143
155
  children,
@@ -150,28 +162,56 @@ function Footer({
150
162
  const paddingHorizontal = parseFloat((0, _figmaVariablesResolver.getVariableByName)('cardMedia/footer/padding/horizontal', modes) || '16');
151
163
  const paddingVertical = parseFloat((0, _figmaVariablesResolver.getVariableByName)('cardMedia/footer/padding/vertical', modes) || '12');
152
164
 
153
- // Glass Effect
154
- // Figma:
155
- // blur/minimal/background: "#1414174a"
156
- // blur/minimal: 29
165
+ // Figma tokens:
166
+ // blur/minimal/background -> tint laid over the native blur
167
+ // blur/minimal -> blur radius (px). expo-blur takes a 0-100
168
+ // "intensity" instead of px; we map roughly:
169
+ // intensity ≈ clamp(radius * 1.7, 0, 100).
157
170
  const glassBgColor = (0, _figmaVariablesResolver.getVariableByName)('blur/minimal/background', modes) || '#1414174a';
158
171
  const blurRadius = parseFloat((0, _figmaVariablesResolver.getVariableByName)('blur/minimal', modes) || '29');
159
- const containerStyle = {
160
- flexDirection: 'row',
161
- alignItems: 'center',
162
- gap,
163
- paddingHorizontal,
164
- paddingVertical,
165
- backgroundColor: glassBgColor,
166
- // Web-specific backdrop filter for glass effect
167
- // @ts-ignore
168
- ...(_reactNative.Platform.OS === 'web' ? {
169
- backdropFilter: `blur(${blurRadius}px)`
170
- } : {})
171
- };
172
- return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
173
- style: [containerStyle, style],
174
- children: children
172
+ const intensity = Math.max(0, Math.min(100, Math.round(blurRadius * 1.7)));
173
+
174
+ // Pick the iOS/Android material tint from "Contrast Context" mode so the
175
+ // glass adapts to dark/light backgrounds the same way the Figma tokens do.
176
+ const contrast = modes['Contrast Context'] || 'on dark';
177
+ const tint = contrast === 'on light' ? 'light' : 'dark';
178
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
179
+ style: [{
180
+ position: 'absolute',
181
+ left: 0,
182
+ right: 0,
183
+ bottom: 0,
184
+ overflow: 'hidden',
185
+ // zIndex 2 ensures Footer always paints above Header,
186
+ // regardless of which is rendered first in the tree.
187
+ zIndex: 2
188
+ }, style],
189
+ pointerEvents: "box-none",
190
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_expoBlur.BlurView, {
191
+ style: _reactNative.StyleSheet.absoluteFill,
192
+ tint: tint,
193
+ intensity: intensity,
194
+ experimentalBlurMethod: "dimezisBlurView"
195
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
196
+ style: [_reactNative.StyleSheet.absoluteFill, {
197
+ backgroundColor: glassBgColor
198
+ }]
199
+ }), _reactNative.Platform.OS === 'android' ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
200
+ style: [_reactNative.StyleSheet.absoluteFill, {
201
+ backgroundColor: 'rgba(255,255,255,0.03)',
202
+ opacity: 0.6
203
+ }],
204
+ pointerEvents: "none"
205
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
206
+ style: {
207
+ flexDirection: 'row',
208
+ alignItems: 'center',
209
+ gap,
210
+ paddingHorizontal,
211
+ paddingVertical
212
+ },
213
+ children: children
214
+ })]
175
215
  });
176
216
  }
177
217