jfs-components 0.0.63 → 0.0.65

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/lib/commonjs/components/Carousel/Carousel.js +12 -9
  3. package/lib/commonjs/components/Drawer/Drawer.js +116 -50
  4. package/lib/commonjs/components/IconButton/IconButton.js +42 -6
  5. package/lib/commonjs/components/IconCapsule/IconCapsule.js +5 -0
  6. package/lib/commonjs/components/Popup/Popup.js +2 -2
  7. package/lib/commonjs/components/Section/Section.js +280 -58
  8. package/lib/commonjs/components/UpiHandle/UpiHandle.js +19 -7
  9. package/lib/commonjs/icons/Icon.js +72 -75
  10. package/lib/commonjs/icons/registry.js +1 -1
  11. package/lib/commonjs/utils/MediaSource.js +181 -0
  12. package/lib/commonjs/utils/index.js +9 -1
  13. package/lib/module/components/Carousel/Carousel.js +12 -9
  14. package/lib/module/components/Drawer/Drawer.js +116 -50
  15. package/lib/module/components/IconButton/IconButton.js +42 -6
  16. package/lib/module/components/IconCapsule/IconCapsule.js +5 -0
  17. package/lib/module/components/Popup/Popup.js +2 -2
  18. package/lib/module/components/Section/Section.js +280 -58
  19. package/lib/module/components/UpiHandle/UpiHandle.js +20 -8
  20. package/lib/module/icons/Icon.js +72 -75
  21. package/lib/module/icons/registry.js +1 -1
  22. package/lib/module/utils/MediaSource.js +176 -0
  23. package/lib/module/utils/index.js +2 -1
  24. package/lib/typescript/src/components/Drawer/Drawer.d.ts +6 -1
  25. package/lib/typescript/src/components/IconButton/IconButton.d.ts +25 -14
  26. package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +12 -1
  27. package/lib/typescript/src/components/Section/Section.d.ts +42 -1
  28. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +17 -3
  29. package/lib/typescript/src/icons/Icon.d.ts +35 -16
  30. package/lib/typescript/src/icons/registry.d.ts +1 -1
  31. package/lib/typescript/src/utils/MediaSource.d.ts +63 -0
  32. package/lib/typescript/src/utils/index.d.ts +2 -0
  33. package/package.json +1 -1
  34. package/src/components/Carousel/Carousel.tsx +16 -17
  35. package/src/components/Drawer/Drawer.tsx +136 -60
  36. package/src/components/IconButton/IconButton.tsx +70 -11
  37. package/src/components/IconCapsule/IconCapsule.tsx +13 -0
  38. package/src/components/Popup/Popup.tsx +2 -2
  39. package/src/components/Section/Section.tsx +411 -71
  40. package/src/components/UpiHandle/UpiHandle.tsx +37 -11
  41. package/src/icons/Icon.tsx +91 -76
  42. package/src/icons/registry.ts +1 -1
  43. package/src/utils/MediaSource.tsx +220 -0
  44. package/src/utils/index.ts +2 -0
@@ -1,5 +1,5 @@
1
1
  import React, { useCallback, useEffect, useState, useRef } from 'react'
2
- import { Platform, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
2
+ import { Platform, StyleSheet, Text, useWindowDimensions, View, ViewStyle } from 'react-native'
3
3
  import {
4
4
  Gesture,
5
5
  GestureDetector,
@@ -79,6 +79,11 @@ type DrawerProps = {
79
79
  * as a tab bar. Defaults to 80.
80
80
  */
81
81
  bottomInset?: number
82
+ /**
83
+ * Called whenever the drawer settles into a new state (collapsed or
84
+ * expanded), so parent components can react programmatically.
85
+ */
86
+ onStateChange?: (state: 'collapsed' | 'expanded') => void
82
87
  }
83
88
 
84
89
  /**
@@ -102,6 +107,7 @@ function Drawer({
102
107
  contentContainerStyle,
103
108
  showsVerticalScrollIndicator = false,
104
109
  bottomInset = 80,
110
+ onStateChange,
105
111
  }: DrawerProps) {
106
112
  const { height: screenHeight } = useWindowDimensions()
107
113
 
@@ -164,13 +170,27 @@ function Drawer({
164
170
 
165
171
  // Update JS state for accessibility/logic if needed
166
172
  const updateMode = useCallback((newMode: 'collapsed' | 'expanded') => {
167
- setMode(newMode)
168
- }, [])
169
-
173
+ setMode((prev) => {
174
+ if (prev !== newMode) {
175
+ onStateChange?.(newMode)
176
+ }
177
+ return newMode
178
+ })
179
+ }, [onStateChange])
180
+
181
+ // Gesture policy:
182
+ // • activeOffsetY: require a clear *vertical* drag (10px) before this
183
+ // pan claims the gesture. Matches typical iOS scroll activation feel.
184
+ // • failOffsetX: if the finger crosses ~16px horizontally *before* we
185
+ // activate, surrender the gesture entirely so any horizontal child
186
+ // (FlatList horizontal, swiper, slider, etc.) can scroll cleanly
187
+ // without the drawer also translating on Y.
188
+ // • simultaneousWithExternalGesture(scrollRef): cooperate with the
189
+ // drawer's own internal vertical ScrollView for nested scrolling.
170
190
  const gesture = Gesture.Pan()
171
191
  .simultaneousWithExternalGesture(scrollRef)
172
- .activeOffsetY([-5, 5])
173
- .activeOffsetX([-5, 5])
192
+ .activeOffsetY([-10, 10])
193
+ .failOffsetX([-16, 16])
174
194
  .onStart(() => {
175
195
  context.value = { y: translateY.value }
176
196
  isDrawerActive.value = true
@@ -179,6 +199,16 @@ function Drawer({
179
199
  scrollTopTranslationOffset.value = 0
180
200
  })
181
201
  .onUpdate((event) => {
202
+ // Defense-in-depth: even after vertical activation, if the *current*
203
+ // motion is dominantly horizontal (e.g., the user activated with a
204
+ // small Y nudge and then curved into a horizontal swipe on a child
205
+ // carousel), don't translate the drawer this frame. failOffsetX
206
+ // already prevents activation in pure-horizontal swipes; this guards
207
+ // the diagonal-then-horizontal case.
208
+ if (Math.abs(event.translationX) > Math.abs(event.translationY) * 1.5) {
209
+ return
210
+ }
211
+
182
212
  // Logic for nested scrolling:
183
213
  // If we are at the expanded position (minTranslateY) AND content is
184
214
  // scrolled down (scrollY > 0), let the ScrollView handle the gesture.
@@ -299,6 +329,36 @@ function Drawer({
299
329
  const titleLineHeight = getVariableByName('drawer/title/lineHeight', modes) || 17
300
330
  const titlePaddingBottom = getVariableByName('drawer/titleWrap/padding/bottom', modes) || 8
301
331
 
332
+ // Drop shadow — Figma layers two shadows (primary + secondary) sharing
333
+ // the same offsetY/blur but with their own offsetX and color.
334
+ const shadowPrimaryOffsetX = (getVariableByName('drawer/shadow/primary/offsetX', modes) ?? 0) as number
335
+ const shadowPrimaryOffsetY = (getVariableByName('drawer/shadow/primary/offsetY', modes) ?? 16) as number
336
+ const shadowPrimaryBlur = (getVariableByName('drawer/shadow/primary/blur', modes) ?? 48) as number
337
+ const shadowPrimaryColor = (getVariableByName('drawer/shadow/primary/color', modes) ?? 'rgba(12, 13, 16, 0.16)') as string
338
+ const shadowSecondaryOffsetX = (getVariableByName('drawer/shadow/secondary/offsetX', modes) ?? 0) as number
339
+ const shadowSecondaryColor = (getVariableByName('drawer/shadow/secondary/color', modes) ?? 'rgba(12, 13, 16, 0.12)') as string
340
+
341
+ // Cross-platform shadow style. Web supports stacking two shadows via
342
+ // boxShadow. iOS only supports a single native shadow per view, so we
343
+ // apply the more prominent (primary) one. Android uses elevation.
344
+ const shadowStyle: ViewStyle = Platform.select({
345
+ web: {
346
+ boxShadow:
347
+ `${shadowSecondaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowSecondaryColor}, ` +
348
+ `${shadowPrimaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowPrimaryColor}`,
349
+ } as ViewStyle,
350
+ ios: {
351
+ shadowColor: shadowPrimaryColor,
352
+ shadowOffset: { width: shadowPrimaryOffsetX, height: shadowPrimaryOffsetY },
353
+ shadowOpacity: 1,
354
+ shadowRadius: shadowPrimaryBlur / 2,
355
+ },
356
+ android: {
357
+ elevation: 16,
358
+ },
359
+ default: {},
360
+ }) as ViewStyle
361
+
302
362
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer'
303
363
 
304
364
  return (
@@ -314,11 +374,8 @@ function Drawer({
314
374
  backgroundColor,
315
375
  borderTopLeftRadius: radius,
316
376
  borderTopRightRadius: radius,
317
- paddingLeft,
318
- paddingRight,
319
- paddingBottom,
320
- rowGap: drawerGap,
321
377
  },
378
+ shadowStyle,
322
379
  sheetStyle,
323
380
  animatedStyle,
324
381
  ]}
@@ -327,57 +384,73 @@ function Drawer({
327
384
  accessibilityLabel={undefined}
328
385
  accessibilityHint={accessibilityHint || 'Swipe up to expand, swipe down to collapse'}
329
386
  >
330
- {/* Handle Area */}
331
- <View style={[styles.handleArea, (!title && !header) && { paddingBottom: 0 }]}>
332
- <View
333
- style={[
334
- {
335
- backgroundColor: handleColor,
336
- width: handleWidth,
337
- height: handleHeight,
338
- borderRadius: handleRadius
339
- },
340
- ]}
341
- />
342
- </View>
343
-
344
- {/* Custom Header Slot */}
345
- {header}
346
-
347
- {/* Title (Legacy/Simple Mode) */}
348
- {title && (
349
- <Text
350
- style={[
351
- {
352
- color: titleColor,
353
- fontSize: titleSize,
354
- fontWeight: titleWeight as any,
355
- lineHeight: titleLineHeight,
356
- marginBottom: titlePaddingBottom,
357
- }
358
- ]}
359
- >
360
- {title}
361
- </Text>
362
- )}
363
-
364
- {/* Scrollable Content */}
365
- <AnimatedScrollView
366
- ref={scrollRef}
367
- style={[styles.content, contentStyle]}
368
- contentContainerStyle={[{ paddingBottom: paddingBottom + bottomInset, gap: drawerGap, flexDirection: 'column', alignItems: 'stretch' }, contentContainerStyle]}
369
- showsVerticalScrollIndicator={showsVerticalScrollIndicator}
370
- animatedProps={animatedScrollProps}
371
- alwaysBounceVertical={false}
372
- overScrollMode="always"
373
- onScroll={useAnimatedScrollHandler((event) => {
374
- scrollY.value = event.contentOffset.y
375
- })}
376
- scrollEventThrottle={16}
387
+ {/* Inner clip layer — keeps overflow:'hidden' off the shadow-carrying
388
+ outer view so iOS doesn't clip the drop shadow. */}
389
+ <View
390
+ style={[
391
+ styles.sheetInner,
392
+ {
393
+ borderTopLeftRadius: radius,
394
+ borderTopRightRadius: radius,
395
+ paddingLeft,
396
+ paddingRight,
397
+ paddingBottom,
398
+ rowGap: drawerGap,
399
+ },
400
+ ]}
377
401
  >
378
- {/* Prevent touch propagation for text selection if needed */}
379
- {children}
380
- </AnimatedScrollView>
402
+ {/* Handle Area */}
403
+ <View style={[styles.handleArea, (!title && !header) && { paddingBottom: 0 }]}>
404
+ <View
405
+ style={[
406
+ {
407
+ backgroundColor: handleColor,
408
+ width: handleWidth,
409
+ height: handleHeight,
410
+ borderRadius: handleRadius
411
+ },
412
+ ]}
413
+ />
414
+ </View>
415
+
416
+ {/* Custom Header Slot */}
417
+ {header}
418
+
419
+ {/* Title (Legacy/Simple Mode) */}
420
+ {title && (
421
+ <Text
422
+ style={[
423
+ {
424
+ color: titleColor,
425
+ fontSize: titleSize,
426
+ fontWeight: titleWeight as any,
427
+ lineHeight: titleLineHeight,
428
+ marginBottom: titlePaddingBottom,
429
+ }
430
+ ]}
431
+ >
432
+ {title}
433
+ </Text>
434
+ )}
435
+
436
+ {/* Scrollable Content */}
437
+ <AnimatedScrollView
438
+ ref={scrollRef}
439
+ style={[styles.content, contentStyle]}
440
+ contentContainerStyle={[{ paddingBottom: paddingBottom + bottomInset, gap: drawerGap, flexDirection: 'column', alignItems: 'stretch' }, contentContainerStyle]}
441
+ showsVerticalScrollIndicator={showsVerticalScrollIndicator}
442
+ animatedProps={animatedScrollProps}
443
+ alwaysBounceVertical={false}
444
+ overScrollMode="always"
445
+ onScroll={useAnimatedScrollHandler((event) => {
446
+ scrollY.value = event.contentOffset.y
447
+ })}
448
+ scrollEventThrottle={16}
449
+ >
450
+ {/* Prevent touch propagation for text selection if needed */}
451
+ {children}
452
+ </AnimatedScrollView>
453
+ </View>
381
454
  </Animated.View>
382
455
  </GestureDetector>
383
456
  </GestureHandlerRootView>
@@ -399,6 +472,9 @@ const styles = StyleSheet.create({
399
472
  width: '100%',
400
473
  position: 'absolute',
401
474
  top: 0,
475
+ },
476
+ sheetInner: {
477
+ flex: 1,
402
478
  overflow: 'hidden',
403
479
  },
404
480
  handleArea: {
@@ -11,9 +11,19 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
11
11
  import Icon from '../../icons/Icon'
12
12
  import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
13
13
  import { EMPTY_MODES } from '../../utils/react-utils'
14
+ import type { UnifiedSource } from '../../utils/MediaSource'
14
15
 
15
16
  type IconButtonProps = SafePressableProps & {
17
+ /** Built-in icon name from the registry (default state). */
16
18
  iconName?: string;
19
+ /**
20
+ * Unified fallback source for the default state, used when `iconName` is
21
+ * missing or not in the registry. Accepts a remote URI, an inline SVG XML
22
+ * string, a `require()` asset, an SVG React component, or a React element.
23
+ * The result is tinted with the button's mode-resolved icon color so it
24
+ * follows design tokens just like a built-in icon. See {@link UnifiedSource}.
25
+ */
26
+ source?: UnifiedSource;
17
27
  modes?: Record<string, any>;
18
28
  onPress?: () => void;
19
29
  disabled?: boolean;
@@ -29,10 +39,24 @@ type IconButtonProps = SafePressableProps & {
29
39
  * Icon to display when isToggle is true and isActive is true
30
40
  */
31
41
  activeIcon?: string;
42
+ /**
43
+ * Unified fallback source for the active state. Used when `activeIcon` is
44
+ * missing or not in the registry (and only when `isToggle` is true and
45
+ * `isActive` is true). Falls back to the default `source` if not provided.
46
+ * See {@link UnifiedSource}.
47
+ */
48
+ activeSource?: UnifiedSource;
32
49
  /**
33
50
  * Icon to display when isToggle is true and isActive is false
34
51
  */
35
52
  inactiveIcon?: string;
53
+ /**
54
+ * Unified fallback source for the inactive state. Used when `inactiveIcon`
55
+ * is missing or not in the registry (and only when `isToggle` is true and
56
+ * `isActive` is false). Falls back to the default `source` if not provided.
57
+ * See {@link UnifiedSource}.
58
+ */
59
+ inactiveSource?: UnifiedSource;
36
60
  /**
37
61
  * Whether the toggle button is in active state (only used when isToggle is true)
38
62
  */
@@ -109,8 +133,14 @@ function resolveIconButtonTokens(modes: Record<string, any>, disabled: boolean):
109
133
  * pressed transform mirrored via React state) — removed.
110
134
  * - Wrapped in `React.memo`.
111
135
  */
136
+ // Legacy default icon used when neither a `name` nor a `source` is supplied
137
+ // for the resolved slot. Kept as a constant rather than a destructuring
138
+ // default so source-only call sites don't accidentally render `'ic_card'`.
139
+ const LEGACY_DEFAULT_ICON_NAME = 'ic_card'
140
+
112
141
  function IconButton({
113
- iconName = 'ic_card',
142
+ iconName,
143
+ source,
114
144
  modes = EMPTY_MODES,
115
145
  onPress,
116
146
  disabled = false,
@@ -121,7 +151,9 @@ function IconButton({
121
151
  webAccessibilityProps,
122
152
  isToggle = false,
123
153
  activeIcon,
154
+ activeSource,
124
155
  inactiveIcon,
156
+ inactiveSource,
125
157
  isActive = false,
126
158
  ...rest
127
159
  }: IconButtonProps) {
@@ -160,16 +192,42 @@ function IconButton({
160
192
  userHandlersRef.current.onHoverIn = (rest as any)?.onHoverIn
161
193
  userHandlersRef.current.onHoverOut = (rest as any)?.onHoverOut
162
194
 
163
- // Determine which icon to display
164
- const finalIconName =
165
- isToggle
166
- ? (isActive && activeIcon
167
- ? activeIcon
168
- : (!isActive && inactiveIcon ? inactiveIcon : iconName))
169
- : iconName
195
+ // Resolve the active (name + source) pair for the current slot. Toggle
196
+ // mode picks active/inactive based on `isActive`; per-state overrides
197
+ // fall back to the default `iconName` / `source` when omitted. We then
198
+ // apply the legacy default icon only as a last resort, so a source-only
199
+ // call site (`<IconButton source="…" />`) renders the source instead of
200
+ // bleeding through to `'ic_card'`.
201
+ let resolvedIconName: string | undefined
202
+ let resolvedSource: UnifiedSource | undefined
203
+ if (isToggle) {
204
+ if (isActive) {
205
+ resolvedIconName = activeIcon ?? iconName
206
+ resolvedSource = activeSource ?? source
207
+ } else {
208
+ resolvedIconName = inactiveIcon ?? iconName
209
+ resolvedSource = inactiveSource ?? source
210
+ }
211
+ } else {
212
+ resolvedIconName = iconName
213
+ resolvedSource = source
214
+ }
215
+ if (!resolvedIconName && resolvedSource === undefined) {
216
+ resolvedIconName = LEGACY_DEFAULT_ICON_NAME
217
+ }
170
218
 
171
- // Generate default accessibility label from icon name if not provided
172
- const defaultAccessibilityLabel = accessibilityLabel || iconName.replace(/^ic_/, '').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
219
+ // Generate default accessibility label from the resolved icon name when
220
+ // possible. Source-only call sites should provide an explicit
221
+ // `accessibilityLabel`; we fall back to a generic 'Icon button' so we
222
+ // never crash on `iconName.replace(...)` when only a `source` is supplied.
223
+ const defaultAccessibilityLabel =
224
+ accessibilityLabel ||
225
+ (resolvedIconName
226
+ ? resolvedIconName
227
+ .replace(/^ic_/, '')
228
+ .replace(/_/g, ' ')
229
+ .replace(/\b\w/g, (l) => l.toUpperCase())
230
+ : 'Icon button')
173
231
 
174
232
  const webProps = usePressableWebSupport({
175
233
  restProps: rest,
@@ -235,7 +293,8 @@ function IconButton({
235
293
  {...webProps}
236
294
  >
237
295
  <Icon
238
- name={finalIconName}
296
+ {...(resolvedIconName !== undefined ? { name: resolvedIconName } : {})}
297
+ {...(resolvedSource !== undefined ? { source: resolvedSource } : {})}
239
298
  size={tokens.iconSize}
240
299
  color={tokens.iconColor}
241
300
  accessibilityElementsHidden={true}
@@ -4,9 +4,19 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
5
  import { EMPTY_MODES } from '../../utils/react-utils'
6
6
  import Icon from '../../icons/Icon'
7
+ import type { UnifiedSource } from '../../utils/MediaSource'
7
8
 
8
9
  type IconCapsuleProps = {
9
10
  iconName?: string;
11
+ /**
12
+ * Unified fallback source rendered when `iconName` is missing or not in the
13
+ * registry. Accepts a remote URI, an inline SVG XML string, a `require()`
14
+ * asset, an SVG React component, or an already-rendered element. The
15
+ * resulting media is tinted with the capsule's mode-resolved icon color so
16
+ * it follows design tokens just like a built-in icon. See
17
+ * {@link UnifiedSource}.
18
+ */
19
+ source?: UnifiedSource;
10
20
  modes?: Record<string, any>;
11
21
  accessibilityLabel?: string;
12
22
  accessibilityRole?: string;
@@ -55,6 +65,7 @@ function resolveIconCapsuleTokens(modes: Record<string, any>): IconCapsuleTokens
55
65
  * @component
56
66
  * @param {Object} props - Component props
57
67
  * @param {string} [props.iconName="ic_card"] - The name of the icon to display from the icon registry
68
+ * @param {UnifiedSource} [props.source] - Fallback source (remote URI, inline SVG XML, `require()` asset, SVG React component, or React element). Used when `iconName` is missing or unknown. Tinted with the mode-resolved icon color so it follows design tokens just like a built-in icon.
58
69
  * @param {Object} [props.modes={}] - Mode configuration for design tokens (e.g., {"Appearance": "Primary"})
59
70
  * @param {string} [props.accessibilityLabel] - Accessibility label for screen readers
60
71
  * @param {string} [props.accessibilityRole] - Accessibility role (defaults to "image" for decorative icons)
@@ -68,6 +79,7 @@ function resolveIconCapsuleTokens(modes: Record<string, any>): IconCapsuleTokens
68
79
  */
69
80
  function IconCapsule({
70
81
  iconName = 'ic_card',
82
+ source,
71
83
  modes: propModes = EMPTY_MODES,
72
84
  // accessibilityLabel is accepted on the type for API back-compat but the
73
85
  // component intentionally renders `accessibilityLabel={undefined}` (icons
@@ -105,6 +117,7 @@ function IconCapsule({
105
117
  >
106
118
  <Icon
107
119
  name={iconName}
120
+ {...(source !== undefined ? { source } : {})}
108
121
  size={tokens.iconSize}
109
122
  color={tokens.iconColor}
110
123
  accessibilityElementsHidden={true}
@@ -162,12 +162,12 @@ const Popup = forwardRef<PopupRef, PopupProps>(function Popup(
162
162
  <View style={styles.overlay}>
163
163
  <Animated.View
164
164
  style={[
165
- StyleSheet.absoluteFillObject,
165
+ StyleSheet.absoluteFill,
166
166
  { backgroundColor: backdropColor, opacity: backdropAnim },
167
167
  ]}
168
168
  >
169
169
  <Pressable
170
- style={StyleSheet.absoluteFillObject}
170
+ style={StyleSheet.absoluteFill}
171
171
  onPress={closeOnBackdropPress ? handleClose : undefined}
172
172
  accessibilityRole="button"
173
173
  accessibilityLabel="Close popup"