@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.
- package/lib/commonjs/components/CLAUDE.md +2 -1
- package/lib/commonjs/components/Input/styles.js +4 -3
- package/lib/commonjs/components/Input/styles.js.map +1 -1
- package/lib/commonjs/components/Select/styles.js +4 -2
- package/lib/commonjs/components/Select/styles.js.map +1 -1
- package/lib/commonjs/components/Textarea/index.js +135 -0
- package/lib/commonjs/components/Textarea/index.js.map +1 -0
- package/lib/commonjs/components/Textarea/styles.js +49 -0
- package/lib/commonjs/components/Textarea/styles.js.map +1 -0
- package/lib/commonjs/components/index.js +12 -0
- package/lib/commonjs/components/index.js.map +1 -1
- package/lib/commonjs/figma/Field.Textarea.figma.js +57 -0
- package/lib/commonjs/figma/Field.Textarea.figma.js.map +1 -0
- package/lib/commonjs/figma/Textarea.figma.js +47 -0
- package/lib/commonjs/figma/Textarea.figma.js.map +1 -0
- package/lib/module/components/CLAUDE.md +2 -1
- package/lib/module/components/Input/styles.js +4 -3
- package/lib/module/components/Input/styles.js.map +1 -1
- package/lib/module/components/Select/styles.js +4 -2
- package/lib/module/components/Select/styles.js.map +1 -1
- package/lib/module/components/Textarea/index.js +132 -0
- package/lib/module/components/Textarea/index.js.map +1 -0
- package/lib/module/components/Textarea/styles.js +45 -0
- package/lib/module/components/Textarea/styles.js.map +1 -0
- package/lib/module/components/index.js +1 -0
- package/lib/module/components/index.js.map +1 -1
- package/lib/module/figma/Field.Textarea.figma.js +51 -0
- package/lib/module/figma/Field.Textarea.figma.js.map +1 -0
- package/lib/module/figma/Textarea.figma.js +41 -0
- package/lib/module/figma/Textarea.figma.js.map +1 -0
- package/lib/typescript/components/Input/styles.d.ts.map +1 -1
- package/lib/typescript/components/Select/styles.d.ts.map +1 -1
- package/lib/typescript/components/Textarea/index.d.ts +26 -0
- package/lib/typescript/components/Textarea/index.d.ts.map +1 -0
- package/lib/typescript/components/Textarea/styles.d.ts +11 -0
- package/lib/typescript/components/Textarea/styles.d.ts.map +1 -0
- package/lib/typescript/components/index.d.ts +1 -0
- package/lib/typescript/components/index.d.ts.map +1 -1
- package/lib/typescript/figma/Field.Textarea.figma.d.ts +8 -0
- package/lib/typescript/figma/Field.Textarea.figma.d.ts.map +1 -0
- package/lib/typescript/figma/Textarea.figma.d.ts +8 -0
- package/lib/typescript/figma/Textarea.figma.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/components/CLAUDE.md +2 -1
- package/src/components/Input/styles.ts +6 -3
- package/src/components/Select/styles.ts +8 -4
- package/src/components/Textarea/index.tsx +179 -0
- package/src/components/Textarea/styles.ts +86 -0
- 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>;
|
package/src/components/index.ts
CHANGED