@proulr/sdui-components 0.1.5 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proulr/sdui-components",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "main": "./src/renderer/index.ts",
5
5
  "types": "./src/renderer/index.ts",
6
6
  "exports": {
@@ -16,6 +16,7 @@
16
16
  "url": "git+https://github.com/saviorbp/proulr-sdui-components.git"
17
17
  },
18
18
  "scripts": {
19
+ "prepare": "husky",
19
20
  "typecheck": "tsc --noEmit",
20
21
  "prepublishOnly": "npm run typecheck",
21
22
  "lint": "eslint .",
@@ -57,10 +58,16 @@
57
58
  "eslint": "^9.0.0",
58
59
  "eslint-config-expo": "~56.0.4",
59
60
  "expo-linear-gradient": "~56.0.4",
61
+ "globals": "^16.0.0",
62
+ "husky": "^9.1.7",
63
+ "lint-staged": "^16.4.0",
60
64
  "react": "19.2.3",
61
65
  "react-native": "0.85.3",
62
66
  "react-native-google-mobile-ads": "^16.3.4",
63
67
  "react-native-safe-area-context": "~5.7.0",
64
68
  "typescript": "~6.0.3"
69
+ },
70
+ "engines": {
71
+ "node": ">=22"
65
72
  }
66
73
  }
@@ -31,6 +31,8 @@ export const m3Colors = {
31
31
  inverseOnSurface: '#000000',
32
32
  scrim: 'rgba(0,0,0,0.78)',
33
33
  online: '#34FF88',
34
+ chromePillBackground: 'rgba(28, 28, 30, 0.82)',
35
+ chromePillBorder: 'rgba(255, 255, 255, 0.06)',
34
36
  } as const;
35
37
 
36
38
  export const m3Shape = {
@@ -77,6 +79,8 @@ export const colors = {
77
79
  dangerSoft: 'rgba(255,68,68,0.22)',
78
80
  overlay: 'rgba(0,0,0,0.78)',
79
81
  surfaceGlass: 'rgba(255,255,255,0.06)',
82
+ chromePillBackground: 'rgba(28, 28, 30, 0.82)',
83
+ chromePillBorder: 'rgba(255, 255, 255, 0.06)',
80
84
  primary: m3Colors.primary,
81
85
  onSurface: m3Colors.onSurface,
82
86
  onSurfaceVariant: m3Colors.onSurfaceVariant,
@@ -0,0 +1,557 @@
1
+ import React, { useCallback, useRef, useState } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import {
4
+ NativeSyntheticEvent,
5
+ Platform,
6
+ Pressable,
7
+ StyleSheet,
8
+ Text,
9
+ TextInput,
10
+ TextInputContentSizeChangeEventData,
11
+ View,
12
+ type StyleProp,
13
+ type TextInputProps,
14
+ type ViewStyle,
15
+ } from 'react-native';
16
+ import { AppIcon } from './app-icon';
17
+ import { colors, fonts, m3Colors, m3Shape, spacing } from '../theme/proulr-theme';
18
+
19
+ const ACTION_BUTTON_SIZE = 48;
20
+ const FONT_SIZE = 16;
21
+ const LINE_HEIGHT = 25;
22
+ const MAX_LINES_DEFAULT = 4;
23
+ const PILL_MIN_HEIGHT = ACTION_BUTTON_SIZE;
24
+ const VERTICAL_PAD = (PILL_MIN_HEIGHT - LINE_HEIGHT) / 2;
25
+
26
+ export type ComposePillVariant = 'surface' | 'chrome' | 'overlay';
27
+
28
+ export type ComposePillReplyPreview = {
29
+ title: string;
30
+ body: string;
31
+ onDismiss: () => void;
32
+ };
33
+
34
+ export type ComposePillLineLayout = {
35
+ lineCount: number;
36
+ isMultiline: boolean;
37
+ pillHeight: number;
38
+ inputMaxHeight: number;
39
+ };
40
+
41
+ export type ComposePillLineLayoutState = ComposePillLineLayout & {
42
+ handleContentSizeChange: (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => void;
43
+ resetLineCount: () => void;
44
+ setLineCount: (count: number) => void;
45
+ };
46
+
47
+ type ComposePillLayoutOptions = {
48
+ maxLines?: number;
49
+ };
50
+
51
+ function singleLineMaxContentHeight(): number {
52
+ return LINE_HEIGHT * 2;
53
+ }
54
+
55
+ function maxPillHeight(maxLines: number): number {
56
+ return LINE_HEIGHT * maxLines + VERTICAL_PAD * 2;
57
+ }
58
+
59
+ export function countComposePillLines(
60
+ text: string,
61
+ contentHeight: number,
62
+ maxLines: number = MAX_LINES_DEFAULT,
63
+ ): number {
64
+ if (!text) return 1;
65
+
66
+ const explicitLines = text.split('\n').length;
67
+ if (explicitLines > 1) {
68
+ return Math.min(maxLines, explicitLines);
69
+ }
70
+
71
+ if (contentHeight <= singleLineMaxContentHeight()) {
72
+ return 1;
73
+ }
74
+
75
+ const wrappedLines = Math.round(contentHeight / LINE_HEIGHT);
76
+ return Math.min(maxLines, Math.max(1, wrappedLines));
77
+ }
78
+
79
+ export function pillHeightForLineCount(lineCount: number, maxLines: number = MAX_LINES_DEFAULT): number {
80
+ if (lineCount <= 1) return PILL_MIN_HEIGHT;
81
+ const textBlockHeight = lineCount * LINE_HEIGHT;
82
+ return Math.min(maxPillHeight(maxLines), textBlockHeight);
83
+ }
84
+
85
+ export function useComposePillLineLayout(
86
+ value: string,
87
+ { maxLines = MAX_LINES_DEFAULT }: ComposePillLayoutOptions = {},
88
+ ): ComposePillLineLayoutState {
89
+ const valueRef = useRef(value);
90
+ valueRef.current = value;
91
+ const [lineCount, setLineCount] = useState(1);
92
+
93
+ const updateLineLayout = useCallback(
94
+ (text: string, contentHeight: number) => {
95
+ const nextLineCount = text ? countComposePillLines(text, contentHeight, maxLines) : 1;
96
+ setLineCount(nextLineCount);
97
+ },
98
+ [maxLines],
99
+ );
100
+
101
+ const handleContentSizeChange = useCallback(
102
+ (event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
103
+ const text = valueRef.current;
104
+ if (!text) {
105
+ setLineCount(1);
106
+ return;
107
+ }
108
+ updateLineLayout(text, event.nativeEvent.contentSize.height);
109
+ },
110
+ [updateLineLayout],
111
+ );
112
+
113
+ const resetLineCount = useCallback(() => {
114
+ setLineCount(1);
115
+ }, []);
116
+
117
+ const isMultiline = lineCount > 1;
118
+ const pillHeight = pillHeightForLineCount(lineCount, maxLines);
119
+ const inputMaxHeight = maxPillHeight(maxLines) - VERTICAL_PAD * 2;
120
+
121
+ return {
122
+ lineCount,
123
+ isMultiline,
124
+ pillHeight,
125
+ inputMaxHeight,
126
+ handleContentSizeChange,
127
+ resetLineCount,
128
+ setLineCount,
129
+ };
130
+ }
131
+
132
+ type ComposePillTextInputRef = React.Ref<TextInput>;
133
+
134
+ export type ComposePillTextFieldProps = Omit<TextInputProps, 'multiline' | 'style'> & {
135
+ value: string;
136
+ onChangeText: (text: string) => void;
137
+ variant?: ComposePillVariant;
138
+ maxLines?: number;
139
+ inputRef?: ComposePillTextInputRef;
140
+ layout?: ComposePillLineLayoutState;
141
+ style?: StyleProp<ViewStyle>;
142
+ };
143
+
144
+ export function ComposePillTextField({
145
+ value,
146
+ onChangeText,
147
+ variant = 'surface',
148
+ maxLines = MAX_LINES_DEFAULT,
149
+ inputRef,
150
+ layout,
151
+ placeholder,
152
+ placeholderTextColor,
153
+ onFocus,
154
+ onBlur,
155
+ editable,
156
+ style,
157
+ ...rest
158
+ }: ComposePillTextFieldProps) {
159
+ const internalLayout = useComposePillLineLayout(value, { maxLines });
160
+ const {
161
+ lineCount,
162
+ isMultiline,
163
+ inputMaxHeight,
164
+ handleContentSizeChange,
165
+ setLineCount,
166
+ } = layout ?? internalLayout;
167
+
168
+ const handleChange = useCallback(
169
+ (text: string) => {
170
+ onChangeText(text);
171
+ if (!text) {
172
+ setLineCount(1);
173
+ }
174
+ },
175
+ [onChangeText, setLineCount],
176
+ );
177
+
178
+ const variantColors = composePillVariantColors(variant);
179
+
180
+ return (
181
+ <TextInput
182
+ ref={inputRef}
183
+ value={value}
184
+ onChangeText={handleChange}
185
+ onContentSizeChange={handleContentSizeChange}
186
+ onFocus={onFocus}
187
+ onBlur={onBlur}
188
+ placeholder={placeholder}
189
+ placeholderTextColor={placeholderTextColor ?? variantColors.placeholder}
190
+ multiline
191
+ scrollEnabled={lineCount >= maxLines}
192
+ blurOnSubmit={false}
193
+ returnKeyType="default"
194
+ editable={editable}
195
+ style={[
196
+ styles.input,
197
+ {
198
+ color: variantColors.text,
199
+ maxHeight: inputMaxHeight,
200
+ textAlignVertical: isMultiline ? 'top' : 'center',
201
+ },
202
+ style,
203
+ ]}
204
+ {...(Platform.OS === 'android' ? { includeFontPadding: false } : {})}
205
+ {...(Platform.OS === 'web'
206
+ ? ({
207
+ outlineStyle: 'none',
208
+ resize: 'none',
209
+ overflow: 'hidden',
210
+ } as object)
211
+ : {})}
212
+ {...rest}
213
+ />
214
+ );
215
+ }
216
+
217
+ export type ComposePillInputProps = {
218
+ value: string;
219
+ onChangeText: (text: string) => void;
220
+ placeholder?: string;
221
+ disabled?: boolean;
222
+ maxLines?: number;
223
+ variant?: ComposePillVariant;
224
+ onFocus?: () => void;
225
+ onBlur?: () => void;
226
+ inputRef?: ComposePillTextInputRef;
227
+ leading?: ReactNode;
228
+ trailing?: ReactNode;
229
+ endAction?: ReactNode;
230
+ replyPreview?: ComposePillReplyPreview | null;
231
+ style?: StyleProp<ViewStyle>;
232
+ transparentWrap?: boolean;
233
+ /** Bare row without pill chrome — text field only grows inline (ex.: comentários). */
234
+ bare?: boolean;
235
+ rowAlignItems?: 'center' | 'flex-end';
236
+ };
237
+
238
+ function composePillVariantColors(variant: ComposePillVariant) {
239
+ if (variant === 'overlay') {
240
+ return {
241
+ text: colors.white,
242
+ placeholder: 'rgba(255,255,255,0.55)',
243
+ pillBackground: 'rgba(28,28,30,0.72)',
244
+ pillBorder: 'rgba(255,255,255,0.08)',
245
+ replyBackground: 'rgba(28,28,30,0.72)',
246
+ };
247
+ }
248
+
249
+ if (variant === 'chrome') {
250
+ return {
251
+ text: m3Colors.onSurface,
252
+ placeholder: m3Colors.onSurfaceVariant,
253
+ pillBackground: colors.chromePillBackground,
254
+ pillBorder: colors.chromePillBorder,
255
+ replyBackground: colors.chromePillBackground,
256
+ };
257
+ }
258
+
259
+ return {
260
+ text: m3Colors.onSurface,
261
+ placeholder: m3Colors.onSurfaceVariant,
262
+ pillBackground: m3Colors.surfaceContainerHigh,
263
+ pillBorder: 'transparent',
264
+ replyBackground: m3Colors.surfaceContainerHigh,
265
+ };
266
+ }
267
+
268
+ export function ComposePillReplyBar({
269
+ preview,
270
+ variant = 'surface',
271
+ }: {
272
+ preview: ComposePillReplyPreview;
273
+ variant?: ComposePillVariant;
274
+ }) {
275
+ const variantColors = composePillVariantColors(variant);
276
+
277
+ return (
278
+ <View style={[styles.replyBar, { backgroundColor: variantColors.replyBackground }]}>
279
+ <View style={styles.replyBarAccent} />
280
+ <View style={styles.replyBarContent}>
281
+ <Text style={styles.replyBarName} numberOfLines={1}>
282
+ {preview.title}
283
+ </Text>
284
+ <Text style={styles.replyBarPreview} numberOfLines={1}>
285
+ {preview.body}
286
+ </Text>
287
+ </View>
288
+ <Pressable
289
+ onPress={preview.onDismiss}
290
+ hitSlop={8}
291
+ style={styles.replyBarClose}
292
+ accessibilityRole="button"
293
+ accessibilityLabel="Cancelar resposta"
294
+ >
295
+ <AppIcon name="close" size={14} color={m3Colors.onSurfaceVariant} />
296
+ </Pressable>
297
+ </View>
298
+ );
299
+ }
300
+
301
+ export function ComposePillIconButton({
302
+ label,
303
+ children,
304
+ onPress,
305
+ disabled,
306
+ }: {
307
+ label: string;
308
+ children: ReactNode;
309
+ onPress?: () => void;
310
+ disabled?: boolean;
311
+ }) {
312
+ return (
313
+ <Pressable
314
+ style={[styles.iconButton, disabled && styles.iconButtonDisabled]}
315
+ onPress={onPress}
316
+ disabled={disabled || !onPress}
317
+ hitSlop={6}
318
+ accessibilityRole="button"
319
+ accessibilityLabel={label}
320
+ >
321
+ {children}
322
+ </Pressable>
323
+ );
324
+ }
325
+
326
+ export function ComposePillActionButton({
327
+ label,
328
+ children,
329
+ onPress,
330
+ disabled,
331
+ }: {
332
+ label: string;
333
+ children: ReactNode;
334
+ onPress?: () => void;
335
+ disabled?: boolean;
336
+ }) {
337
+ return (
338
+ <Pressable
339
+ style={[styles.actionButton, disabled && styles.actionButtonDisabled]}
340
+ onPress={onPress}
341
+ disabled={disabled || !onPress}
342
+ accessibilityRole="button"
343
+ accessibilityLabel={label}
344
+ >
345
+ {children}
346
+ </Pressable>
347
+ );
348
+ }
349
+
350
+ export function ComposePillInput({
351
+ value,
352
+ onChangeText,
353
+ placeholder = 'Mensagem',
354
+ disabled,
355
+ maxLines = MAX_LINES_DEFAULT,
356
+ variant = 'surface',
357
+ onFocus,
358
+ onBlur,
359
+ inputRef,
360
+ leading,
361
+ trailing,
362
+ endAction,
363
+ replyPreview,
364
+ style,
365
+ transparentWrap = false,
366
+ bare = false,
367
+ rowAlignItems = bare ? 'center' : 'flex-end',
368
+ }: ComposePillInputProps) {
369
+ const layout = useComposePillLineLayout(value, { maxLines });
370
+ const { isMultiline, pillHeight, resetLineCount } = layout;
371
+ const variantColors = composePillVariantColors(variant);
372
+
373
+ const handleChange = useCallback(
374
+ (text: string) => {
375
+ onChangeText(text);
376
+ if (!text) {
377
+ resetLineCount();
378
+ }
379
+ },
380
+ [onChangeText, resetLineCount],
381
+ );
382
+
383
+ const textField = (
384
+ <ComposePillTextField
385
+ value={value}
386
+ onChangeText={handleChange}
387
+ variant={variant}
388
+ maxLines={maxLines}
389
+ inputRef={inputRef}
390
+ layout={layout}
391
+ placeholder={placeholder}
392
+ onFocus={onFocus}
393
+ onBlur={onBlur}
394
+ editable={!disabled}
395
+ style={bare ? styles.bareInput : undefined}
396
+ />
397
+ );
398
+
399
+ return (
400
+ <View
401
+ style={[
402
+ styles.wrap,
403
+ transparentWrap && styles.wrapTransparent,
404
+ variant === 'surface' && !transparentWrap && styles.wrapSurface,
405
+ style,
406
+ ]}
407
+ >
408
+ {replyPreview ? <ComposePillReplyBar preview={replyPreview} variant={variant} /> : null}
409
+
410
+ <View style={[styles.composerRow, { alignItems: rowAlignItems }]}>
411
+ {bare ? (
412
+ <>
413
+ {leading}
414
+ {textField}
415
+ {trailing}
416
+ </>
417
+ ) : (
418
+ <View
419
+ style={[
420
+ styles.inputPill,
421
+ isMultiline ? styles.inputPillExpanded : styles.inputPillSingle,
422
+ {
423
+ minHeight: pillHeight,
424
+ alignItems: isMultiline ? 'flex-end' : 'center',
425
+ backgroundColor: variantColors.pillBackground,
426
+ borderColor: variantColors.pillBorder,
427
+ borderWidth: variant === 'overlay' || variant === 'chrome' ? 1 : 0,
428
+ },
429
+ ]}
430
+ >
431
+ {leading}
432
+ {textField}
433
+ {trailing ? (
434
+ <View style={[styles.trailingIcons, isMultiline && styles.trailingIconsExpanded]}>
435
+ {trailing}
436
+ </View>
437
+ ) : null}
438
+ </View>
439
+ )}
440
+
441
+ {endAction}
442
+ </View>
443
+ </View>
444
+ );
445
+ }
446
+
447
+ const styles = StyleSheet.create({
448
+ wrap: {
449
+ backgroundColor: 'transparent',
450
+ },
451
+ wrapTransparent: {
452
+ backgroundColor: 'transparent',
453
+ },
454
+ wrapSurface: {
455
+ backgroundColor: m3Colors.surface,
456
+ },
457
+ replyBar: {
458
+ flexDirection: 'row',
459
+ alignItems: 'center',
460
+ marginHorizontal: spacing.sm,
461
+ marginTop: spacing.sm,
462
+ marginBottom: spacing.xs,
463
+ paddingVertical: spacing.sm,
464
+ paddingHorizontal: spacing.md,
465
+ borderRadius: m3Shape.cornerMedium,
466
+ gap: spacing.sm,
467
+ },
468
+ replyBarAccent: {
469
+ width: 4,
470
+ alignSelf: 'stretch',
471
+ borderRadius: m3Shape.cornerExtraSmall,
472
+ backgroundColor: m3Colors.primary,
473
+ },
474
+ replyBarContent: {
475
+ flex: 1,
476
+ gap: 2,
477
+ },
478
+ replyBarName: {
479
+ color: m3Colors.primary,
480
+ fontSize: 13,
481
+ fontFamily: fonts.semibold,
482
+ },
483
+ replyBarPreview: {
484
+ color: m3Colors.onSurfaceVariant,
485
+ fontSize: 13,
486
+ fontFamily: fonts.regular,
487
+ },
488
+ replyBarClose: {
489
+ width: 32,
490
+ height: 32,
491
+ alignItems: 'center',
492
+ justifyContent: 'center',
493
+ },
494
+ composerRow: {
495
+ flexDirection: 'row',
496
+ alignItems: 'flex-end',
497
+ gap: spacing.sm,
498
+ paddingHorizontal: spacing.sm,
499
+ paddingTop: spacing.sm,
500
+ },
501
+ inputPill: {
502
+ flex: 1,
503
+ flexDirection: 'row',
504
+ paddingLeft: spacing.xs,
505
+ paddingRight: spacing.xs,
506
+ overflow: 'hidden',
507
+ },
508
+ inputPillSingle: {
509
+ borderRadius: m3Shape.cornerFull,
510
+ },
511
+ inputPillExpanded: {
512
+ borderRadius: m3Shape.cornerLarge,
513
+ },
514
+ iconButton: {
515
+ width: 40,
516
+ height: 40,
517
+ alignItems: 'center',
518
+ justifyContent: 'center',
519
+ },
520
+ iconButtonDisabled: {
521
+ opacity: 0.4,
522
+ },
523
+ trailingIcons: {
524
+ flexDirection: 'row',
525
+ alignItems: 'center',
526
+ },
527
+ trailingIconsExpanded: {
528
+ paddingBottom: spacing.xs,
529
+ },
530
+ input: {
531
+ flex: 1,
532
+ fontSize: FONT_SIZE,
533
+ lineHeight: LINE_HEIGHT,
534
+ fontFamily: fonts.regular,
535
+ paddingHorizontal: spacing.xs,
536
+ paddingTop: VERTICAL_PAD,
537
+ paddingBottom: VERTICAL_PAD,
538
+ },
539
+ bareInput: {
540
+ minHeight: 40,
541
+ paddingTop: spacing.xs,
542
+ paddingBottom: spacing.xs,
543
+ },
544
+ actionButton: {
545
+ width: ACTION_BUTTON_SIZE,
546
+ height: ACTION_BUTTON_SIZE,
547
+ borderRadius: ACTION_BUTTON_SIZE / 2,
548
+ backgroundColor: m3Colors.primary,
549
+ alignItems: 'center',
550
+ justifyContent: 'center',
551
+ },
552
+ actionButtonDisabled: {
553
+ opacity: 0.45,
554
+ },
555
+ });
556
+
557
+ export const COMPOSE_PILL_ACTION_BUTTON_SIZE = ACTION_BUTTON_SIZE;
@@ -71,6 +71,23 @@ export { ComposeMediaShortcut, COMPOSE_MEDIA_TILE_SIZE } from './compose-media-s
71
71
  export { ComposeRecentMediaGrid, type ComposeRecentMediaItem } from './compose-recent-media-grid';
72
72
  export { ComposeScreenHeader } from './compose-screen-header';
73
73
  export { ComposeTextArea, ComposeMediaPreviewFrame } from './compose-text-area';
74
+ export {
75
+ ComposePillActionButton,
76
+ ComposePillIconButton,
77
+ ComposePillInput,
78
+ ComposePillReplyBar,
79
+ ComposePillTextField,
80
+ COMPOSE_PILL_ACTION_BUTTON_SIZE,
81
+ countComposePillLines,
82
+ pillHeightForLineCount,
83
+ useComposePillLineLayout,
84
+ type ComposePillInputProps,
85
+ type ComposePillLineLayout,
86
+ type ComposePillLineLayoutState,
87
+ type ComposePillReplyPreview,
88
+ type ComposePillTextFieldProps,
89
+ type ComposePillVariant,
90
+ } from './compose-pill-input';
74
91
  export { ComposeToolbar, ComposeToolbarIcon, COMPOSE_TOOLBAR_ICON_SIZE } from './compose-toolbar';
75
92
  export { ComposeReplyAudienceSheet, type ComposeReplyAudienceOption, type ComposeReplyAudienceValue } from './compose-reply-audience-sheet';
76
93
  export { ComposeVisibilityRow } from './compose-visibility-row';