jfs-components 0.1.23 → 0.1.28
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 +40 -0
- package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
- package/lib/commonjs/components/CardCTA/CardCTA.js +32 -25
- package/lib/commonjs/components/Carousel/Carousel.js +112 -19
- package/lib/commonjs/components/ChipSelect/ChipSelect.js +2 -1
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +39 -5
- package/lib/commonjs/components/FormUpload/Additem.js +168 -0
- package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
- package/lib/commonjs/components/Grid/Grid.js +206 -0
- package/lib/commonjs/components/ListItem/ListItem.js +11 -4
- package/lib/commonjs/components/MetricData/MetricData.js +132 -0
- package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
- package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
- package/lib/commonjs/components/Rating/Rating.js +137 -0
- package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/commonjs/components/index.js +42 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +23 -10
- package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
- package/lib/commonjs/icons/components/IconImage.js +19 -0
- package/lib/commonjs/icons/components/IconMic.js +19 -0
- package/lib/commonjs/icons/components/IconMicoff.js +19 -0
- package/lib/commonjs/icons/components/IconPause.js +19 -0
- package/lib/commonjs/icons/components/IconPlay.js +19 -0
- package/lib/commonjs/icons/components/index.js +66 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
- package/lib/module/components/CardCTA/CardCTA.js +32 -25
- package/lib/module/components/Carousel/Carousel.js +111 -19
- package/lib/module/components/ChipSelect/ChipSelect.js +2 -1
- package/lib/module/components/ContentSheet/ContentSheet.js +41 -7
- package/lib/module/components/FormUpload/Additem.js +162 -0
- package/lib/module/components/FormUpload/FormUpload.js +192 -0
- package/lib/module/components/Grid/Grid.js +201 -0
- package/lib/module/components/ListItem/ListItem.js +11 -4
- package/lib/module/components/MetricData/MetricData.js +127 -0
- package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
- package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
- package/lib/module/components/Rating/Rating.js +132 -0
- package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/module/components/index.js +7 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +23 -10
- package/lib/module/icons/components/IconBrokenimage.js +12 -0
- package/lib/module/icons/components/IconImage.js +12 -0
- package/lib/module/icons/components/IconMic.js +12 -0
- package/lib/module/icons/components/IconMicoff.js +12 -0
- package/lib/module/icons/components/IconPause.js +12 -0
- package/lib/module/icons/components/IconPlay.js +12 -0
- package/lib/module/icons/components/index.js +6 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
- package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
- package/lib/typescript/src/components/ChipSelect/ChipSelect.d.ts +6 -1
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +7 -1
- package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
- package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
- package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
- package/lib/typescript/src/components/MetricData/MetricData.d.ts +53 -0
- package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
- package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
- package/lib/typescript/src/components/Rating/Rating.d.ts +30 -0
- package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
- package/lib/typescript/src/components/index.d.ts +34 -28
- package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
- package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +6 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
- package/src/components/CardCTA/CardCTA.tsx +30 -15
- package/src/components/Carousel/Carousel.tsx +133 -23
- package/src/components/ChipSelect/ChipSelect.tsx +7 -1
- package/src/components/ContentSheet/ContentSheet.tsx +50 -0
- package/src/components/FormUpload/Additem.tsx +262 -0
- package/src/components/FormUpload/FormUpload.tsx +313 -0
- package/src/components/Grid/Grid.tsx +368 -0
- package/src/components/ListItem/ListItem.tsx +11 -4
- package/src/components/MetricData/MetricData.tsx +185 -0
- package/src/components/NumberPagination/NumberPagination.tsx +170 -0
- package/src/components/ProfileCard/ProfileCard.tsx +268 -0
- package/src/components/Rating/Rating.tsx +174 -0
- package/src/components/UpiHandle/UpiHandle.tsx +35 -24
- package/src/components/index.ts +329 -182
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +23 -10
- package/src/icons/components/IconBrokenimage.tsx +11 -0
- package/src/icons/components/IconImage.tsx +11 -0
- package/src/icons/components/IconMic.tsx +11 -0
- package/src/icons/components/IconMicoff.tsx +11 -0
- package/src/icons/components/IconPause.tsx +11 -0
- package/src/icons/components/IconPlay.tsx +11 -0
- package/src/icons/components/index.ts +6 -0
- package/src/icons/registry.ts +25 -1
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
22
22
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
23
23
|
import type { Modes } from '../../design-tokens'
|
|
24
|
+
import AutoplayControl from '../AutoplayControl/AutoplayControl'
|
|
25
|
+
import NumberPagination from '../NumberPagination/NumberPagination'
|
|
24
26
|
|
|
25
27
|
// ---------------------------------------------------------------------------
|
|
26
28
|
// Context
|
|
@@ -75,6 +77,13 @@ export interface CarouselProps {
|
|
|
75
77
|
paddingHorizontal?: number
|
|
76
78
|
/** Explicit vertical padding (px) for the outer container. Overrides `carousel/padding/vertical`. */
|
|
77
79
|
paddingVertical?: number
|
|
80
|
+
/**
|
|
81
|
+
* Carousel visual type.
|
|
82
|
+
* - `'Default'` — standard carousel with dot pagination below.
|
|
83
|
+
* - `'Numbered'` — full-bleed carousel with floating AutoplayControl and
|
|
84
|
+
* NumberPagination overlaid at the bottom center.
|
|
85
|
+
*/
|
|
86
|
+
type?: 'Default' | 'Numbered'
|
|
78
87
|
|
|
79
88
|
/** Called when the active index changes. */
|
|
80
89
|
onIndexChange?: (index: number) => void
|
|
@@ -97,10 +106,12 @@ export function Carousel({
|
|
|
97
106
|
itemWidth: itemWidthProp,
|
|
98
107
|
paddingHorizontal: paddingHorizontalProp,
|
|
99
108
|
paddingVertical: paddingVerticalProp,
|
|
109
|
+
type = 'Default',
|
|
100
110
|
|
|
101
111
|
onIndexChange,
|
|
102
112
|
style,
|
|
103
113
|
}: CarouselProps) {
|
|
114
|
+
const isNumbered = type === 'Numbered'
|
|
104
115
|
// ---- Token resolution ----
|
|
105
116
|
const tokenGap = parseFloat(
|
|
106
117
|
getVariableByName('carousel/gap', modes),
|
|
@@ -119,15 +130,32 @@ export function Carousel({
|
|
|
119
130
|
const maxHeight = parseFloat(
|
|
120
131
|
getVariableByName('carousel/maxHeight', modes),
|
|
121
132
|
)
|
|
133
|
+
// Gap between AutoplayControl and NumberPagination in the Numbered overlay.
|
|
134
|
+
const controlGap = parseFloat(
|
|
135
|
+
getVariableByName('carouselControl/gap', modes),
|
|
136
|
+
)
|
|
137
|
+
|
|
122
138
|
// Spacing between the cards row and the pagination dots uses `carousel/gap`.
|
|
123
139
|
const paginationOffset = gap
|
|
124
140
|
|
|
125
141
|
// ---- Refs & state ----
|
|
126
142
|
const scrollRef = useRef<ScrollView>(null)
|
|
127
143
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
144
|
+
const activeIndexRef = useRef(0)
|
|
128
145
|
const [containerWidth, setContainerWidth] = useState(0)
|
|
129
146
|
const autoPlayTimer = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
130
147
|
const userInteracting = useRef(false)
|
|
148
|
+
// Guard: suppress handleScroll index updates during programmatic scrollTo
|
|
149
|
+
// animations so intermediate offsets don't flicker activeIndex back.
|
|
150
|
+
const programmaticScroll = useRef(false)
|
|
151
|
+
const programmaticTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
152
|
+
|
|
153
|
+
useEffect(() => {
|
|
154
|
+
activeIndexRef.current = activeIndex
|
|
155
|
+
}, [activeIndex])
|
|
156
|
+
|
|
157
|
+
// Interactive play/pause state for the Numbered variant.
|
|
158
|
+
const [isPlaying, setIsPlaying] = useState(autoPlay)
|
|
131
159
|
|
|
132
160
|
// Flatten children so we can count items
|
|
133
161
|
const items = useMemo(
|
|
@@ -137,13 +165,15 @@ export function Carousel({
|
|
|
137
165
|
|
|
138
166
|
const totalItems = items.length
|
|
139
167
|
|
|
140
|
-
// Effective item width: provided prop, or full container width minus peek offsets
|
|
168
|
+
// Effective item width: provided prop, or full container width minus peek offsets.
|
|
169
|
+
// For the Numbered variant the items are full-bleed (no peek padding).
|
|
141
170
|
const effectiveItemWidth = useMemo(() => {
|
|
142
171
|
if (itemWidthProp != null) return itemWidthProp
|
|
143
172
|
if (containerWidth === 0) return 0
|
|
173
|
+
if (isNumbered) return containerWidth
|
|
144
174
|
// Full-width minus peekOffset on each side
|
|
145
175
|
return containerWidth - containerPaddingH * 4
|
|
146
|
-
}, [itemWidthProp, containerWidth, containerPaddingH])
|
|
176
|
+
}, [itemWidthProp, containerWidth, containerPaddingH, isNumbered])
|
|
147
177
|
|
|
148
178
|
// Snap interval = item width + gap
|
|
149
179
|
const snapInterval = effectiveItemWidth + gap
|
|
@@ -158,6 +188,17 @@ export function Carousel({
|
|
|
158
188
|
} else {
|
|
159
189
|
targetIndex = Math.max(0, Math.min(index, totalItems - 1))
|
|
160
190
|
}
|
|
191
|
+
// Lock out handleScroll during the animated scroll so intermediate
|
|
192
|
+
// offsets don't flicker activeIndex back to the old value.
|
|
193
|
+
if (animated) {
|
|
194
|
+
programmaticScroll.current = true
|
|
195
|
+
if (programmaticTimer.current != null) {
|
|
196
|
+
clearTimeout(programmaticTimer.current)
|
|
197
|
+
}
|
|
198
|
+
programmaticTimer.current = setTimeout(() => {
|
|
199
|
+
programmaticScroll.current = false
|
|
200
|
+
}, 500)
|
|
201
|
+
}
|
|
161
202
|
scrollRef.current?.scrollTo({
|
|
162
203
|
x: targetIndex * snapInterval,
|
|
163
204
|
animated,
|
|
@@ -185,6 +226,9 @@ export function Carousel({
|
|
|
185
226
|
const handleScroll = useCallback(
|
|
186
227
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
187
228
|
if (snapInterval === 0) return
|
|
229
|
+
// Skip index recalculation during programmatic scrollTo animations
|
|
230
|
+
// to prevent the active indicator from flickering back to the old page.
|
|
231
|
+
if (programmaticScroll.current) return
|
|
188
232
|
const offsetX = event.nativeEvent.contentOffset.x
|
|
189
233
|
const newIndex = Math.round(offsetX / snapInterval)
|
|
190
234
|
const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1))
|
|
@@ -205,31 +249,28 @@ export function Carousel({
|
|
|
205
249
|
}, [])
|
|
206
250
|
|
|
207
251
|
const startAutoPlay = useCallback(() => {
|
|
208
|
-
if (!autoPlay || totalItems <= 1) return
|
|
252
|
+
if ((!autoPlay && !isPlaying) || totalItems <= 1) return
|
|
253
|
+
if (!isPlaying) return
|
|
209
254
|
clearAutoPlay()
|
|
210
255
|
autoPlayTimer.current = setInterval(() => {
|
|
211
256
|
if (!userInteracting.current) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
})
|
|
220
|
-
onIndexChange?.(next)
|
|
221
|
-
return next
|
|
222
|
-
})
|
|
257
|
+
const prev = activeIndexRef.current
|
|
258
|
+
const next = loop
|
|
259
|
+
? (prev + 1) % totalItems
|
|
260
|
+
: Math.min(prev + 1, totalItems - 1)
|
|
261
|
+
// Route through scrollToIndex so the programmaticScroll guard
|
|
262
|
+
// suppresses intermediate handleScroll offsets during the animation.
|
|
263
|
+
scrollToIndex(next, true)
|
|
223
264
|
}
|
|
224
265
|
}, autoPlayInterval)
|
|
225
266
|
}, [
|
|
226
267
|
autoPlay,
|
|
268
|
+
isPlaying,
|
|
227
269
|
totalItems,
|
|
228
270
|
loop,
|
|
229
|
-
snapInterval,
|
|
230
271
|
autoPlayInterval,
|
|
231
272
|
clearAutoPlay,
|
|
232
|
-
|
|
273
|
+
scrollToIndex,
|
|
233
274
|
])
|
|
234
275
|
|
|
235
276
|
useEffect(() => {
|
|
@@ -244,8 +285,27 @@ export function Carousel({
|
|
|
244
285
|
const handleScrollEndDrag = useCallback(() => {
|
|
245
286
|
userInteracting.current = false
|
|
246
287
|
// Reset autoplay timer after interaction
|
|
247
|
-
if (autoPlay) startAutoPlay()
|
|
248
|
-
}, [autoPlay, startAutoPlay])
|
|
288
|
+
if (autoPlay || isPlaying) startAutoPlay()
|
|
289
|
+
}, [autoPlay, isPlaying, startAutoPlay])
|
|
290
|
+
|
|
291
|
+
const finishProgrammaticScroll = useCallback(
|
|
292
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
293
|
+
programmaticScroll.current = false
|
|
294
|
+
if (programmaticTimer.current != null) {
|
|
295
|
+
clearTimeout(programmaticTimer.current)
|
|
296
|
+
programmaticTimer.current = null
|
|
297
|
+
}
|
|
298
|
+
if (snapInterval === 0) return
|
|
299
|
+
const offsetX = event.nativeEvent.contentOffset.x
|
|
300
|
+
const newIndex = Math.round(offsetX / snapInterval)
|
|
301
|
+
const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1))
|
|
302
|
+
if (clampedIndex !== activeIndexRef.current) {
|
|
303
|
+
setActiveIndex(clampedIndex)
|
|
304
|
+
onIndexChange?.(clampedIndex)
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[snapInterval, totalItems, onIndexChange],
|
|
308
|
+
)
|
|
249
309
|
|
|
250
310
|
// ---- Layout ----
|
|
251
311
|
const handleLayout = useCallback((e: LayoutChangeEvent) => {
|
|
@@ -265,15 +325,37 @@ export function Carousel({
|
|
|
265
325
|
[modes, activeIndex, totalItems, goTo, goNext, goPrev],
|
|
266
326
|
)
|
|
267
327
|
|
|
328
|
+
// ---- Toggle handler for Numbered variant ----
|
|
329
|
+
const handleToggleAutoplay = useCallback(() => {
|
|
330
|
+
setIsPlaying((prev) => {
|
|
331
|
+
if (prev) {
|
|
332
|
+
// Pausing
|
|
333
|
+
clearAutoPlay()
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
// Resuming — autoplay will restart via the effect
|
|
337
|
+
return true
|
|
338
|
+
})
|
|
339
|
+
}, [clearAutoPlay])
|
|
340
|
+
|
|
341
|
+
// Restart autoplay whenever isPlaying flips to true
|
|
342
|
+
useEffect(() => {
|
|
343
|
+
if (isPlaying) startAutoPlay()
|
|
344
|
+
else clearAutoPlay()
|
|
345
|
+
}, [isPlaying, startAutoPlay, clearAutoPlay])
|
|
346
|
+
|
|
268
347
|
// ---- Render ----
|
|
269
348
|
// Explicit padding props win verbatim; otherwise fall back to the
|
|
270
349
|
// token-derived values (horizontal keeps its legacy ×2 "peek" multiplier).
|
|
271
|
-
|
|
272
|
-
const
|
|
350
|
+
// Numbered variant is full-bleed: zero padding.
|
|
351
|
+
const effectivePaddingH = isNumbered ? 0 : (paddingHorizontalProp ?? containerPaddingH)
|
|
352
|
+
const effectivePaddingV = isNumbered ? 0 : (paddingVerticalProp ?? containerPaddingV)
|
|
273
353
|
|
|
274
354
|
const outerStyle: ViewStyle = {
|
|
275
355
|
paddingVertical: effectivePaddingV,
|
|
276
|
-
maxHeight,
|
|
356
|
+
maxHeight: isNumbered ? undefined : maxHeight,
|
|
357
|
+
// Numbered needs relative positioning for the floating controls overlay.
|
|
358
|
+
...(isNumbered ? { overflow: 'hidden' as const } : {}),
|
|
277
359
|
}
|
|
278
360
|
|
|
279
361
|
const contentContainerStyle: ViewStyle = {
|
|
@@ -299,7 +381,7 @@ export function Carousel({
|
|
|
299
381
|
scrollEventThrottle={16}
|
|
300
382
|
onScrollBeginDrag={handleScrollBeginDrag}
|
|
301
383
|
onScrollEndDrag={handleScrollEndDrag}
|
|
302
|
-
|
|
384
|
+
onMomentumScrollEnd={finishProgrammaticScroll}
|
|
303
385
|
>
|
|
304
386
|
{items.map((child, index) => {
|
|
305
387
|
// Strict slot box: width must be honored; never grow or shrink with
|
|
@@ -343,12 +425,40 @@ export function Carousel({
|
|
|
343
425
|
})}
|
|
344
426
|
</ScrollView>
|
|
345
427
|
|
|
346
|
-
{showPagination && totalItems > 1 && (
|
|
428
|
+
{!isNumbered && showPagination && totalItems > 1 && (
|
|
347
429
|
<Pagination
|
|
348
430
|
modes={modes}
|
|
349
431
|
style={{ marginTop: paginationOffset }}
|
|
350
432
|
/>
|
|
351
433
|
)}
|
|
434
|
+
|
|
435
|
+
{isNumbered && showPagination && totalItems > 1 && (
|
|
436
|
+
<View
|
|
437
|
+
style={{
|
|
438
|
+
position: 'absolute',
|
|
439
|
+
bottom: 12,
|
|
440
|
+
left: 0,
|
|
441
|
+
right: 0,
|
|
442
|
+
flexDirection: 'row',
|
|
443
|
+
justifyContent: 'center',
|
|
444
|
+
alignItems: 'center',
|
|
445
|
+
gap: controlGap,
|
|
446
|
+
}}
|
|
447
|
+
pointerEvents="box-none"
|
|
448
|
+
>
|
|
449
|
+
<AutoplayControl
|
|
450
|
+
state={isPlaying ? 'pause' : 'play'}
|
|
451
|
+
onPress={handleToggleAutoplay}
|
|
452
|
+
modes={modes}
|
|
453
|
+
/>
|
|
454
|
+
<NumberPagination
|
|
455
|
+
activePage={activeIndex + 1}
|
|
456
|
+
totalPages={totalItems}
|
|
457
|
+
onPageChange={(page) => goTo(page - 1)}
|
|
458
|
+
modes={modes}
|
|
459
|
+
/>
|
|
460
|
+
</View>
|
|
461
|
+
)}
|
|
352
462
|
</View>
|
|
353
463
|
</CarouselContext.Provider>
|
|
354
464
|
)
|
|
@@ -21,6 +21,11 @@ export type ChipSelectProps = {
|
|
|
21
21
|
* @default "ic_calendar_week"
|
|
22
22
|
*/
|
|
23
23
|
icon?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Whether to show the close icon when active.
|
|
26
|
+
* @default true
|
|
27
|
+
*/
|
|
28
|
+
showCloseIcon?: boolean;
|
|
24
29
|
/**
|
|
25
30
|
* Modes for design token resolution.
|
|
26
31
|
*/
|
|
@@ -47,6 +52,7 @@ function ChipSelect({
|
|
|
47
52
|
label = 'Date',
|
|
48
53
|
active = false,
|
|
49
54
|
icon = 'ic_calendar_week',
|
|
55
|
+
showCloseIcon = true,
|
|
50
56
|
modes = EMPTY_MODES,
|
|
51
57
|
style,
|
|
52
58
|
labelSlot,
|
|
@@ -123,7 +129,7 @@ function ChipSelect({
|
|
|
123
129
|
|
|
124
130
|
{renderLabel()}
|
|
125
131
|
|
|
126
|
-
{active && (
|
|
132
|
+
{active && showCloseIcon && (
|
|
127
133
|
<Icon name="ic_close" size={iconSize} color={textColor} />
|
|
128
134
|
)}
|
|
129
135
|
</TouchableOpacity>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import React, { useContext, useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Platform,
|
|
4
|
+
Text,
|
|
4
5
|
View,
|
|
5
6
|
type StyleProp,
|
|
7
|
+
type TextStyle,
|
|
6
8
|
type ViewProps,
|
|
7
9
|
type ViewStyle,
|
|
8
10
|
} from 'react-native'
|
|
@@ -31,6 +33,12 @@ export type ContentSheetProps = {
|
|
|
31
33
|
* the same theme without you wiring `modes` onto each one.
|
|
32
34
|
*/
|
|
33
35
|
modes?: Modes
|
|
36
|
+
/**
|
|
37
|
+
* Optional title rendered in a centered, sticky header at the top of the
|
|
38
|
+
* sheet. Uses the same `drawer/title*` token family as the `Drawer` title so
|
|
39
|
+
* the two surfaces stay visually consistent.
|
|
40
|
+
*/
|
|
41
|
+
title?: string
|
|
34
42
|
/**
|
|
35
43
|
* Keep the sheet above the on-screen keyboard. When the keyboard opens the
|
|
36
44
|
* sheet rises by exactly the keyboard height; when it closes the sheet drops
|
|
@@ -70,6 +78,8 @@ export type ContentSheetProps = {
|
|
|
70
78
|
interface ContentSheetStyle {
|
|
71
79
|
container: ViewStyle
|
|
72
80
|
paddingBottom: number
|
|
81
|
+
headerStyle: ViewStyle
|
|
82
|
+
titleStyle: TextStyle
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
@@ -81,6 +91,23 @@ function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
|
81
91
|
const radiusTop = getVariableByName('contentSheet/padding/radius/top', modes) as number
|
|
82
92
|
const radiusBottom = getVariableByName('contentSheet/padding/radius/bottom', modes) as number
|
|
83
93
|
|
|
94
|
+
// Title tokens mirror the Drawer title so the two bottom-sheet surfaces stay
|
|
95
|
+
// visually aligned.
|
|
96
|
+
const titleColor = getVariableByName('drawer/title/color', modes) as string
|
|
97
|
+
const titleSize = getVariableByName('drawer/title/fontSize', modes) as number
|
|
98
|
+
const titleLineHeight = getVariableByName('drawer/title/lineHeight', modes) as number
|
|
99
|
+
const titleFontFamily = getVariableByName('drawer/title/fontFamily', modes) as string
|
|
100
|
+
const titleFontWeightRaw = getVariableByName('drawer/title/fontWeight', modes)
|
|
101
|
+
const titleFontWeight =
|
|
102
|
+
typeof titleFontWeightRaw === 'number' ? titleFontWeightRaw.toString() : titleFontWeightRaw
|
|
103
|
+
|
|
104
|
+
const titleWrapPaddingTop = getVariableByName('drawer/titleWrap/padding/top', modes) as number
|
|
105
|
+
const titleWrapPaddingBottom = getVariableByName('drawer/titleWrap/padding/bottom', modes) as number
|
|
106
|
+
const titleWrapPaddingHorizontal = getVariableByName(
|
|
107
|
+
'drawer/titleWrap/padding/horizontal',
|
|
108
|
+
modes
|
|
109
|
+
) as number
|
|
110
|
+
|
|
84
111
|
return {
|
|
85
112
|
container: {
|
|
86
113
|
backgroundColor,
|
|
@@ -97,6 +124,20 @@ function resolveContentSheetStyle(modes: Modes): ContentSheetStyle {
|
|
|
97
124
|
overflow: 'hidden',
|
|
98
125
|
},
|
|
99
126
|
paddingBottom,
|
|
127
|
+
headerStyle: {
|
|
128
|
+
paddingTop: titleWrapPaddingTop,
|
|
129
|
+
paddingBottom: titleWrapPaddingBottom,
|
|
130
|
+
paddingHorizontal: titleWrapPaddingHorizontal,
|
|
131
|
+
alignItems: 'center',
|
|
132
|
+
},
|
|
133
|
+
titleStyle: {
|
|
134
|
+
color: titleColor,
|
|
135
|
+
fontSize: titleSize,
|
|
136
|
+
lineHeight: titleLineHeight,
|
|
137
|
+
fontFamily: titleFontFamily,
|
|
138
|
+
fontWeight: titleFontWeight as TextStyle['fontWeight'],
|
|
139
|
+
textAlign: 'center',
|
|
140
|
+
},
|
|
100
141
|
}
|
|
101
142
|
}
|
|
102
143
|
|
|
@@ -126,6 +167,7 @@ const pinnedStyle: ViewStyle = {
|
|
|
126
167
|
function ContentSheet({
|
|
127
168
|
children,
|
|
128
169
|
modes = EMPTY_MODES,
|
|
170
|
+
title,
|
|
129
171
|
avoidKeyboard = true,
|
|
130
172
|
keyboardSpacing = 0,
|
|
131
173
|
safeAreaBottom = true,
|
|
@@ -162,6 +204,12 @@ function ContentSheet({
|
|
|
162
204
|
// `avoidKeyboard` (and platform). Because they are distinct component types,
|
|
163
205
|
// React remounts on toggle, so the conditional `useAnimatedKeyboard` hook
|
|
164
206
|
// inside `KeyboardAwareSheet` always runs in a stable hook order.
|
|
207
|
+
const header = title ? (
|
|
208
|
+
<View style={resolved.headerStyle}>
|
|
209
|
+
<Text style={resolved.titleStyle}>{title}</Text>
|
|
210
|
+
</View>
|
|
211
|
+
) : null
|
|
212
|
+
|
|
165
213
|
if (avoidKeyboard && !IS_WEB) {
|
|
166
214
|
return (
|
|
167
215
|
<KeyboardAwareSheet
|
|
@@ -169,6 +217,7 @@ function ContentSheet({
|
|
|
169
217
|
spacing={keyboardSpacing}
|
|
170
218
|
{...rest}
|
|
171
219
|
>
|
|
220
|
+
{header}
|
|
172
221
|
{processedChildren}
|
|
173
222
|
</KeyboardAwareSheet>
|
|
174
223
|
)
|
|
@@ -176,6 +225,7 @@ function ContentSheet({
|
|
|
176
225
|
|
|
177
226
|
return (
|
|
178
227
|
<View style={containerStyle} {...rest}>
|
|
228
|
+
{header}
|
|
179
229
|
{processedChildren}
|
|
180
230
|
</View>
|
|
181
231
|
)
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { Pressable, View, type StyleProp, type ViewStyle, type ImageSourcePropType } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
|
+
import Image from '../Image/Image'
|
|
7
|
+
import IconCapsule from '../IconCapsule/IconCapsule'
|
|
8
|
+
import type { Modes } from '../../design-tokens'
|
|
9
|
+
|
|
10
|
+
export type PickedAsset = {
|
|
11
|
+
/** URI to the selected file (image, video, or document). */
|
|
12
|
+
uri: string
|
|
13
|
+
/** Original file name, when available. */
|
|
14
|
+
name?: string
|
|
15
|
+
/** MIME type, when available. */
|
|
16
|
+
type?: string
|
|
17
|
+
/** File size in bytes, when available. */
|
|
18
|
+
size?: number
|
|
19
|
+
/** Image/video width in pixels, when available. */
|
|
20
|
+
width?: number
|
|
21
|
+
/** Image/video height in pixels, when available. */
|
|
22
|
+
height?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type AdditemPickerResult = {
|
|
26
|
+
/** `true` when the user cancelled the picker. */
|
|
27
|
+
didCancel?: boolean
|
|
28
|
+
/** Selected assets. Empty when cancelled. */
|
|
29
|
+
assets: PickedAsset[]
|
|
30
|
+
/** Native error message, when something went wrong. */
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Callback that opens a platform picker and resolves with the selected assets.
|
|
36
|
+
* This is the integration point for libraries like `react-native-image-picker`,
|
|
37
|
+
* `expo-image-picker`, or `react-native-document-picker`.
|
|
38
|
+
*/
|
|
39
|
+
export type AdditemPicker = () => Promise<AdditemPickerResult>
|
|
40
|
+
|
|
41
|
+
export type AdditemState = 'empty' | 'preview'
|
|
42
|
+
|
|
43
|
+
export type AdditemProps = {
|
|
44
|
+
/**
|
|
45
|
+
* Visual state of the item.
|
|
46
|
+
* - `'empty'` — shows the add/upload icon (default).
|
|
47
|
+
* - `'preview'` — shows `imageSource` and a remove icon overlay.
|
|
48
|
+
*/
|
|
49
|
+
state?: AdditemState
|
|
50
|
+
/**
|
|
51
|
+
* Image to preview when `state` is `'preview'`. Accepts the same shapes as
|
|
52
|
+
* the library's `Image` component (remote URI string, `{ uri }`, or `require()`).
|
|
53
|
+
*/
|
|
54
|
+
imageSource?: ImageSourcePropType | string | undefined
|
|
55
|
+
/**
|
|
56
|
+
* Called when the cell is pressed. In `'empty'` mode this is typically where
|
|
57
|
+
* the picker is launched; in `'preview'` mode it can open a preview/lightbox.
|
|
58
|
+
*/
|
|
59
|
+
onPress?: () => void
|
|
60
|
+
/**
|
|
61
|
+
* Called when the remove icon overlay is pressed in `'preview'` mode.
|
|
62
|
+
* Receives the press event so the parent can stop propagation if needed.
|
|
63
|
+
*/
|
|
64
|
+
onRemove?: () => void
|
|
65
|
+
/**
|
|
66
|
+
* Optional picker to invoke when the empty cell is pressed. When provided,
|
|
67
|
+
* `onPress` is ignored for the empty state and the cell becomes a real
|
|
68
|
+
* upload trigger that resolves with picked assets via `onAssetsPicked`.
|
|
69
|
+
*/
|
|
70
|
+
picker?: AdditemPicker
|
|
71
|
+
/**
|
|
72
|
+
* Called after a successful picker invocation with the selected assets.
|
|
73
|
+
*/
|
|
74
|
+
onAssetsPicked?: (assets: PickedAsset[]) => void
|
|
75
|
+
/**
|
|
76
|
+
* Modes for design-token resolution. Defaults to Neutral / Low / Icon Capsule
|
|
77
|
+
* Size S for the inner `IconCapsule` icons; any key supplied here overrides
|
|
78
|
+
* those defaults.
|
|
79
|
+
*/
|
|
80
|
+
modes?: Modes
|
|
81
|
+
/**
|
|
82
|
+
* Accessibility label. Defaults to a descriptive label based on state.
|
|
83
|
+
*/
|
|
84
|
+
accessibilityLabel?: string
|
|
85
|
+
/**
|
|
86
|
+
* Disables press interaction. Also dims the cell.
|
|
87
|
+
*/
|
|
88
|
+
isDisabled?: boolean
|
|
89
|
+
/** Test identifier. */
|
|
90
|
+
testID?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface AdditemTokens {
|
|
94
|
+
backgroundColor: string
|
|
95
|
+
borderRadius: number
|
|
96
|
+
paddingHorizontal: number
|
|
97
|
+
paddingVertical: number
|
|
98
|
+
size: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function toNumber(value: unknown, fallback: number): number {
|
|
102
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
103
|
+
if (typeof value === 'string') {
|
|
104
|
+
const parsed = parseFloat(value)
|
|
105
|
+
if (Number.isFinite(parsed)) return parsed
|
|
106
|
+
}
|
|
107
|
+
return fallback
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveAdditemTokens(modes: Modes): AdditemTokens {
|
|
111
|
+
const backgroundColor = getVariableByName('compareCardItem/background', modes) as string
|
|
112
|
+
const borderRadius = toNumber(getVariableByName('compareCardItem/radius', modes), 8)
|
|
113
|
+
const paddingHorizontal = toNumber(getVariableByName('compareCardItem/padding/horizontal', modes), 6)
|
|
114
|
+
const paddingVertical = toNumber(getVariableByName('compareCardItem/padding/vertical', modes), 8)
|
|
115
|
+
// The Figma design uses a fixed 44 × 44 dp cell. There is no
|
|
116
|
+
// `compareCardItem/size` token in the variables JSON, so we keep the size
|
|
117
|
+
// constant to match the design exactly.
|
|
118
|
+
const size = 44
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
backgroundColor,
|
|
122
|
+
borderRadius,
|
|
123
|
+
paddingHorizontal,
|
|
124
|
+
paddingVertical,
|
|
125
|
+
size,
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Figma defaults for the inner `IconCapsule` icons. Overridable via `modes`. */
|
|
130
|
+
const ADDITEM_DEFAULT_MODES: Readonly<Record<string, string>> = Object.freeze({
|
|
131
|
+
AppearanceBrand: 'Neutral',
|
|
132
|
+
Emphasis: 'Low',
|
|
133
|
+
'Icon Capsule Size': 'S',
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* `Additem` — a small, reusable upload/preview cell used inside form uploaders.
|
|
138
|
+
*
|
|
139
|
+
* In its default `'empty'` state it shows a centered add/upload icon. In
|
|
140
|
+
* `'preview'` state it renders a thumbnail with a removable overlay. The
|
|
141
|
+
* component is intentionally unopinionated about *how* files are picked: pass a
|
|
142
|
+
* `picker` callback that wraps `react-native-image-picker`,
|
|
143
|
+
* `expo-image-picker`, `react-native-document-picker`, or any custom selector,
|
|
144
|
+
* and the cell will invoke it, surface the result via `onAssetsPicked`, and
|
|
145
|
+
* handle cancellation/errors gracefully.
|
|
146
|
+
*/
|
|
147
|
+
function Additem({
|
|
148
|
+
state = 'empty',
|
|
149
|
+
imageSource,
|
|
150
|
+
onPress,
|
|
151
|
+
onRemove,
|
|
152
|
+
picker,
|
|
153
|
+
onAssetsPicked,
|
|
154
|
+
modes: propModes = EMPTY_MODES,
|
|
155
|
+
accessibilityLabel,
|
|
156
|
+
isDisabled = false,
|
|
157
|
+
testID,
|
|
158
|
+
}: AdditemProps) {
|
|
159
|
+
const { modes: globalModes } = useTokens()
|
|
160
|
+
|
|
161
|
+
const modes = useMemo(
|
|
162
|
+
() =>
|
|
163
|
+
globalModes === EMPTY_MODES && propModes === EMPTY_MODES
|
|
164
|
+
? ADDITEM_DEFAULT_MODES
|
|
165
|
+
: { ...ADDITEM_DEFAULT_MODES, ...globalModes, ...propModes },
|
|
166
|
+
[globalModes, propModes],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const tokens = useMemo(() => resolveAdditemTokens(modes), [modes])
|
|
170
|
+
|
|
171
|
+
const handlePress = React.useCallback(async () => {
|
|
172
|
+
if (isDisabled) return
|
|
173
|
+
|
|
174
|
+
if (state === 'empty' && picker) {
|
|
175
|
+
try {
|
|
176
|
+
const result = await picker()
|
|
177
|
+
if (result.didCancel) return
|
|
178
|
+
if (result.error) {
|
|
179
|
+
if (__DEV__) {
|
|
180
|
+
console.warn('[Additem] picker error:', result.error)
|
|
181
|
+
}
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
if (result.assets.length > 0) {
|
|
185
|
+
onAssetsPicked?.(result.assets)
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (__DEV__) {
|
|
189
|
+
console.warn('[Additem] unexpected picker error:', err)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onPress?.()
|
|
196
|
+
}, [isDisabled, onPress, onAssetsPicked, picker, state])
|
|
197
|
+
|
|
198
|
+
const handleRemove = React.useCallback(() => {
|
|
199
|
+
if (!isDisabled) {
|
|
200
|
+
onRemove?.()
|
|
201
|
+
}
|
|
202
|
+
}, [isDisabled, onRemove])
|
|
203
|
+
|
|
204
|
+
const resolvedAccessibilityLabel = accessibilityLabel ?? (state === 'empty' ? 'Add attachment' : 'Attachment preview')
|
|
205
|
+
|
|
206
|
+
const containerStyle: StyleProp<ViewStyle> = useMemo(
|
|
207
|
+
() => ({
|
|
208
|
+
width: tokens.size,
|
|
209
|
+
height: tokens.size,
|
|
210
|
+
borderRadius: tokens.borderRadius,
|
|
211
|
+
backgroundColor: tokens.backgroundColor,
|
|
212
|
+
paddingHorizontal: tokens.paddingHorizontal,
|
|
213
|
+
paddingVertical: tokens.paddingVertical,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
justifyContent: 'center',
|
|
216
|
+
overflow: 'hidden',
|
|
217
|
+
opacity: isDisabled ? 0.5 : 1,
|
|
218
|
+
}),
|
|
219
|
+
[tokens, isDisabled],
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
const isPreview = state === 'preview'
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<Pressable
|
|
226
|
+
onPress={handlePress}
|
|
227
|
+
disabled={isDisabled}
|
|
228
|
+
accessibilityRole="button"
|
|
229
|
+
accessibilityLabel={resolvedAccessibilityLabel}
|
|
230
|
+
accessibilityState={{ disabled: isDisabled }}
|
|
231
|
+
testID={testID}
|
|
232
|
+
style={containerStyle}
|
|
233
|
+
>
|
|
234
|
+
{isPreview ? (
|
|
235
|
+
<>
|
|
236
|
+
<Image
|
|
237
|
+
imageSource={imageSource}
|
|
238
|
+
width="100%"
|
|
239
|
+
height={tokens.size - tokens.paddingVertical * 2}
|
|
240
|
+
resizeMode="cover"
|
|
241
|
+
borderRadius={tokens.borderRadius - 2}
|
|
242
|
+
/>
|
|
243
|
+
{onRemove != null && (
|
|
244
|
+
<View style={{ position: 'absolute', top: 2, right: 2 }}>
|
|
245
|
+
<IconCapsule
|
|
246
|
+
iconName="ic_close"
|
|
247
|
+
modes={modes}
|
|
248
|
+
onTouchEnd={handleRemove}
|
|
249
|
+
accessibilityRole="button"
|
|
250
|
+
accessibilityLabel="Remove attachment"
|
|
251
|
+
/>
|
|
252
|
+
</View>
|
|
253
|
+
)}
|
|
254
|
+
</>
|
|
255
|
+
) : (
|
|
256
|
+
<IconCapsule iconName="ic_add" modes={modes} />
|
|
257
|
+
)}
|
|
258
|
+
</Pressable>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export default React.memo(Additem)
|