jfs-components 0.0.79 → 0.0.84

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 (110) hide show
  1. package/lib/commonjs/components/AppBar/AppBar.js +56 -6
  2. package/lib/commonjs/components/Attached/Attached.js +46 -7
  3. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  4. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  5. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  6. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  7. package/lib/commonjs/components/FormField/FormField.js +1 -14
  8. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
  9. package/lib/commonjs/components/ListItem/ListItem.js +6 -11
  10. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  11. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  12. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
  13. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  14. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  15. package/lib/commonjs/components/index.js +7 -0
  16. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  17. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  18. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  19. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  20. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  21. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  22. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  23. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  24. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  25. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  26. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  27. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  28. package/lib/commonjs/icons/components/index.js +132 -0
  29. package/lib/commonjs/icons/registry.js +2 -2
  30. package/lib/module/components/AppBar/AppBar.js +56 -6
  31. package/lib/module/components/Attached/Attached.js +46 -7
  32. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  33. package/lib/module/components/Drawer/Drawer.js +6 -1
  34. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  35. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  36. package/lib/module/components/FormField/FormField.js +3 -16
  37. package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
  38. package/lib/module/components/ListItem/ListItem.js +6 -11
  39. package/lib/module/components/MessageField/MessageField.js +3 -15
  40. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  41. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
  42. package/lib/module/components/Spinner/Spinner.js +212 -0
  43. package/lib/module/components/TextInput/TextInput.js +34 -19
  44. package/lib/module/components/index.js +1 -0
  45. package/lib/module/icons/components/IconArrowdown.js +12 -0
  46. package/lib/module/icons/components/IconArrowup.js +12 -0
  47. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  48. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  49. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  50. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  51. package/lib/module/icons/components/IconOsnavback.js +12 -0
  52. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  53. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  54. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  55. package/lib/module/icons/components/IconSignin.js +12 -0
  56. package/lib/module/icons/components/IconSignout.js +12 -0
  57. package/lib/module/icons/components/index.js +12 -0
  58. package/lib/module/icons/registry.js +2 -2
  59. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  60. package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
  61. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  62. package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
  63. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  64. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
  65. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  66. package/lib/typescript/src/components/index.d.ts +1 -0
  67. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  68. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  69. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  70. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  71. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  72. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  73. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  74. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  75. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  76. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  77. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  78. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  79. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  80. package/lib/typescript/src/icons/registry.d.ts +1 -1
  81. package/package.json +3 -2
  82. package/src/components/AppBar/AppBar.tsx +79 -12
  83. package/src/components/Attached/Attached.tsx +63 -7
  84. package/src/components/Checkbox/Checkbox.tsx +14 -2
  85. package/src/components/Drawer/Drawer.tsx +4 -0
  86. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  87. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  88. package/src/components/FormField/FormField.tsx +3 -19
  89. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
  90. package/src/components/ListItem/ListItem.tsx +14 -16
  91. package/src/components/MessageField/MessageField.tsx +3 -18
  92. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  93. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
  94. package/src/components/Spinner/Spinner.tsx +273 -0
  95. package/src/components/TextInput/TextInput.tsx +37 -19
  96. package/src/components/index.ts +1 -0
  97. package/src/icons/components/IconArrowdown.tsx +11 -0
  98. package/src/icons/components/IconArrowup.tsx +11 -0
  99. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  100. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  101. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  102. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  103. package/src/icons/components/IconOsnavback.tsx +11 -0
  104. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  105. package/src/icons/components/IconOsnavhome.tsx +11 -0
  106. package/src/icons/components/IconOsnavtask.tsx +11 -0
  107. package/src/icons/components/IconSignin.tsx +11 -0
  108. package/src/icons/components/IconSignout.tsx +11 -0
  109. package/src/icons/components/index.ts +12 -0
  110. package/src/icons/registry.ts +49 -1
@@ -439,6 +439,10 @@ function Drawer({
439
439
  style={[styles.content, contentStyle]}
440
440
  contentContainerStyle={[{ paddingBottom: paddingBottom + bottomInset, gap: drawerGap, flexDirection: 'column', alignItems: 'stretch' }, contentContainerStyle]}
441
441
  showsVerticalScrollIndicator={showsVerticalScrollIndicator}
442
+ // Let a tap on an input inside the sheet focus it on the FIRST tap
443
+ // even while the keyboard is already open (default 'never' would
444
+ // eat that tap just to dismiss the keyboard).
445
+ keyboardShouldPersistTaps="handled"
442
446
  animatedProps={animatedScrollProps}
443
447
  alwaysBounceVertical={false}
444
448
  overScrollMode="always"
@@ -117,8 +117,9 @@ export type DropdownInputProps = {
117
117
  */
118
118
  menuMaxHeight?: number
119
119
  /**
120
- * Pixel offset between the trigger and the popup. Defaults to 4 so the
121
- * popup visually peeks below the input.
120
+ * Pixel gap between the trigger and the popup. When omitted, it defaults
121
+ * to the `formField/gap` design token so the menu sits the same distance
122
+ * below the input as the rest of the field's internal spacing.
122
123
  */
123
124
  menuOffset?: number
124
125
  /**
@@ -325,7 +326,7 @@ function DropdownInput({
325
326
  supportText,
326
327
  errorMessage,
327
328
  menuMaxHeight = 240,
328
- menuOffset = 6,
329
+ menuOffset,
329
330
  matchTriggerWidth = true,
330
331
  closeOnBackdropPress = true,
331
332
  modes: propModes = EMPTY_MODES,
@@ -422,11 +423,30 @@ function DropdownInput({
422
423
  const tokens = useFormFieldTokens(modes)
423
424
  const chevron = useChevronTokens(modes)
424
425
 
426
+ // Gap between the input and the popup. Falls back to the `formField/gap`
427
+ // token so the menu's offset matches the field's own internal spacing.
428
+ const effectiveMenuOffset = menuOffset ?? tokens.gap
429
+
425
430
  // ---------------- Layout / measurement ----------------
426
431
  const triggerRef = useRef<View>(null)
427
432
  const [triggerRect, setTriggerRect] = useState<Rect | null>(null)
428
433
  const insets = useSafeAreaInsets()
429
434
 
435
+ // Android coordinate-space bridge.
436
+ //
437
+ // The popup lives inside a `statusBarTranslucent` Modal, whose window is
438
+ // laid out from the PHYSICAL top of the screen (behind the status bar).
439
+ // The trigger, however, is rendered inside the app's content area (Expo
440
+ // Router / react-native-screens under edge-to-edge), so its
441
+ // `measureInWindow` Y is relative to the content area — it does NOT include
442
+ // the status bar height. Feeding that Y straight into the Modal would place
443
+ // the popup one status-bar-height too high, landing it on top of the input.
444
+ //
445
+ // Adding `insets.top` converts the trigger's content-relative Y into the
446
+ // Modal's full-screen coordinate space. iOS/web share a single coordinate
447
+ // space for the Modal and the trigger, so no shift is needed there.
448
+ const windowTopOffset = Platform.OS === 'android' ? insets.top : 0
449
+
430
450
  const measure = useCallback(() => {
431
451
  if (!triggerRef.current) return
432
452
  triggerRef.current.measureInWindow((x, y, width, height) => {
@@ -503,7 +523,7 @@ function DropdownInput({
503
523
  menuSize?.height ?? menuMaxHeight,
504
524
  menuMaxHeight
505
525
  )
506
- const needed = desiredHeight + menuOffset + 8
526
+ const needed = desiredHeight + effectiveMenuOffset + 8
507
527
  if (placement === 'top') {
508
528
  return spaceAbove >= needed || spaceAbove >= spaceBelow
509
529
  ? 'top'
@@ -523,7 +543,7 @@ function DropdownInput({
523
543
  windowHeight,
524
544
  menuSize?.height,
525
545
  menuMaxHeight,
526
- menuOffset,
546
+ effectiveMenuOffset,
527
547
  insets.top,
528
548
  insets.bottom,
529
549
  ])
@@ -544,15 +564,18 @@ function DropdownInput({
544
564
  if (leftPos > maxLeft) leftPos = maxLeft
545
565
  if (leftPos < minLeft) leftPos = minLeft
546
566
 
567
+ // Trigger top expressed in the Modal's (full-screen) coordinate space.
568
+ const triggerTop = triggerRect.y + windowTopOffset
569
+
547
570
  let topPos: number
548
571
  if (computedPlacement === 'top') {
549
572
  const desiredHeight = menuSize?.height ?? menuMaxHeight
550
- topPos = triggerRect.y - desiredHeight - menuOffset
573
+ topPos = triggerTop - desiredHeight - effectiveMenuOffset
551
574
  if (topPos < insets.top + screenPadding) {
552
575
  topPos = insets.top + screenPadding
553
576
  }
554
577
  } else {
555
- topPos = triggerRect.y + triggerRect.height + menuOffset
578
+ topPos = triggerTop + triggerRect.height + effectiveMenuOffset
556
579
  }
557
580
 
558
581
  const style: ViewStyle = {
@@ -569,7 +592,8 @@ function DropdownInput({
569
592
  triggerRect,
570
593
  computedPlacement,
571
594
  menuSize,
572
- menuOffset,
595
+ effectiveMenuOffset,
596
+ windowTopOffset,
573
597
  menuMaxHeight,
574
598
  matchTriggerWidth,
575
599
  windowWidth,
@@ -779,22 +803,32 @@ function DropdownInput({
779
803
  )}
780
804
 
781
805
  {/*
782
- IMPORTANT: do NOT pass `statusBarTranslucent` to this Modal.
783
- On Android, a `statusBarTranslucent` Modal opens its own window
784
- that spans the entire screen (origin at screen-top, including
785
- the status bar), but `measureInWindow` on the trigger returns
786
- coordinates relative to the *activity* window — which on a
787
- default Android setup starts BELOW the status bar. The two
788
- coordinate spaces then differ by `StatusBar.currentHeight`, so
789
- `triggerRect.y + triggerRect.height + menuOffset` lands roughly
790
- one status-bar-height ABOVE the visible input, making the
791
- popup overlap the input row. Leaving `statusBarTranslucent`
792
- off keeps the Modal's window aligned with the activity
793
- window, which is what every measurement here assumes.
806
+ IMPORTANT: this Modal MUST be `statusBarTranslucent` (and
807
+ `navigationBarTranslucent`) on Android.
808
+
809
+ The app runs edge-to-edge (Expo SDK 54+ / Android 15 enforce it
810
+ and it cannot be disabled). That means the activity window spans
811
+ the entire physical screen, so `measureInWindow` on the trigger
812
+ returns a `y` measured from the very TOP of the screen — the
813
+ status bar height is INCLUDED.
814
+
815
+ A non-translucent Modal, however, opens a window whose content
816
+ area starts BELOW the status bar, so `top: 0` inside it maps to
817
+ screen-Y = statusBarHeight. Every `top` we compute is then
818
+ shifted UP by one status-bar-height relative to the trigger,
819
+ which (because the input row height is roughly a status bar tall)
820
+ drops the popup right on top of the input.
821
+
822
+ Making the Modal translucent gives it a full-screen window whose
823
+ origin matches the edge-to-edge activity window, so the
824
+ `measureInWindow` coordinates and the popup's absolute `top`/
825
+ `left` finally live in the same coordinate space.
794
826
  */}
795
827
  <Modal
796
828
  visible={isOpen}
797
829
  transparent
830
+ statusBarTranslucent
831
+ navigationBarTranslucent
798
832
  animationType="fade"
799
833
  onRequestClose={closeMenu}
800
834
  >
@@ -129,10 +129,6 @@ function ExpandableCheckbox({
129
129
 
130
130
  const rowGap =
131
131
  (getVariableByName('checkboxItem/gap', modes) as number | null) ?? 8
132
- const rowPaddingHorizontal =
133
- (getVariableByName('checkboxItem/padding/horizontal', modes) as number | null) ?? 0
134
- const rowPaddingVertical =
135
- (getVariableByName('checkboxItem/padding/vertical', modes) as number | null) ?? 0
136
132
 
137
133
  const labelColor =
138
134
  (getVariableByName('checkboxItem/foreground', modes) as string | null) ?? '#1a1c1f'
@@ -163,12 +159,10 @@ function ExpandableCheckbox({
163
159
  alignSelf: isExpanded ? 'stretch' : 'auto',
164
160
  minWidth: 0,
165
161
  flexDirection: 'row',
166
- alignItems: 'flex-start',
162
+ alignItems: isExpanded ? 'flex-start' : 'center',
167
163
  gap: rowGap,
168
- paddingHorizontal: rowPaddingHorizontal,
169
- paddingVertical: rowPaddingVertical,
170
164
  }),
171
- [isExpanded, rowGap, rowPaddingHorizontal, rowPaddingVertical]
165
+ [isExpanded, rowGap]
172
166
  )
173
167
 
174
168
  const resolvedLabelStyle: TextStyle = useMemo(
@@ -180,6 +174,13 @@ function ExpandableCheckbox({
180
174
  fontSize: labelFontSize,
181
175
  lineHeight: labelLineHeight,
182
176
  fontWeight: labelFontWeight,
177
+ // Android adds asymmetric font padding and top-aligns the glyph inside
178
+ // an inflated line box when `lineHeight` is set. That makes the centered
179
+ // checkbox look like it drops below the text. Disabling the extra
180
+ // padding + centering the glyph keeps the single-line label optically
181
+ // aligned with the checkbox. No-op on iOS / web.
182
+ includeFontPadding: false,
183
+ textAlignVertical: isExpanded ? 'top' : 'center',
183
184
  }),
184
185
  [
185
186
  labelColor,
@@ -187,11 +188,14 @@ function ExpandableCheckbox({
187
188
  labelFontSize,
188
189
  labelLineHeight,
189
190
  labelFontWeight,
191
+ isExpanded,
190
192
  ]
191
193
  )
192
194
 
195
+ // Layer component modes first (e.g. Color Mode), then button defaults so
196
+ // Secondary / XS / Low always win unless a dedicated override prop is added.
193
197
  const buttonModes = useMemo(
194
- () => ({ ...BUTTON_DEFAULT_MODES, ...modes }),
198
+ () => ({ ...modes, ...BUTTON_DEFAULT_MODES }),
195
199
  [modes]
196
200
  )
197
201
 
@@ -1,8 +1,7 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react'
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- Pressable,
6
5
  TextInput as RNTextInput,
7
6
  type StyleProp,
8
7
  type TextInputProps as RNTextInputProps,
@@ -347,16 +346,6 @@ function FormField({
347
346
  const [isFocused, setIsFocused] = useState(false)
348
347
  const interactive = !isDisabled && !isReadOnly
349
348
 
350
- // Ref to the native input so tapping anywhere in the input row (padding,
351
- // leading/trailing gutters) focuses it on the FIRST tap — fixing the Android
352
- // "two taps to open the keyboard" issue caused by the row intercepting the
353
- // initial touch.
354
- const inputRef = useRef<RNTextInput>(null)
355
- const focusInput = useCallback(() => {
356
- if (!interactive) return
357
- inputRef.current?.focus()
358
- }, [interactive])
359
-
360
349
  // FormField States cascade — error > read only/disabled > active (focused) > idle.
361
350
  // Disabled maps to "Read Only" since there is no dedicated disabled mode and
362
351
  // the visual treatment is closest. This is only the DEFAULT — an explicit
@@ -552,11 +541,7 @@ function FormField({
552
541
  </View>
553
542
  )}
554
543
 
555
- <Pressable
556
- style={[inputRowStyle, inputStyle]}
557
- onPress={focusInput}
558
- accessible={false}
559
- >
544
+ <View style={[inputRowStyle, inputStyle]}>
560
545
  {processedLeading != null && (
561
546
  <View
562
547
  accessibilityElementsHidden
@@ -566,7 +551,6 @@ function FormField({
566
551
  </View>
567
552
  )}
568
553
  <RNTextInput
569
- ref={inputRef}
570
554
  style={[inputTextStyles, inputTextStyle]}
571
555
  value={value ?? ''}
572
556
  onChangeText={handleChangeText}
@@ -594,7 +578,7 @@ function FormField({
594
578
  {processedTrailing}
595
579
  </View>
596
580
  )}
597
- </Pressable>
581
+ </View>
598
582
 
599
583
  {supportLabel != null && supportLabel !== '' && (
600
584
  <SupportText
@@ -374,6 +374,9 @@ function FullscreenModal({
374
374
  showsVerticalScrollIndicator={false}
375
375
  onScroll={onScroll}
376
376
  scrollEventThrottle={16}
377
+ // Tap an input in the body and it focuses on the FIRST tap, even when
378
+ // the keyboard is already open (default 'never' eats that tap).
379
+ keyboardShouldPersistTaps="handled"
377
380
  >
378
381
  <View style={heroTextRegionStyle}>
379
382
  <HeroText
@@ -11,7 +11,6 @@ import {
11
11
  type PressableStateCallbackType,
12
12
  } from 'react-native'
13
13
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
14
- import IconCapsule from '../IconCapsule/IconCapsule'
15
14
  import NavArrow from '../NavArrow/NavArrow'
16
15
  import { usePressableWebSupport, type WebAccessibilityProps } from '../../utils/web-platform-utils'
17
16
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -21,8 +20,8 @@ type ListItemProps = {
21
20
  title?: string;
22
21
  supportText?: string;
23
22
  showSupportText?: boolean;
24
- /** Leading slot (Figma "leading"). Defaults to an `IconCapsule` when omitted. */
25
- leading?: React.ReactNode;
23
+ /** Leading slot (Figma "leading"). Omitted or `null` renders nothing. */
24
+ leading?: React.ReactNode | null;
26
25
  supportSlot?: React.ReactNode;
27
26
  /** Trailing slot (Figma "trailing"), e.g. `MoneyValue` or `Button`. Horizontal layout only. */
28
27
  trailing?: React.ReactNode;
@@ -188,7 +187,7 @@ const verticalSupportTextOverride: TextStyle = { textAlign: 'center' }
188
187
  * @param {string} [props.title='Title'] - Primary title used in the horizontal layout.
189
188
  * @param {string} [props.supportText='Support Text'] - Support text used in both layouts when `supportSlot` is not provided.
190
189
  * @param {boolean} [props.showSupportText=true] - Toggles rendering of the support text in Horizontal layout.
191
- * @param {React.ReactNode} [props.leading] - Optional leading slot. Defaults to `IconCapsule`.
190
+ * @param {React.ReactNode|null} [props.leading] - Optional leading slot. Omitted or `null` renders nothing.
192
191
  * @param {React.ReactNode} [props.supportSlot] - Optional custom slot used instead of the default support text block.
193
192
  * @param {React.ReactNode} [props.trailing] - Optional trailing slot (Figma Slot "trailing"). Horizontal layout only.
194
193
  * @param {boolean} [props.navArrow=true] - Whether to show NavArrow on the far right (Horizontal layout only).
@@ -263,16 +262,15 @@ function ListItemImpl({
263
262
  // Process leading slot to pass modes to children. Memoized on
264
263
  // (leading, resolvedModes) so a parent re-render doesn't re-walk the tree.
265
264
  const leadingElement = useMemo(() => {
266
- const processed = leading
267
- ? cloneChildrenWithModes(
268
- React.Children.toArray(leading),
269
- tokens.resolvedModes,
270
- SLOT_FORCED_MODES
271
- )
272
- : []
273
- if (processed.length === 0) {
274
- return <IconCapsule modes={tokens.resolvedModes} accessibilityLabel={undefined} />
275
- }
265
+ if (leading == null) return null
266
+
267
+ const processed = cloneChildrenWithModes(
268
+ React.Children.toArray(leading),
269
+ tokens.resolvedModes,
270
+ SLOT_FORCED_MODES
271
+ )
272
+ if (processed.length === 0) return null
273
+
276
274
  return processed.length === 1 ? processed[0] : processed
277
275
  }, [leading, tokens.resolvedModes])
278
276
 
@@ -373,7 +371,7 @@ function ListItemImpl({
373
371
  if (layout === 'Horizontal') {
374
372
  const innerContent = (
375
373
  <View style={innerContentStyleArray}>
376
- {leadingElement}
374
+ {leadingElement ?? null}
377
375
  <View
378
376
  style={{
379
377
  flex: 1,
@@ -431,7 +429,7 @@ function ListItemImpl({
431
429
  // Vertical layout — icon on top, support text/slot below
432
430
  const verticalContent = (
433
431
  <View style={verticalContentStyleArray}>
434
- {leadingElement}
432
+ {leadingElement ?? null}
435
433
  {renderSupportContent()}
436
434
  </View>
437
435
  )
@@ -1,8 +1,7 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react'
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- Pressable,
6
5
  TextInput as RNTextInput,
7
6
  type StyleProp,
8
7
  type TextInputProps as RNTextInputProps,
@@ -297,15 +296,6 @@ function MessageField({
297
296
  const [isFocused, setIsFocused] = useState(false)
298
297
  const interactive = !isDisabled && !isReadOnly
299
298
 
300
- // Ref to the native textarea so tapping anywhere in the (padded) textarea
301
- // container focuses it on the FIRST tap, fixing the Android "two taps to
302
- // open the keyboard" issue.
303
- const inputRef = useRef<RNTextInput>(null)
304
- const focusInput = useCallback(() => {
305
- if (!interactive) return
306
- inputRef.current?.focus()
307
- }, [interactive])
308
-
309
299
  const { modes: globalModes } = useTokens()
310
300
  const baseModes = useMemo(
311
301
  () => ({ ...globalModes, ...propModes }),
@@ -508,13 +498,8 @@ function MessageField({
508
498
  </View>
509
499
  )}
510
500
 
511
- <Pressable
512
- style={[textareaContainerStyle, textareaStyle]}
513
- onPress={focusInput}
514
- accessible={false}
515
- >
501
+ <View style={[textareaContainerStyle, textareaStyle]}>
516
502
  <RNTextInput
517
- ref={inputRef}
518
503
  multiline
519
504
  value={currentValue}
520
505
  onChangeText={handleChangeText}
@@ -529,7 +514,7 @@ function MessageField({
529
514
  accessibilityHint={accessibilityHint}
530
515
  style={[inputTextStyle, inputStyle]}
531
516
  />
532
- </Pressable>
517
+ </View>
533
518
 
534
519
  {shouldShowCounter && (
535
520
  <Text style={counterTextStyle} accessibilityElementsHidden>
@@ -1,9 +1,9 @@
1
- import React, { type ReactNode, isValidElement, cloneElement } from 'react'
1
+ import React, { type ReactNode } from 'react'
2
2
  import { View, Text, type ViewStyle, type TextStyle } from 'react-native'
3
3
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
4
4
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
5
5
  import IconCapsule from '../IconCapsule/IconCapsule'
6
- import { EMPTY_MODES } from '../../utils/react-utils'
6
+ import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
7
7
 
8
8
  export type PaymentFeedbackProps = {
9
9
  /** Large heading text, typically a monetary value (e.g. "₹50,000") */
@@ -20,7 +20,11 @@ export type PaymentFeedbackProps = {
20
20
  iconName?: string
21
21
  /** Optional custom media slot that replaces the default IconCapsule */
22
22
  renderMedia?: ReactNode
23
- /** Mode configuration for design tokens */
23
+ /**
24
+ * Mode configuration for design tokens. Also drives the default
25
+ * IconCapsule's color — pass `AppearanceSystem: 'positive' | 'warning' |
26
+ * 'negative'` to render a green/orange/red capsule (defaults to `positive`).
27
+ */
24
28
  modes?: Record<string, any>
25
29
  style?: ViewStyle
26
30
  }
@@ -28,7 +32,7 @@ export type PaymentFeedbackProps = {
28
32
  export default function PaymentFeedback({
29
33
  title = '₹50,000',
30
34
  subtitle = 'Payment successful',
31
- body = 'Your payment has been\nsuccessfully processed.',
35
+ body,
32
36
  details = '18 March 2025, 4:15 pm\nTransaction ID: TXN121466784',
33
37
  showDetails = true,
34
38
  iconName = 'ic_confirm',
@@ -123,14 +127,17 @@ export default function PaymentFeedback({
123
127
  textAlign: 'center',
124
128
  }
125
129
 
126
- const mediaContent = isValidElement(renderMedia)
127
- ? cloneElement(renderMedia as React.ReactElement<any>, { modes })
128
- : renderMedia
130
+ // Cascade modes into a custom media slot (per the modes-cascade convention);
131
+ // any modes the consumer set on the slot child still take precedence.
132
+ const mediaContent =
133
+ renderMedia != null ? cloneChildrenWithModes(renderMedia, modes) : null
129
134
 
130
135
  const defaultMedia = (
131
136
  <IconCapsule
132
137
  iconName={iconName}
133
- modes={{ ...modes, 'Icon Capsule Size': 'L', Emphasis: 'High', 'Semantic Intent': 'System', AppearanceSystem: 'positive' }}
138
+ // `positive` is the default; consumers override the capsule color by
139
+ // passing `AppearanceSystem` (or any other mode) via the `modes` prop.
140
+ modes={{ AppearanceSystem: 'positive', ...modes, 'Icon Capsule Size': 'L', Emphasis: 'High', 'Semantic Intent': 'System' }}
134
141
  />
135
142
  )
136
143