panelui-native 0.35.2 → 0.36.0
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/module/components/input/index.js +135 -32
- package/lib/module/components/input/index.js.map +1 -1
- package/lib/module/components/otp-input/index.js +4 -1
- package/lib/module/components/otp-input/index.js.map +1 -1
- package/lib/module/components/panelside/index.js +1 -1
- package/lib/module/components/panelside/index.js.map +1 -1
- package/lib/module/components/textarea/index.js +3 -1
- package/lib/module/components/textarea/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +801 -0
- package/lib/module/components/time-picker/index.js.map +1 -0
- package/lib/module/icons/index.js +59 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/utils/time.js +180 -0
- package/lib/module/utils/time.js.map +1 -0
- package/lib/typescript/src/components/input/index.d.ts +92 -0
- package/lib/typescript/src/components/input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -1
- package/lib/typescript/src/components/textarea/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +104 -0
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -0
- package/lib/typescript/src/icons/index.d.ts +4 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/utils/time.d.ts +88 -0
- package/lib/typescript/src/utils/time.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/input/index.tsx +171 -29
- package/src/components/otp-input/index.tsx +4 -1
- package/src/components/panelside/index.tsx +1 -1
- package/src/components/textarea/index.tsx +3 -1
- package/src/components/time-picker/index.tsx +971 -0
- package/src/icons/index.tsx +47 -0
- package/src/index.ts +25 -0
- package/src/theme/use-theme.ts +1 -1
- package/src/utils/time.ts +192 -0
- package/theme.css +32 -0
|
@@ -14,13 +14,31 @@
|
|
|
14
14
|
* ```tsx
|
|
15
15
|
* <Input label="Email" placeholder="you@example.com" />
|
|
16
16
|
* <Input variant="filled" size="lg" label="Name" isRequired />
|
|
17
|
+
* <Input label="Note" startContent={<PencilIcon size={18} />} />
|
|
17
18
|
* ```
|
|
18
19
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
20
|
+
* ## An icon inside the field, and why it lives here
|
|
21
|
+
*
|
|
22
|
+
* `startContent` and `endContent` are measured and turned into padding on the
|
|
23
|
+
* text, so a value never runs underneath them however wide they turn out to
|
|
24
|
+
* be. They are positioned against the *field box* rather than against the
|
|
25
|
+
* component, which is the whole reason they are a prop rather than something
|
|
26
|
+
* you compose: a label and a description are laid out above and below the
|
|
27
|
+
* field, so anything centred on the component as a whole drifts upward the
|
|
28
|
+
* moment a label is added.
|
|
29
|
+
*
|
|
30
|
+
* `InputGroup` is still the right answer for a decorator that is not part of
|
|
31
|
+
* the field — a button attached to its end, a select bolted to its start, an
|
|
32
|
+
* addon with its own background. It measures the same way; it just spans a
|
|
33
|
+
* different box.
|
|
21
34
|
*/
|
|
22
|
-
import { forwardRef, useCallback, useEffect, useState } from 'react';
|
|
23
|
-
import {
|
|
35
|
+
import { forwardRef, useCallback, useEffect, useState, type ReactNode } from 'react';
|
|
36
|
+
import {
|
|
37
|
+
TextInput,
|
|
38
|
+
View,
|
|
39
|
+
type LayoutChangeEvent,
|
|
40
|
+
type TextInputProps,
|
|
41
|
+
} from 'react-native';
|
|
24
42
|
import Animated, {
|
|
25
43
|
interpolateColor,
|
|
26
44
|
useAnimatedStyle,
|
|
@@ -30,8 +48,9 @@ import Animated, {
|
|
|
30
48
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
31
49
|
import { useCSSVariable } from 'uniwind';
|
|
32
50
|
import type { KeyboardAvoidanceMode } from '../../hooks/use-keyboard-avoidance';
|
|
51
|
+
import { IconColorProvider } from '../../icons';
|
|
33
52
|
import { KeyboardAvoider } from '../../primitives/keyboard-avoider';
|
|
34
|
-
import { Text } from '../../primitives/text';
|
|
53
|
+
import { Text, textChildren } from '../../primitives/text';
|
|
35
54
|
import { Label } from '../label';
|
|
36
55
|
|
|
37
56
|
/** Long enough to read as a transition, short enough not to lag a fast tab. */
|
|
@@ -40,9 +59,25 @@ const FOCUS_DURATION = 150;
|
|
|
40
59
|
const inputVariants = tv({
|
|
41
60
|
slots: {
|
|
42
61
|
container: 'w-full gap-1.5',
|
|
43
|
-
|
|
62
|
+
/*
|
|
63
|
+
* `font-normal` on a field that is not bold: this is a TextInput, so it
|
|
64
|
+
* does not go through the Text primitive and inherits nothing. The class
|
|
65
|
+
* is what an app's `--font-normal` token attaches to, and without it a
|
|
66
|
+
* custom font would stop at the edge of every field in the library.
|
|
67
|
+
*/
|
|
68
|
+
field: 'w-full rounded-lg border font-normal text-foreground',
|
|
44
69
|
description: 'text-sm text-muted-foreground',
|
|
45
70
|
error: 'text-sm text-destructive',
|
|
71
|
+
/*
|
|
72
|
+
* The box the content is positioned against, and the reason this works
|
|
73
|
+
* where wrapping the whole component does not: it is the field alone, so
|
|
74
|
+
* a label above it and a description below it cannot move what is inside.
|
|
75
|
+
*/
|
|
76
|
+
fieldBox: 'relative w-full',
|
|
77
|
+
startContent:
|
|
78
|
+
'absolute bottom-0 start-0 top-0 z-10 flex-row items-center justify-center gap-2',
|
|
79
|
+
endContent:
|
|
80
|
+
'absolute bottom-0 end-0 top-0 z-10 flex-row items-center justify-center gap-2',
|
|
46
81
|
},
|
|
47
82
|
variants: {
|
|
48
83
|
variant: {
|
|
@@ -60,19 +95,33 @@ const inputVariants = tv({
|
|
|
60
95
|
* below the middle of the box, a few pixels off whatever is beside them.
|
|
61
96
|
* A length sets the size alone and leaves the line box the font's own.
|
|
62
97
|
*/
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
// The content boxes take the field's own horizontal padding, so an icon
|
|
99
|
+
// sits at exactly the inset the text would have started at.
|
|
100
|
+
sm: { field: 'h-10 px-3 text-[14px]', startContent: 'px-3', endContent: 'px-3' },
|
|
101
|
+
md: { field: 'h-12 px-3.5 text-[16px]', startContent: 'px-3.5', endContent: 'px-3.5' },
|
|
102
|
+
lg: { field: 'h-14 px-4 text-[16px]', startContent: 'px-4', endContent: 'px-4' },
|
|
66
103
|
},
|
|
67
104
|
multiline: {
|
|
68
105
|
// A multiline field grows, so a fixed height would crop it. Text starts
|
|
69
106
|
// at the top rather than floating in the middle of an empty box — and
|
|
70
107
|
// once there are several lines the leading is wanted back, since here it
|
|
71
108
|
// separates the lines instead of pushing one off centre.
|
|
72
|
-
|
|
109
|
+
//
|
|
110
|
+
// The content follows the text to the top for the same reason: centred
|
|
111
|
+
// in a box that grows, an icon slides further from the line it belongs
|
|
112
|
+
// to with every line typed.
|
|
113
|
+
true: {
|
|
114
|
+
field: 'h-auto min-h-24 py-3 leading-normal',
|
|
115
|
+
startContent: 'bottom-auto py-3',
|
|
116
|
+
endContent: 'bottom-auto py-3',
|
|
117
|
+
},
|
|
73
118
|
},
|
|
74
119
|
disabled: {
|
|
75
|
-
true: {
|
|
120
|
+
true: {
|
|
121
|
+
field: 'opacity-[0.64]',
|
|
122
|
+
startContent: 'opacity-[0.64]',
|
|
123
|
+
endContent: 'opacity-[0.64]',
|
|
124
|
+
},
|
|
76
125
|
},
|
|
77
126
|
},
|
|
78
127
|
defaultVariants: {
|
|
@@ -95,6 +144,24 @@ export interface InputProps
|
|
|
95
144
|
/** Marks the field required — an asterisk on the label, and the a11y state. */
|
|
96
145
|
isRequired?: boolean;
|
|
97
146
|
disabled?: boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Content inside the field, before the text — usually an icon. Measured, so
|
|
149
|
+
* the text is padded clear of it however wide it is.
|
|
150
|
+
*
|
|
151
|
+
* `start`, not `left`: it follows the reading direction, and swaps sides
|
|
152
|
+
* under `Direction dir="rtl"` along with the text it introduces.
|
|
153
|
+
*/
|
|
154
|
+
startContent?: ReactNode;
|
|
155
|
+
/** Content inside the field, after the text — an icon, a unit, a count. */
|
|
156
|
+
endContent?: ReactNode;
|
|
157
|
+
/**
|
|
158
|
+
* Let touches reach the content instead of falling through to the field.
|
|
159
|
+
*
|
|
160
|
+
* Off by default: an icon is decoration, and a tap anywhere on a field
|
|
161
|
+
* should put the caret in it rather than hitting a dead spot. Turn it on for
|
|
162
|
+
* content that does something — a clear button, a show-password toggle.
|
|
163
|
+
*/
|
|
164
|
+
interactiveContent?: boolean;
|
|
98
165
|
/**
|
|
99
166
|
* Keep the field clear of the software keyboard. Moves by exactly the
|
|
100
167
|
* overlap, and not at all when the field is already clear — or when the
|
|
@@ -136,6 +203,9 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
136
203
|
errorMessage,
|
|
137
204
|
isRequired,
|
|
138
205
|
disabled,
|
|
206
|
+
startContent,
|
|
207
|
+
endContent,
|
|
208
|
+
interactiveContent = false,
|
|
139
209
|
variant,
|
|
140
210
|
size,
|
|
141
211
|
avoidKeyboard = false,
|
|
@@ -151,6 +221,10 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
151
221
|
) => {
|
|
152
222
|
const [focused, setFocused] = useState(false);
|
|
153
223
|
const invalid = !!errorMessage;
|
|
224
|
+
// Measured so the text can be padded clear of the content, whatever it
|
|
225
|
+
// turns out to be — an icon, a unit, a two-word label.
|
|
226
|
+
const [startWidth, setStartWidth] = useState(0);
|
|
227
|
+
const [endWidth, setEndWidth] = useState(0);
|
|
154
228
|
|
|
155
229
|
const placeholderColor = useCSSVariable('--color-muted-foreground');
|
|
156
230
|
const restColor = useCSSVariable('--color-input');
|
|
@@ -207,6 +281,49 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
207
281
|
[onBlur]
|
|
208
282
|
);
|
|
209
283
|
|
|
284
|
+
const handleStartLayout = useCallback((event: LayoutChangeEvent) => {
|
|
285
|
+
setStartWidth(event.nativeEvent.layout.width);
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
const handleEndLayout = useCallback((event: LayoutChangeEvent) => {
|
|
289
|
+
setEndWidth(event.nativeEvent.layout.width);
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
292
|
+
/*
|
|
293
|
+
* The content is quieter than the value. An icon at the same weight as the
|
|
294
|
+
* text reads as part of what was typed, and the placeholder colour is
|
|
295
|
+
* already the field's own word for "not the value".
|
|
296
|
+
*/
|
|
297
|
+
const iconColor = typeof placeholderColor === 'string' ? placeholderColor : undefined;
|
|
298
|
+
|
|
299
|
+
const field = (
|
|
300
|
+
<AnimatedTextInput
|
|
301
|
+
ref={ref}
|
|
302
|
+
editable={!disabled}
|
|
303
|
+
onFocus={handleFocus}
|
|
304
|
+
onBlur={handleBlur}
|
|
305
|
+
accessibilityLabel={label}
|
|
306
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
307
|
+
aria-required={isRequired}
|
|
308
|
+
aria-invalid={invalid}
|
|
309
|
+
className={slots.field({ className })}
|
|
310
|
+
style={[
|
|
311
|
+
borderStyle,
|
|
312
|
+
// Only the side that has content, and only once it has been
|
|
313
|
+
// measured — a 0 here would wipe out the field's own padding.
|
|
314
|
+
startWidth ? { paddingStart: startWidth } : null,
|
|
315
|
+
endWidth ? { paddingEnd: endWidth } : null,
|
|
316
|
+
// Caller styles come last so InputGroup can pad the field around its
|
|
317
|
+
// decorators, but never last enough to drop the animated border.
|
|
318
|
+
style,
|
|
319
|
+
]}
|
|
320
|
+
placeholderTextColor={
|
|
321
|
+
typeof placeholderColor === 'string' ? placeholderColor : undefined
|
|
322
|
+
}
|
|
323
|
+
{...props}
|
|
324
|
+
/>
|
|
325
|
+
);
|
|
326
|
+
|
|
210
327
|
const body = (
|
|
211
328
|
<>
|
|
212
329
|
{label ? (
|
|
@@ -214,24 +331,49 @@ export const Input = forwardRef<TextInput, InputProps>(
|
|
|
214
331
|
{label}
|
|
215
332
|
</Label>
|
|
216
333
|
) : null}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
334
|
+
{startContent || endContent ? (
|
|
335
|
+
/*
|
|
336
|
+
* Only wrapped when there is something to position. An extra view
|
|
337
|
+
* around every field in an app is a real cost in a long form, and
|
|
338
|
+
* the field is `w-full` either way so the wrapper changes nothing
|
|
339
|
+
* else about the layout.
|
|
340
|
+
*/
|
|
341
|
+
<View className={slots.fieldBox()}>
|
|
342
|
+
{startContent ? (
|
|
343
|
+
<View
|
|
344
|
+
onLayout={handleStartLayout}
|
|
345
|
+
pointerEvents={interactiveContent ? 'auto' : 'none'}
|
|
346
|
+
accessibilityElementsHidden={!interactiveContent}
|
|
347
|
+
importantForAccessibility={
|
|
348
|
+
interactiveContent ? 'auto' : 'no-hide-descendants'
|
|
349
|
+
}
|
|
350
|
+
className={slots.startContent()}
|
|
351
|
+
>
|
|
352
|
+
<IconColorProvider color={iconColor}>
|
|
353
|
+
{textChildren(startContent)}
|
|
354
|
+
</IconColorProvider>
|
|
355
|
+
</View>
|
|
356
|
+
) : null}
|
|
357
|
+
{field}
|
|
358
|
+
{endContent ? (
|
|
359
|
+
<View
|
|
360
|
+
onLayout={handleEndLayout}
|
|
361
|
+
pointerEvents={interactiveContent ? 'auto' : 'none'}
|
|
362
|
+
accessibilityElementsHidden={!interactiveContent}
|
|
363
|
+
importantForAccessibility={
|
|
364
|
+
interactiveContent ? 'auto' : 'no-hide-descendants'
|
|
365
|
+
}
|
|
366
|
+
className={slots.endContent()}
|
|
367
|
+
>
|
|
368
|
+
<IconColorProvider color={iconColor}>
|
|
369
|
+
{textChildren(endContent)}
|
|
370
|
+
</IconColorProvider>
|
|
371
|
+
</View>
|
|
372
|
+
) : null}
|
|
373
|
+
</View>
|
|
374
|
+
) : (
|
|
375
|
+
field
|
|
376
|
+
)}
|
|
235
377
|
{errorMessage ? (
|
|
236
378
|
<Text className={slots.error()}>{errorMessage}</Text>
|
|
237
379
|
) : description ? (
|
|
@@ -49,7 +49,10 @@ const otpVariants = tv({
|
|
|
49
49
|
container: 'gap-1.5',
|
|
50
50
|
row: 'flex-row items-center',
|
|
51
51
|
cell: 'items-center justify-center rounded-lg border border-input bg-background',
|
|
52
|
-
|
|
52
|
+
/* The typed character is drawn by an Animated.Text rather than the Text
|
|
53
|
+
primitive, so its weight class has to be written here for an app's
|
|
54
|
+
--font-normal token to reach it. */
|
|
55
|
+
char: 'font-normal text-foreground',
|
|
53
56
|
caret: 'w-0.5 rounded-full bg-foreground',
|
|
54
57
|
separator: 'h-0.5 rounded-full bg-border',
|
|
55
58
|
error: 'text-sm text-destructive',
|
|
@@ -740,7 +740,7 @@ function PanelsideSearch({
|
|
|
740
740
|
* text and the placeholder sit below the middle of the row. A length
|
|
741
741
|
* sets the size alone and leaves the line box the font's own.
|
|
742
742
|
*/
|
|
743
|
-
className={cn('h-full flex-1 text-[16px] text-foreground', className)}
|
|
743
|
+
className={cn('h-full flex-1 font-normal text-[16px] text-foreground', className)}
|
|
744
744
|
style={typeof textTint === 'string' ? { color: textTint } : undefined}
|
|
745
745
|
accessibilityRole="search"
|
|
746
746
|
returnKeyType="search"
|
|
@@ -66,7 +66,9 @@ const textareaVariants = tv({
|
|
|
66
66
|
* the leading is wanted — between several lines it separates them, where in
|
|
67
67
|
* a one-line field it would push the text off centre.
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
/* `font-normal` so an app's --font-normal token reaches a TextInput,
|
|
70
|
+
which inherits nothing from the Text primitive. */
|
|
71
|
+
field: 'w-full rounded-lg border font-normal text-foreground',
|
|
70
72
|
footer: 'w-full flex-row items-start justify-between gap-3',
|
|
71
73
|
description: 'text-sm text-muted-foreground',
|
|
72
74
|
error: 'text-sm text-destructive',
|