@cdx-ui/components 0.0.1-beta.90 → 0.0.1-beta.92

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 (49) hide show
  1. package/lib/commonjs/components/CLAUDE.md +2 -1
  2. package/lib/commonjs/components/Input/styles.js +4 -3
  3. package/lib/commonjs/components/Input/styles.js.map +1 -1
  4. package/lib/commonjs/components/Select/styles.js +4 -2
  5. package/lib/commonjs/components/Select/styles.js.map +1 -1
  6. package/lib/commonjs/components/Textarea/index.js +135 -0
  7. package/lib/commonjs/components/Textarea/index.js.map +1 -0
  8. package/lib/commonjs/components/Textarea/styles.js +49 -0
  9. package/lib/commonjs/components/Textarea/styles.js.map +1 -0
  10. package/lib/commonjs/components/index.js +12 -0
  11. package/lib/commonjs/components/index.js.map +1 -1
  12. package/lib/commonjs/figma/Field.Textarea.figma.js +57 -0
  13. package/lib/commonjs/figma/Field.Textarea.figma.js.map +1 -0
  14. package/lib/commonjs/figma/Textarea.figma.js +47 -0
  15. package/lib/commonjs/figma/Textarea.figma.js.map +1 -0
  16. package/lib/module/components/CLAUDE.md +2 -1
  17. package/lib/module/components/Input/styles.js +4 -3
  18. package/lib/module/components/Input/styles.js.map +1 -1
  19. package/lib/module/components/Select/styles.js +4 -2
  20. package/lib/module/components/Select/styles.js.map +1 -1
  21. package/lib/module/components/Textarea/index.js +132 -0
  22. package/lib/module/components/Textarea/index.js.map +1 -0
  23. package/lib/module/components/Textarea/styles.js +45 -0
  24. package/lib/module/components/Textarea/styles.js.map +1 -0
  25. package/lib/module/components/index.js +1 -0
  26. package/lib/module/components/index.js.map +1 -1
  27. package/lib/module/figma/Field.Textarea.figma.js +51 -0
  28. package/lib/module/figma/Field.Textarea.figma.js.map +1 -0
  29. package/lib/module/figma/Textarea.figma.js +41 -0
  30. package/lib/module/figma/Textarea.figma.js.map +1 -0
  31. package/lib/typescript/components/Input/styles.d.ts.map +1 -1
  32. package/lib/typescript/components/Select/styles.d.ts.map +1 -1
  33. package/lib/typescript/components/Textarea/index.d.ts +26 -0
  34. package/lib/typescript/components/Textarea/index.d.ts.map +1 -0
  35. package/lib/typescript/components/Textarea/styles.d.ts +11 -0
  36. package/lib/typescript/components/Textarea/styles.d.ts.map +1 -0
  37. package/lib/typescript/components/index.d.ts +1 -0
  38. package/lib/typescript/components/index.d.ts.map +1 -1
  39. package/lib/typescript/figma/Field.Textarea.figma.d.ts +8 -0
  40. package/lib/typescript/figma/Field.Textarea.figma.d.ts.map +1 -0
  41. package/lib/typescript/figma/Textarea.figma.d.ts +8 -0
  42. package/lib/typescript/figma/Textarea.figma.d.ts.map +1 -0
  43. package/package.json +4 -4
  44. package/src/components/CLAUDE.md +2 -1
  45. package/src/components/Input/styles.ts +6 -3
  46. package/src/components/Select/styles.ts +8 -4
  47. package/src/components/Textarea/index.tsx +179 -0
  48. package/src/components/Textarea/styles.ts +86 -0
  49. package/src/components/index.ts +1 -0
@@ -0,0 +1,179 @@
1
+ import { forwardRef, useEffect, useState } from 'react';
2
+ import {
3
+ Platform,
4
+ type TextInput,
5
+ type TextInputProps,
6
+ type TextStyle,
7
+ type ViewProps,
8
+ } from 'react-native';
9
+ import {
10
+ InputField as InputFieldPrimitive,
11
+ InputRoot as InputRootPrimitive,
12
+ dataAttributes,
13
+ type IInputFieldProps,
14
+ } from '@cdx-ui/primitives';
15
+ import { cn, composeEventHandlers } from '@cdx-ui/utils';
16
+ import {
17
+ type TextareaVariantProps,
18
+ textareaFieldPlaceholderVariants,
19
+ textareaFieldVariants,
20
+ textareaRootVariants,
21
+ } from './styles';
22
+
23
+ // Initial/minimum visible rows. Web textareas render compactly, so a smaller default
24
+ // reads better there; native has no intrinsic multiline height, so it needs more.
25
+ const DEFAULT_MIN_ROWS = Platform.select({ web: 2, default: 8 });
26
+
27
+ // Explicit per-size line height (px) set on the field so `rows * lineHeight` maps to an
28
+ // exact pixel height on native. Values track the field font sizes in `styles.ts`.
29
+ const LINE_HEIGHT_BY_SIZE = { default: 20, small: 16 } as const;
30
+
31
+ // Module-scoped latch so the dev conflict warning fires at most once per session.
32
+ let hasWarnedResizeAutoGrowConflict = false;
33
+
34
+ type ContentSizeChangeEvent = Parameters<NonNullable<TextInputProps['onContentSizeChange']>>[0];
35
+
36
+ export type TextareaResize = 'none' | 'vertical';
37
+
38
+ export interface TextareaProps
39
+ extends
40
+ Omit<TextInputProps, 'multiline' | 'children' | 'style'>,
41
+ IInputFieldProps,
42
+ TextareaVariantProps {
43
+ className?: string;
44
+ placeholderTextColorClassName?: string;
45
+ /** Initial / minimum visible rows. Defaults to 2 on web and 8 on native. */
46
+ numberOfLines?: number;
47
+ /**
48
+ * Grow the field height to fit its content. Defaults to `false`.
49
+ * Web uses `field-sizing: content`; native measures via `onContentSizeChange`.
50
+ */
51
+ autoGrow?: boolean;
52
+ /** Caps `autoGrow` at this many rows, after which the field scrolls. */
53
+ maxRows?: number;
54
+ /**
55
+ * Web-only user resize affordance. Defaults to `'none'`. `'vertical'` exposes the
56
+ * browser resize handle and takes precedence over `autoGrow` (they are mutually
57
+ * exclusive). No-op on native.
58
+ */
59
+ resize?: TextareaResize;
60
+ style?: ViewProps['style'];
61
+ }
62
+
63
+ export const Textarea = forwardRef<TextInput, TextareaProps>(
64
+ (
65
+ {
66
+ variant = 'outline',
67
+ size = 'default',
68
+ isDisabled,
69
+ isInvalid,
70
+ isReadOnly,
71
+ isRequired,
72
+ isFocused,
73
+ isHovered,
74
+ isFullWidth,
75
+ className,
76
+ placeholderTextColorClassName,
77
+ style,
78
+ numberOfLines,
79
+ autoGrow = false,
80
+ maxRows,
81
+ resize = 'none',
82
+ onContentSizeChange,
83
+ ...rest
84
+ },
85
+ ref,
86
+ ) => {
87
+ const minRows = numberOfLines ?? DEFAULT_MIN_ROWS;
88
+ const lineHeight = LINE_HEIGHT_BY_SIZE[size ?? 'default'];
89
+ const minHeight = minRows * lineHeight;
90
+ const maxHeight = maxRows != null ? maxRows * lineHeight : undefined;
91
+
92
+ const isWeb = Platform.OS === 'web';
93
+ // Web user-resize wins over auto-grow; the two are mutually exclusive.
94
+ const userResizable = isWeb && resize === 'vertical';
95
+ const effectiveAutoGrow = autoGrow && !userResizable;
96
+
97
+ useEffect(() => {
98
+ if (__DEV__ && autoGrow && resize === 'vertical' && !hasWarnedResizeAutoGrowConflict) {
99
+ hasWarnedResizeAutoGrowConflict = true;
100
+ console.warn(
101
+ '[Textarea] `autoGrow` and `resize="vertical"` are mutually exclusive. ' +
102
+ '`resize` takes precedence and `autoGrow` is ignored.',
103
+ );
104
+ }
105
+ }, [autoGrow, resize]);
106
+
107
+ // Native auto-grow: track the measured content height so we can enable internal
108
+ // scrolling once it passes the `maxRows` cap. Growth itself is handled natively
109
+ // (see `nativeScrollEnabled`), not by pinning a height here.
110
+ const [measuredHeight, setMeasuredHeight] = useState<number | undefined>(undefined);
111
+
112
+ const handleContentSizeChange = (event: ContentSizeChangeEvent) => {
113
+ if (!isWeb && effectiveAutoGrow) {
114
+ setMeasuredHeight(event.nativeEvent.contentSize.height);
115
+ }
116
+ };
117
+
118
+ // On native, a multiline TextInput only grows to fit its content when scrolling is
119
+ // disabled — an explicit height or an enabled scroll view pins it. So for auto-grow we
120
+ // keep `scrollEnabled` off (letting the view size itself between min/max) and only turn
121
+ // scrolling back on once the content exceeds the `maxRows` cap. Undefined elsewhere
122
+ // (web + fixed height) leaves React Native's default (`true`) so overflow scrolls.
123
+ const nativeScrollEnabled =
124
+ !isWeb && effectiveAutoGrow
125
+ ? maxHeight != null && measuredHeight != null && measuredHeight > maxHeight
126
+ : undefined;
127
+
128
+ // Auto-grow (web + native) and fixed sizing all bound the field with min/max height and
129
+ // never pin an explicit `height`: web grows via `field-sizing: content`, native grows via
130
+ // the disabled scroll view above, and the fixed case simply floors at `minHeight`.
131
+ const heightStyle: TextStyle =
132
+ !isWeb && !effectiveAutoGrow ? { minHeight } : { minHeight, maxHeight };
133
+
134
+ // `textAlignVertical` is set in style (not just the prop) so it overrides the Android
135
+ // primitive's `center` default — style wins over the deprecated prop.
136
+ const fieldStyle: TextStyle = { lineHeight, textAlignVertical: 'top', ...heightStyle };
137
+
138
+ // `className` applies to the root (the visible bordered box), matching the flat
139
+ // CurrencyInput pattern. Field-level utilities below are internal sizing concerns.
140
+ const fieldClassName = cn(
141
+ textareaFieldVariants({ size }),
142
+ effectiveAutoGrow && 'web:[field-sizing:content]',
143
+ userResizable ? 'web:[resize:vertical]' : 'web:[resize:none]',
144
+ );
145
+
146
+ return (
147
+ <InputRootPrimitive
148
+ isDisabled={isDisabled}
149
+ isInvalid={isInvalid}
150
+ isReadOnly={isReadOnly}
151
+ isRequired={isRequired}
152
+ isFocused={isFocused}
153
+ isHovered={isHovered}
154
+ isFullWidth={isFullWidth}
155
+ className={cn(textareaRootVariants({ variant, size }), className)}
156
+ style={style}
157
+ {...dataAttributes({ slot: 'textarea' })}
158
+ >
159
+ <InputFieldPrimitive
160
+ ref={ref}
161
+ placeholderTextColorClassName={cn(
162
+ textareaFieldPlaceholderVariants(),
163
+ placeholderTextColorClassName,
164
+ )}
165
+ {...rest}
166
+ multiline
167
+ numberOfLines={isWeb ? minRows : undefined}
168
+ textAlignVertical="top"
169
+ scrollEnabled={nativeScrollEnabled}
170
+ className={fieldClassName}
171
+ style={fieldStyle}
172
+ onContentSizeChange={composeEventHandlers(onContentSizeChange, handleContentSizeChange)}
173
+ />
174
+ </InputRootPrimitive>
175
+ );
176
+ },
177
+ );
178
+
179
+ Textarea.displayName = 'Textarea';
@@ -0,0 +1,86 @@
1
+ import { cva, type VariantProps } from 'class-variance-authority';
2
+ import { DISABLED_CURSOR, TRANSITION_COLORS } from '../../styles/primitives';
3
+
4
+ export const textareaRootVariants = cva(
5
+ [
6
+ // Multiline shell: stretch the field to full width and let the shell grow with it
7
+ // (no fixed height, no overflow-hidden so a web resize handle is never clipped).
8
+ 'w-full flex-row items-stretch',
9
+ 'bg-surface-primary',
10
+ 'border-solid border-stroke-primary',
11
+ 'web:border-default native:border-[length:var(--border-width-default)]',
12
+ 'rounded-[var(--border-radius-default)]',
13
+ TRANSITION_COLORS,
14
+ 'data-[disabled=true]:bg-surface-secondary',
15
+ 'data-[disabled=true]:opacity-[var(--opacity-disabled)]',
16
+ DISABLED_CURSOR,
17
+ 'data-[readonly=true]:cursor-default',
18
+ 'data-[readonly=true]:bg-surface-secondary',
19
+ 'web:data-[hover=true]:data-[focus=false]:data-[invalid=false]:border-stroke-hover',
20
+ // Web: inset ring avoids a border-width change so the shell never shifts on focus.
21
+ // Recolor the border to match the ring so the two read as a single focus outline
22
+ // instead of a gray border + colored ring.
23
+ 'web:data-[focus=true]:ring-inset',
24
+ 'web:data-[focus=true]:ring-[length:var(--border-width-focused)]',
25
+ 'web:data-[focus=true]:data-[invalid=false]:ring-stroke-action',
26
+ 'web:data-[focus=true]:data-[invalid=false]:border-stroke-action',
27
+ 'web:data-[focus=true]:data-[invalid=true]:ring-stroke-danger',
28
+ 'web:data-[focus=true]:data-[invalid=true]:border-stroke-danger',
29
+ // Native: thicker border on focus; size variants subtract a hairline of padding
30
+ // so layout doesn't shift by 1px.
31
+ 'native:data-[focus=true]:border-[length:var(--border-width-focused)]',
32
+ 'native:data-[field-state=focused]:border-stroke-action',
33
+ 'native:data-[field-state=focused-invalid]:border-stroke-danger',
34
+ 'data-[invalid=true]:border-stroke-danger',
35
+ ],
36
+ {
37
+ variants: {
38
+ variant: {
39
+ outline: [],
40
+ },
41
+ size: {
42
+ default: [
43
+ 'px-4 py-3 gap-4',
44
+ 'native:data-[focus=true]:px-[calc(var(--spacing-4)-var(--spacing-px))]',
45
+ 'native:data-[focus=true]:py-[calc(var(--spacing-3)-var(--spacing-px))]',
46
+ ],
47
+ small: [
48
+ 'px-3 py-2 gap-1.5',
49
+ 'native:data-[focus=true]:px-[calc(var(--spacing-3)-var(--spacing-px))]',
50
+ 'native:data-[focus=true]:py-[calc(var(--spacing-2)-var(--spacing-px))]',
51
+ ],
52
+ },
53
+ },
54
+ defaultVariants: {
55
+ variant: 'outline',
56
+ size: 'default',
57
+ },
58
+ },
59
+ );
60
+
61
+ export const textareaFieldVariants = cva(
62
+ [
63
+ // `min-w-0` lets the field shrink below its intrinsic content width so the shell
64
+ // never overflows (mirrors the Input field rationale).
65
+ 'flex-1 min-w-0 bg-transparent p-0',
66
+ 'text-content-primary',
67
+ 'placeholder:text-content-tertiary',
68
+ 'web:outline-none',
69
+ 'tracking-normal',
70
+ ],
71
+ {
72
+ variants: {
73
+ size: {
74
+ default: 'text-[0.875rem]',
75
+ small: 'text-[0.75rem]',
76
+ },
77
+ },
78
+ defaultVariants: {
79
+ size: 'default',
80
+ },
81
+ },
82
+ );
83
+
84
+ export const textareaFieldPlaceholderVariants = cva(['accent-content-tertiary']);
85
+
86
+ export type TextareaVariantProps = VariantProps<typeof textareaRootVariants>;
@@ -25,6 +25,7 @@ export * from './ScopedTheme';
25
25
  export * from './Select';
26
26
  export * from './VirtualizedList';
27
27
  export * from './Switch';
28
+ export * from './Textarea';
28
29
  export * from './Tile';
29
30
  export * from './Toast';
30
31
  export { HStack, VStack } from './Stack';