jfs-components 0.1.28 → 0.1.30

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 (26) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
  3. package/lib/commonjs/components/ContentSheet/ContentSheet.js +66 -6
  4. package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
  5. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
  6. package/lib/commonjs/components/Table/Table.js +27 -7
  7. package/lib/commonjs/icons/registry.js +1 -1
  8. package/lib/module/components/CompareTable/CompareTable.js +191 -26
  9. package/lib/module/components/ContentSheet/ContentSheet.js +69 -9
  10. package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
  11. package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
  12. package/lib/module/components/Table/Table.js +27 -7
  13. package/lib/module/icons/registry.js +1 -1
  14. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
  15. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +14 -1
  16. package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
  17. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
  18. package/lib/typescript/src/components/Table/Table.d.ts +9 -1
  19. package/lib/typescript/src/icons/registry.d.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/components/CompareTable/CompareTable.tsx +224 -30
  22. package/src/components/ContentSheet/ContentSheet.tsx +88 -8
  23. package/src/components/MoneyValue/MoneyValue.tsx +216 -65
  24. package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
  25. package/src/components/Table/Table.tsx +29 -4
  26. package/src/icons/registry.ts +1 -1
@@ -1,9 +1,14 @@
1
- import React, { useMemo, useEffect, useRef } from 'react'
1
+ import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
+ TextInput,
5
6
  Pressable,
6
7
  Animated,
8
+ Platform,
9
+ Keyboard,
10
+ type GestureResponderEvent,
11
+ type LayoutChangeEvent,
7
12
  type StyleProp,
8
13
  type ViewStyle,
9
14
  type TextStyle,
@@ -58,6 +63,15 @@ export type MoneyValueProps = {
58
63
  hidden?: boolean
59
64
  /** When true, a blinking vertical cursor is shown at the end of the value text. */
60
65
  focused?: boolean
66
+ /**
67
+ * When true, tapping the component enters an inline edit mode where the value
68
+ * becomes a numeric TextInput. The user can type digits or delete the existing
69
+ * amount. Only numeric characters (including one decimal point) are accepted.
70
+ * On blur or submit the edit is committed and the display restores.
71
+ */
72
+ editable?: boolean
73
+ /** Called when the value changes during editing (after commit on blur). */
74
+ onValueChange?: (newValue: string | number) => void
61
75
  /** Modes configuration mapped to Figma tokens. */
62
76
  modes?: Modes
63
77
  style?: StyleProp<ViewStyle>
@@ -85,6 +99,8 @@ function MoneyValue({
85
99
  negative,
86
100
  focused = false,
87
101
  hidden = false,
102
+ editable = false,
103
+ onValueChange,
88
104
  modes = EMPTY_MODES,
89
105
  style,
90
106
  valueStyle,
@@ -96,23 +112,60 @@ function MoneyValue({
96
112
  ...rest
97
113
  }: MoneyValueProps) {
98
114
  // Auto-detect negative from value and compute display value
99
- const { isNegative, displayValue } = useMemo(() => {
100
- const stringValue = String(value)
101
- const trimmed = stringValue.trim()
102
- const valueIsNegative =
103
- trimmed.startsWith('-') || (typeof value === 'number' && value < 0)
115
+ const stringValue = String(value)
116
+ const trimmed = stringValue.trim()
117
+ const valueIsNegative =
118
+ trimmed.startsWith('-') || (typeof value === 'number' && value < 0)
104
119
 
105
- // Strip leading minus sign for display (we show it separately)
106
- const absoluteValue = trimmed.startsWith('-') ? trimmed.slice(1) : trimmed
120
+ // Strip leading minus sign for display (we show it separately)
121
+ const absoluteValue = trimmed.startsWith('-') ? trimmed.slice(1) : trimmed
107
122
 
108
- // Use explicit negative prop if provided, otherwise use auto-detected
109
- const showNegative = negative !== undefined ? negative : valueIsNegative
123
+ // Use explicit negative prop if provided, otherwise use auto-detected
124
+ const showNegative = negative !== undefined ? negative : valueIsNegative
110
125
 
111
- return {
112
- isNegative: hidden ? false : showNegative,
113
- displayValue: hidden ? '•••' : absoluteValue,
126
+ const isNegative = hidden ? false : showNegative
127
+ const displayValue = hidden ? '•••' : absoluteValue
128
+
129
+ // Inline editing state
130
+ const [isEditing, setIsEditing] = useState(false)
131
+ const [editBuffer, setEditBuffer] = useState('')
132
+ const inputRef = useRef<TextInput>(null)
133
+ // Always-current snapshot of the display value for callbacks to reference.
134
+ const displayValueRef = useRef(displayValue)
135
+ displayValueRef.current = displayValue
136
+
137
+ const enterEditMode = useCallback(() => {
138
+ setIsEditing(true)
139
+ setEditBuffer(displayValueRef.current)
140
+ // Focus the input on next frame so the keyboard opens
141
+ setTimeout(() => inputRef.current?.focus(), 0)
142
+ }, [])
143
+
144
+ const commitEdit = useCallback(() => {
145
+ setIsEditing(false)
146
+ const resolved = editBuffer || '0'
147
+ if (resolved !== displayValueRef.current) {
148
+ onValueChange?.(resolved)
149
+ }
150
+ }, [editBuffer, onValueChange])
151
+
152
+ const handleChangeText = useCallback(
153
+ (text: string) => {
154
+ // Allow only digits and at most one decimal point
155
+ const filtered = text.replace(/[^0-9.]/g, '')
156
+ const parts = filtered.split('.')
157
+ const clean = parts.length > 2 ? parts[0] + '.' + parts.slice(1).join('') : filtered
158
+ setEditBuffer(clean)
159
+ },
160
+ [],
161
+ )
162
+
163
+ const handlePress = useCallback((event: GestureResponderEvent) => {
164
+ if (editable) {
165
+ enterEditMode()
114
166
  }
115
- }, [value, negative, hidden])
167
+ onPress?.(event)
168
+ }, [editable, enterEditMode, onPress])
116
169
 
117
170
  // Resolve typography and layout tokens from Figma
118
171
  const textColor = getVariableByName('moneyValue/text/color', modes)
@@ -191,64 +244,162 @@ function MoneyValue({
191
244
  }
192
245
  }, [focused, cursorOpacity])
193
246
 
194
- return (
195
- <Pressable
196
- style={[containerStyle, style]}
197
- accessibilityRole="text"
198
- accessibilityLabel={undefined}
199
- accessibilityHint={accessibilityHint}
200
- onPress={onPress}
201
- disabled={!onPress}
202
- {...rest}
203
- >
204
- {isNegative && (
205
- <Text
206
- style={[currencyTextStyle, negativeSignStyle]}
207
- accessibilityElementsHidden={true}
208
- importantForAccessibility="no"
209
- >
210
- -
211
- </Text>
212
- )}
213
- <Text
214
- style={[currencyTextStyle, currencyStyle]}
215
- accessibilityElementsHidden={true}
216
- importantForAccessibility="no"
217
- >
218
- {resolvedCurrency}
219
- </Text>
220
-
221
- {/* Group value and cursor in their own view to bypass the parent's generic gap constraint */}
222
- <View style={{ flexDirection: 'row', alignItems: 'center' }}>
223
- <Text
224
- style={[valueTextStyle, valueStyle]}
225
- accessibilityElementsHidden={true}
226
- importantForAccessibility="no"
227
- >
228
- {displayValue}
229
- </Text>
230
- {focused && (
231
- <Animated.View
232
- style={{
233
- opacity: cursorOpacity,
234
- width: 2,
235
- height: (valueFontSize as number) * 1.1,
236
- backgroundColor: textColor,
237
- marginLeft: 2,
238
- }}
239
- />
240
- )}
241
- </View>
242
- </Pressable>
247
+ // Keyboard avoidance — lift the component when the keyboard would cover it.
248
+ // Follows the same pattern as ActionFooter (uses Keyboard.addListener).
249
+ // Resets when editing ends or the keyboard hides.
250
+ const rootRef = useRef<View>(null)
251
+ const keyboardLift = useRef(new Animated.Value(0)).current
252
+ const rootWindowYRef = useRef(0)
253
+ const rootMeasuredHeightRef = useRef(0)
254
+
255
+ const measureRoot = useCallback(() => {
256
+ rootRef.current?.measureInWindow((_x, y, _w, h) => {
257
+ rootWindowYRef.current = y
258
+ rootMeasuredHeightRef.current = h
259
+ })
260
+ }, [])
261
+
262
+ const handleRootLayout = useCallback(
263
+ (_e: LayoutChangeEvent) => {
264
+ // measureInWindow gives the component's position relative to the window,
265
+ // which is what we need for keyboard overlap calculation. onLayout fires
266
+ // after the component mounts and whenever its size changes.
267
+ measureRoot()
268
+ },
269
+ [measureRoot],
243
270
  )
244
- }
245
271
 
246
- export default MoneyValue
272
+ useEffect(() => {
273
+ if (!editable) return
274
+
275
+ const showSub = Keyboard.addListener('keyboardDidShow', (e) => {
276
+ measureRoot()
277
+ if (!isEditing) return
247
278
 
279
+ const kbHeight = e.endCoordinates.height
280
+ const { height: screenH } = require('react-native').Dimensions.get('window')
281
+ const componentBottom = rootWindowYRef.current + rootMeasuredHeightRef.current
282
+ const visibleBottom = screenH - kbHeight
283
+ const overlap = componentBottom - visibleBottom + 12
248
284
 
285
+ if (overlap > 0) {
286
+ Animated.timing(keyboardLift, {
287
+ toValue: -overlap,
288
+ duration: e.duration ?? 250,
289
+ useNativeDriver: true,
290
+ }).start()
291
+ }
292
+ })
249
293
 
294
+ const hideSub = Keyboard.addListener('keyboardDidHide', () => {
295
+ Animated.timing(keyboardLift, {
296
+ toValue: 0,
297
+ duration: 150,
298
+ useNativeDriver: true,
299
+ }).start()
300
+ })
250
301
 
302
+ return () => {
303
+ showSub.remove()
304
+ hideSub.remove()
305
+ }
306
+ }, [editable, isEditing, keyboardLift, measureRoot])
307
+
308
+ // Reset lift when editing ends
309
+ useEffect(() => {
310
+ if (!isEditing) {
311
+ keyboardLift.setValue(0)
312
+ }
313
+ }, [isEditing, keyboardLift])
251
314
 
315
+ const handleBlur = useCallback(() => {
316
+ commitEdit()
317
+ }, [commitEdit])
252
318
 
319
+ const handleSubmitEditing = useCallback(() => {
320
+ commitEdit()
321
+ }, [commitEdit])
253
322
 
323
+ return (
324
+ <Animated.View style={{ transform: [{ translateY: keyboardLift }] }}>
325
+ <View ref={rootRef} onLayout={handleRootLayout}>
326
+ <Pressable
327
+ style={[containerStyle, style]}
328
+ accessibilityRole="text"
329
+ accessibilityLabel={undefined}
330
+ accessibilityHint={accessibilityHint}
331
+ onPress={handlePress}
332
+ disabled={!onPress && !editable}
333
+ {...rest}
334
+ >
335
+ {isNegative && (
336
+ <Text
337
+ style={[currencyTextStyle, negativeSignStyle]}
338
+ accessibilityElementsHidden={true}
339
+ importantForAccessibility="no"
340
+ >
341
+ -
342
+ </Text>
343
+ )}
344
+ <Text
345
+ style={[currencyTextStyle, currencyStyle]}
346
+ accessibilityElementsHidden={true}
347
+ importantForAccessibility="no"
348
+ >
349
+ {resolvedCurrency}
350
+ </Text>
351
+
352
+ {/* Group value and cursor in their own view to bypass the parent's generic gap constraint */}
353
+ <View style={{ flexDirection: 'row', alignItems: 'center' }}>
354
+ {isEditing ? (
355
+ <TextInput
356
+ ref={inputRef}
357
+ value={editBuffer}
358
+ onChangeText={handleChangeText}
359
+ onBlur={handleBlur}
360
+ onSubmitEditing={handleSubmitEditing}
361
+ keyboardType="decimal-pad"
362
+ returnKeyType="done"
363
+ selectTextOnFocus
364
+ style={[
365
+ valueTextStyle,
366
+ valueStyle,
367
+ {
368
+ padding: 0,
369
+ margin: 0,
370
+ // On web a thin border helps the cursor appear precisely
371
+ ...(Platform.OS === 'web' ? { outlineStyle: 'none' as any } : {}),
372
+ },
373
+ ]}
374
+ accessibilityLabel={accessibilityLabel || 'Edit value'}
375
+ />
376
+ ) : (
377
+ <>
378
+ <Text
379
+ style={[valueTextStyle, valueStyle]}
380
+ accessibilityElementsHidden={true}
381
+ importantForAccessibility="no"
382
+ >
383
+ {displayValue}
384
+ </Text>
385
+ {focused && (
386
+ <Animated.View
387
+ style={{
388
+ opacity: cursorOpacity,
389
+ width: 2,
390
+ height: (valueFontSize as number) * 1.1,
391
+ backgroundColor: textColor,
392
+ marginLeft: 2,
393
+ }}
394
+ />
395
+ )}
396
+ </>
397
+ )}
398
+ </View>
399
+ </Pressable>
400
+ </View>
401
+ </Animated.View>
402
+ )
403
+ }
254
404
 
405
+ export default MoneyValue
@@ -50,6 +50,30 @@ export interface PdpCcCardProps {
50
50
  title?: string
51
51
  /** Subtitle rendered below the title (14px medium). */
52
52
  subtitle?: string
53
+ /**
54
+ * Number of lines to limit the title to. The `Title` component defaults to
55
+ * `1` when this is unset, so the headline is a single line with an ellipsis
56
+ * by default. Pass a larger value (e.g. `2`) to allow the title to wrap onto
57
+ * that many lines before truncating, or set {@link disableTruncation} to
58
+ * remove the clamp entirely. The subtitle is never line-clamped.
59
+ */
60
+ numberOfLines?: number
61
+ /**
62
+ * When `true`, disables truncation for both the title and subtitle: the
63
+ * trailing ellipsis (`…`) is never shown and the `numberOfLines` clamp is
64
+ * ignored. The text is also never shrunk by a tight parent — it keeps its
65
+ * full intrinsic size and overflows (even breaking the parent layout) rather
66
+ * than ever clipping into an ellipsis. The text still wraps onto multiple
67
+ * lines.
68
+ */
69
+ disableTruncation?: boolean
70
+ /**
71
+ * When `true`, forces both the title and subtitle onto a single line with no
72
+ * wrapping (implies {@link disableTruncation}). On web the line overflows
73
+ * horizontally — brutally breaking the parent layout if it has to — instead
74
+ * of wrapping or truncating.
75
+ */
76
+ singleLine?: boolean
53
77
  /**
54
78
  * The metric columns rendered in the stats row. Vertical dividers are
55
79
  * inserted automatically between adjacent metrics. Defaults to two sample
@@ -105,6 +129,9 @@ function PdpCcCard({
105
129
  media,
106
130
  title = 'Title',
107
131
  subtitle = 'Subtitle',
132
+ numberOfLines,
133
+ disableTruncation,
134
+ singleLine,
108
135
  metrics = DEFAULT_METRICS,
109
136
  buttonLabel = 'button',
110
137
  buttonIcon = 'ic_add_circle',
@@ -165,7 +192,15 @@ function PdpCcCard({
165
192
  <>
166
193
  <View style={styles.mediaSlot}>{mediaNode}</View>
167
194
 
168
- <Title title={title} subtitle={subtitle} textAlign="Center" modes={modes} />
195
+ <Title
196
+ title={title}
197
+ subtitle={subtitle}
198
+ textAlign="Center"
199
+ modes={modes}
200
+ numberOfLines={numberOfLines}
201
+ disableTruncation={disableTruncation}
202
+ singleLine={singleLine}
203
+ />
169
204
 
170
205
  {metrics.length > 0 ? (
171
206
  <View style={styles.metricsRow}>
@@ -1,4 +1,4 @@
1
- import React from 'react'
1
+ import React, { createContext, useContext } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
@@ -13,6 +13,16 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
13
13
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
14
14
  import type { Modes } from '../../design-tokens'
15
15
 
16
+ /**
17
+ * Carries the root `Table`'s `columnWidth` down to every cell so that, when it
18
+ * is set, all columns (header + body, data-driven or composed) share the same
19
+ * fixed width without each cell having to repeat the prop. Cells still honour
20
+ * an explicit `width` prop over this context value. `undefined` (the default,
21
+ * when no `Table` ancestor is present, or `columnWidth` is unset) leaves cells
22
+ * to flex — preserving the original behaviour.
23
+ */
24
+ const TableContext = createContext<number | undefined>(undefined)
25
+
16
26
  /* -------------------------------------------------------------------------------------------------
17
27
  * Shared types & token resolution
18
28
  * -----------------------------------------------------------------------------------------------*/
@@ -135,6 +145,10 @@ function TableCell({
135
145
  style,
136
146
  }: TableCellProps) {
137
147
  const t = resolveTableTokens(modes)
148
+ // A cell's width resolves in priority order: an explicit `width` prop, then
149
+ // the root Table's `columnWidth` (via context), then fall back to flex.
150
+ const ctxColumnWidth = useContext(TableContext)
151
+ const effectiveWidth = width ?? ctxColumnWidth
138
152
  return (
139
153
  <View
140
154
  style={[
@@ -148,7 +162,7 @@ function TableCell({
148
162
  borderColor: t.borderColor,
149
163
  borderBottomWidth: isLastRow ? 0 : t.borderSize,
150
164
  borderRightWidth: isLastColumn ? 0 : t.borderSize,
151
- ...(width != null ? { width } : { flex, minWidth: 0 }),
165
+ ...(effectiveWidth != null ? { width: effectiveWidth } : { flex, minWidth: 0 }),
152
166
  },
153
167
  style,
154
168
  ]}
@@ -188,6 +202,8 @@ function TableHeaderCell({
188
202
  style,
189
203
  }: TableHeaderCellProps) {
190
204
  const t = resolveTableTokens(modes)
205
+ const ctxColumnWidth = useContext(TableContext)
206
+ const effectiveWidth = width ?? ctxColumnWidth
191
207
  return (
192
208
  <View
193
209
  style={[
@@ -198,7 +214,7 @@ function TableHeaderCell({
198
214
  justifyContent: alignToJustify(align),
199
215
  paddingHorizontal: t.headerPaddingH,
200
216
  paddingVertical: t.headerPaddingV,
201
- ...(width != null ? { width } : { flex, minWidth: 0 }),
217
+ ...(effectiveWidth != null ? { width: effectiveWidth } : { flex, minWidth: 0 }),
202
218
  },
203
219
  style,
204
220
  ]}
@@ -327,6 +343,14 @@ export type TableProps<T = Record<string, unknown>> = {
327
343
  * for wide tables that exceed the viewport on mobile. @default false
328
344
  */
329
345
  scrollable?: boolean
346
+ /**
347
+ * Fixed width (px) applied to every column — header and body alike — so
348
+ * all columns share the same width. An explicit per-column `width` (on
349
+ * `TableColumn`, or on an individual `Table.Cell`/`Table.HeaderCell`) still
350
+ * takes precedence when set. Omit to let columns flex to share the
351
+ * available space.
352
+ */
353
+ columnWidth?: number
330
354
  /** Accessibility label for the table container. */
331
355
  accessibilityLabel?: string
332
356
  /** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
@@ -360,6 +384,7 @@ function Table<T = Record<string, unknown>>({
360
384
  getRowKey,
361
385
  onRowPress,
362
386
  scrollable = false,
387
+ columnWidth,
363
388
  accessibilityLabel,
364
389
  modes = EMPTY_MODES,
365
390
  style,
@@ -424,7 +449,7 @@ function Table<T = Record<string, unknown>>({
424
449
  accessibilityLabel={accessibilityLabel}
425
450
  style={[{ overflow: 'hidden' }, !scrollable && { width: '100%' }, style]}
426
451
  >
427
- {body}
452
+ <TableContext.Provider value={columnWidth}>{body}</TableContext.Provider>
428
453
  </View>
429
454
  )
430
455
 
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-07-07T17:35:34.036Z
7
+ * Generated: 2026-07-10T20:55:36.505Z
8
8
  */
9
9
 
10
10
  // Icon name to SVG data mapping