@cdx-ui/components 0.0.1-beta.91 → 0.0.1-beta.93

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 +27 -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 +180 -0
  48. package/src/components/Textarea/styles.ts +86 -0
  49. package/src/components/index.ts +1 -0
@@ -0,0 +1,180 @@
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 grows intrinsically (`scrollEnabled`
50
+ * off), using `onContentSizeChange` only to enable scrolling past `maxRows`.
51
+ */
52
+ autoGrow?: boolean;
53
+ /** Caps `autoGrow` at this many rows, after which the field scrolls. */
54
+ maxRows?: number;
55
+ /**
56
+ * Web-only user resize affordance. Defaults to `'none'`. `'vertical'` exposes the
57
+ * browser resize handle and takes precedence over `autoGrow` (they are mutually
58
+ * exclusive). No-op on native.
59
+ */
60
+ resize?: TextareaResize;
61
+ style?: ViewProps['style'];
62
+ }
63
+
64
+ export const Textarea = forwardRef<TextInput, TextareaProps>(
65
+ (
66
+ {
67
+ variant = 'outline',
68
+ size = 'default',
69
+ isDisabled,
70
+ isInvalid,
71
+ isReadOnly,
72
+ isRequired,
73
+ isFocused,
74
+ isHovered,
75
+ isFullWidth,
76
+ className,
77
+ placeholderTextColorClassName,
78
+ style,
79
+ numberOfLines,
80
+ autoGrow = false,
81
+ maxRows,
82
+ resize = 'none',
83
+ onContentSizeChange,
84
+ ...rest
85
+ },
86
+ ref,
87
+ ) => {
88
+ const minRows = numberOfLines ?? DEFAULT_MIN_ROWS;
89
+ const lineHeight = LINE_HEIGHT_BY_SIZE[size ?? 'default'];
90
+ const minHeight = minRows * lineHeight;
91
+ const maxHeight = maxRows != null ? maxRows * lineHeight : undefined;
92
+
93
+ const isWeb = Platform.OS === 'web';
94
+ // Web user-resize wins over auto-grow; the two are mutually exclusive.
95
+ const userResizable = isWeb && resize === 'vertical';
96
+ const effectiveAutoGrow = autoGrow && !userResizable;
97
+
98
+ useEffect(() => {
99
+ if (__DEV__ && autoGrow && resize === 'vertical' && !hasWarnedResizeAutoGrowConflict) {
100
+ hasWarnedResizeAutoGrowConflict = true;
101
+ console.warn(
102
+ '[Textarea] `autoGrow` and `resize="vertical"` are mutually exclusive. ' +
103
+ '`resize` takes precedence and `autoGrow` is ignored.',
104
+ );
105
+ }
106
+ }, [autoGrow, resize]);
107
+
108
+ // Native auto-grow: track the measured content height so we can enable internal
109
+ // scrolling once it passes the `maxRows` cap. Growth itself is handled natively
110
+ // (see `nativeScrollEnabled`), not by pinning a height here.
111
+ const [measuredHeight, setMeasuredHeight] = useState<number | undefined>(undefined);
112
+
113
+ const handleContentSizeChange = (event: ContentSizeChangeEvent) => {
114
+ if (!isWeb && effectiveAutoGrow) {
115
+ setMeasuredHeight(event.nativeEvent.contentSize.height);
116
+ }
117
+ };
118
+
119
+ // On native, a multiline TextInput only grows to fit its content when scrolling is
120
+ // disabled — an explicit height or an enabled scroll view pins it. So for auto-grow we
121
+ // keep `scrollEnabled` off (letting the view size itself between min/max) and only turn
122
+ // scrolling back on once the content exceeds the `maxRows` cap. Undefined elsewhere
123
+ // (web + fixed height) leaves React Native's default (`true`) so overflow scrolls.
124
+ const nativeScrollEnabled =
125
+ !isWeb && effectiveAutoGrow
126
+ ? maxHeight != null && measuredHeight != null && measuredHeight > maxHeight
127
+ : undefined;
128
+
129
+ // Auto-grow (web + native) and fixed sizing all bound the field with min/max height and
130
+ // never pin an explicit `height`: web grows via `field-sizing: content`, native grows via
131
+ // the disabled scroll view above, and the fixed case simply floors at `minHeight`.
132
+ const heightStyle: TextStyle =
133
+ !isWeb && !effectiveAutoGrow ? { minHeight } : { minHeight, maxHeight };
134
+
135
+ // `textAlignVertical` is set in style (not just the prop) so it overrides the Android
136
+ // primitive's `center` default — style wins over the deprecated prop.
137
+ const fieldStyle: TextStyle = { lineHeight, textAlignVertical: 'top', ...heightStyle };
138
+
139
+ // `className` applies to the root (the visible bordered box), matching the flat
140
+ // CurrencyInput pattern. Field-level utilities below are internal sizing concerns.
141
+ const fieldClassName = cn(
142
+ textareaFieldVariants({ size }),
143
+ effectiveAutoGrow && 'web:[field-sizing:content]',
144
+ userResizable ? 'web:[resize:vertical]' : 'web:[resize:none]',
145
+ );
146
+
147
+ return (
148
+ <InputRootPrimitive
149
+ isDisabled={isDisabled}
150
+ isInvalid={isInvalid}
151
+ isReadOnly={isReadOnly}
152
+ isRequired={isRequired}
153
+ isFocused={isFocused}
154
+ isHovered={isHovered}
155
+ isFullWidth={isFullWidth}
156
+ className={cn(textareaRootVariants({ variant, size }), className)}
157
+ style={style}
158
+ {...dataAttributes({ slot: 'textarea' })}
159
+ >
160
+ <InputFieldPrimitive
161
+ ref={ref}
162
+ placeholderTextColorClassName={cn(
163
+ textareaFieldPlaceholderVariants(),
164
+ placeholderTextColorClassName,
165
+ )}
166
+ {...rest}
167
+ multiline
168
+ numberOfLines={isWeb ? minRows : undefined}
169
+ textAlignVertical="top"
170
+ scrollEnabled={nativeScrollEnabled}
171
+ className={fieldClassName}
172
+ style={fieldStyle}
173
+ onContentSizeChange={composeEventHandlers(onContentSizeChange, handleContentSizeChange)}
174
+ />
175
+ </InputRootPrimitive>
176
+ );
177
+ },
178
+ );
179
+
180
+ 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';