panelui-native 0.14.0 → 0.19.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/README.md +2 -0
- package/lib/module/components/bottom-sheet/index.js +38 -10
- package/lib/module/components/bottom-sheet/index.js.map +1 -1
- package/lib/module/components/checkbox/index.js +24 -11
- package/lib/module/components/checkbox/index.js.map +1 -1
- package/lib/module/components/direction/index.js +86 -0
- package/lib/module/components/direction/index.js.map +1 -0
- package/lib/module/components/field/index.js +337 -0
- package/lib/module/components/field/index.js.map +1 -0
- package/lib/module/components/flow/flow-paths.js +378 -0
- package/lib/module/components/flow/flow-paths.js.map +1 -0
- package/lib/module/components/flow/index.js +1598 -0
- package/lib/module/components/flow/index.js.map +1 -0
- package/lib/module/components/form/index.js +77 -0
- package/lib/module/components/form/index.js.map +1 -0
- package/lib/module/components/form/use-field.js +50 -0
- package/lib/module/components/form/use-field.js.map +1 -0
- package/lib/module/components/form/use-form.js +217 -0
- package/lib/module/components/form/use-form.js.map +1 -0
- package/lib/module/components/frame/index.js +124 -22
- package/lib/module/components/frame/index.js.map +1 -1
- package/lib/module/components/message/index.js +16 -1
- package/lib/module/components/message/index.js.map +1 -1
- package/lib/module/components/otp-input/index.js +303 -0
- package/lib/module/components/otp-input/index.js.map +1 -0
- package/lib/module/components/signature/index.js +500 -0
- package/lib/module/components/signature/index.js.map +1 -0
- package/lib/module/components/soundwave/index.js +813 -0
- package/lib/module/components/soundwave/index.js.map +1 -0
- package/lib/module/components/typography/index.js +196 -5
- package/lib/module/components/typography/index.js.map +1 -1
- package/lib/module/icons/index.js +287 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
- package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
- package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
- package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/direction/index.d.ts +64 -0
- package/lib/typescript/src/components/direction/index.d.ts.map +1 -0
- package/lib/typescript/src/components/field/index.d.ts +94 -0
- package/lib/typescript/src/components/field/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
- package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +310 -0
- package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/index.d.ts +64 -0
- package/lib/typescript/src/components/form/index.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-field.d.ts +16 -0
- package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-form.d.ts +37 -0
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
- package/lib/typescript/src/components/frame/index.d.ts +39 -2
- package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message/index.d.ts.map +1 -1
- package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
- package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
- package/lib/typescript/src/components/signature/index.d.ts +254 -0
- package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
- package/lib/typescript/src/components/soundwave/index.d.ts +91 -0
- package/lib/typescript/src/components/soundwave/index.d.ts.map +1 -0
- package/lib/typescript/src/components/typography/index.d.ts +154 -1
- package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
- package/lib/typescript/src/icons/index.d.ts +23 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +9 -2
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/components/bottom-sheet/index.tsx +50 -12
- package/src/components/checkbox/index.tsx +40 -12
- package/src/components/direction/index.tsx +90 -0
- package/src/components/field/index.tsx +329 -0
- package/src/components/flow/flow-paths.ts +348 -0
- package/src/components/flow/index.tsx +1924 -0
- package/src/components/form/index.tsx +85 -0
- package/src/components/form/use-field.ts +65 -0
- package/src/components/form/use-form.ts +252 -0
- package/src/components/frame/index.tsx +162 -14
- package/src/components/message/index.tsx +17 -1
- package/src/components/otp-input/index.tsx +396 -0
- package/src/components/signature/index.tsx +681 -0
- package/src/components/soundwave/index.tsx +1029 -0
- package/src/components/typography/index.tsx +231 -10
- package/src/icons/index.tsx +195 -0
- package/src/index.ts +87 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form — binds a `useForm` instance to its fields without threading `form`
|
|
3
|
+
* through every one of them by hand.
|
|
4
|
+
*
|
|
5
|
+
* `Form.Field` is a render prop, not a component that clones its child, for
|
|
6
|
+
* the same reason `useField` is a hook and not a wrapper: PanelUI's controls
|
|
7
|
+
* take differently-shaped change props, and only the caller knows which one
|
|
8
|
+
* a given field needs to wire up.
|
|
9
|
+
*
|
|
10
|
+
* `Form`/`Form.Field` trade some type precision for that JSX ergonomics — a
|
|
11
|
+
* field's `name` isn't checked against `keyof YourValues` here, because a
|
|
12
|
+
* value's shape can't flow through `useContext` generically. Reach for
|
|
13
|
+
* `useField(form, name)` directly when a field's name should be checked
|
|
14
|
+
* against the form's own value type.
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* const form = useForm({
|
|
18
|
+
* defaultValues: { email: '' },
|
|
19
|
+
* onSubmit: async (values) => { ... },
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* <Form form={form}>
|
|
23
|
+
* <Form.Field name="email" validate={(value) => (value ? undefined : 'Required')}>
|
|
24
|
+
* {(field) => (
|
|
25
|
+
* <Input
|
|
26
|
+
* label="Email"
|
|
27
|
+
* value={field.value}
|
|
28
|
+
* onChangeText={field.onChange}
|
|
29
|
+
* onBlur={field.onBlur}
|
|
30
|
+
* errorMessage={field.error}
|
|
31
|
+
* />
|
|
32
|
+
* )}
|
|
33
|
+
* </Form.Field>
|
|
34
|
+
* </Form>
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import { createContext, useContext, type ReactNode } from 'react';
|
|
38
|
+
import { useField, type FormFieldRenderProps, type UseFieldOptions } from './use-field';
|
|
39
|
+
import { useForm, type FormApi, type FieldErrors, type FieldTouched, type FieldState, type UseFormOptions, type Validator } from './use-form';
|
|
40
|
+
|
|
41
|
+
export { useForm, useField };
|
|
42
|
+
export type {
|
|
43
|
+
FormApi,
|
|
44
|
+
FieldErrors,
|
|
45
|
+
FieldTouched,
|
|
46
|
+
FieldState,
|
|
47
|
+
UseFormOptions,
|
|
48
|
+
Validator,
|
|
49
|
+
FormFieldRenderProps,
|
|
50
|
+
UseFieldOptions,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const FormContext = createContext<FormApi<any> | null>(null);
|
|
54
|
+
|
|
55
|
+
export interface FormProps {
|
|
56
|
+
form: FormApi<any>;
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function FormRoot({ form, children }: FormProps) {
|
|
61
|
+
return <FormContext.Provider value={form}>{children}</FormContext.Provider>;
|
|
62
|
+
}
|
|
63
|
+
FormRoot.displayName = 'Form';
|
|
64
|
+
|
|
65
|
+
export interface FormFieldProps {
|
|
66
|
+
name: string;
|
|
67
|
+
validate?: (value: any, values: any) => string | undefined | Promise<string | undefined>;
|
|
68
|
+
/** Runs on blur, and always on submit. `'change'` also validates on every edit. */
|
|
69
|
+
validateOn?: 'blur' | 'change';
|
|
70
|
+
children: (field: FormFieldRenderProps<any>) => ReactNode;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function FormField({ name, validate, validateOn, children }: FormFieldProps) {
|
|
74
|
+
const form = useContext(FormContext);
|
|
75
|
+
if (!form) {
|
|
76
|
+
throw new Error('Form.Field must be rendered inside a <Form>.');
|
|
77
|
+
}
|
|
78
|
+
const field = useField(form, name, { validate, validateOn });
|
|
79
|
+
return <>{children(field)}</>;
|
|
80
|
+
}
|
|
81
|
+
FormField.displayName = 'Form.Field';
|
|
82
|
+
|
|
83
|
+
export const Form = Object.assign(FormRoot, {
|
|
84
|
+
Field: FormField,
|
|
85
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useField — binds one `useForm` field to a control's own props.
|
|
3
|
+
*
|
|
4
|
+
* A hook rather than a component that clones its child, because PanelUI's
|
|
5
|
+
* controls each take a differently-shaped change prop — `onChangeText` on
|
|
6
|
+
* `Input`, `onCheckedChange` on `Checkbox`, `onValueChange` on `Switch` and
|
|
7
|
+
* `RadioGroup` — so the caller is the one who knows which to wire `onChange`
|
|
8
|
+
* to. `Form.Field` is a thin JSX wrapper around this same hook, for callers
|
|
9
|
+
* who prefer a render prop to calling a hook per field.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useRef } from 'react';
|
|
12
|
+
import type { FormApi, Validator } from './use-form';
|
|
13
|
+
|
|
14
|
+
export interface FormFieldRenderProps<V> {
|
|
15
|
+
value: V;
|
|
16
|
+
/** Only set once the field has been touched (blurred, or the form submitted). */
|
|
17
|
+
error?: string;
|
|
18
|
+
touched: boolean;
|
|
19
|
+
onChange: (value: V) => void;
|
|
20
|
+
onBlur: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface UseFieldOptions<T extends Record<string, any>, K extends keyof T> {
|
|
24
|
+
validate?: Validator<T, K>;
|
|
25
|
+
/** Runs on blur, and always on submit. `'change'` also validates on every edit. */
|
|
26
|
+
validateOn?: 'blur' | 'change';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useField<T extends Record<string, any>, K extends keyof T>(
|
|
30
|
+
form: FormApi<T>,
|
|
31
|
+
name: K,
|
|
32
|
+
{ validate, validateOn = 'blur' }: UseFieldOptions<T, K> = {}
|
|
33
|
+
): FormFieldRenderProps<T[K]> {
|
|
34
|
+
// Kept fresh every render without retriggering the registration effect —
|
|
35
|
+
// an inline validator function has a new identity on every render, and
|
|
36
|
+
// re-registering on every one of those would be wasted work.
|
|
37
|
+
const validateRef = useRef(validate);
|
|
38
|
+
validateRef.current = validate;
|
|
39
|
+
|
|
40
|
+
const { registerValidator } = form;
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
registerValidator(
|
|
43
|
+
name,
|
|
44
|
+
validate ? (value, values) => validateRef.current!(value, values) : undefined
|
|
45
|
+
);
|
|
46
|
+
return () => registerValidator(name, undefined);
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [registerValidator, name, !!validate]);
|
|
49
|
+
|
|
50
|
+
const { value, error, touched } = form.getFieldState(name);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
value,
|
|
54
|
+
error: touched ? error : undefined,
|
|
55
|
+
touched,
|
|
56
|
+
onChange: (next: T[K]) => {
|
|
57
|
+
form.setFieldValue(name, next);
|
|
58
|
+
if (validateOn === 'change') form.validateField(name);
|
|
59
|
+
},
|
|
60
|
+
onBlur: () => {
|
|
61
|
+
form.setFieldTouched(name, true);
|
|
62
|
+
if (validateOn === 'blur') form.validateField(name);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useForm — form state for React Native, with no form library underneath.
|
|
3
|
+
*
|
|
4
|
+
* Values, errors and touched state live in one `useReducer` so a single
|
|
5
|
+
* dispatch can update more than one of them atomically — setting a value
|
|
6
|
+
* also clears that field's error in the same update, so a field being fixed
|
|
7
|
+
* never flashes invalid for one extra frame while the two states catch up
|
|
8
|
+
* with each other.
|
|
9
|
+
*
|
|
10
|
+
* Per-field validators are not passed in here. They register themselves —
|
|
11
|
+
* `useField`/`Form.Field` do this — because the field that owns the
|
|
12
|
+
* validation rule is usually rendered far from the `useForm` call, and
|
|
13
|
+
* threading every field's validator up to one options object would mean
|
|
14
|
+
* `useForm`'s call site has to know about every field in the tree. What is
|
|
15
|
+
* passed in here (`validate`) is whole-form validation: cross-field rules
|
|
16
|
+
* like "confirm password must match password".
|
|
17
|
+
*
|
|
18
|
+
* Only flat, JSON-serializable values are supported — string, number,
|
|
19
|
+
* boolean, and plain arrays/objects of those. There is no field-array helper
|
|
20
|
+
* (`useFieldArray`) in this version; model a repeating group with your own
|
|
21
|
+
* `useState` array of ids and one `useForm` per row, or key a field's name
|
|
22
|
+
* with an index (`"emails.0"`) and treat it as an opaque string.
|
|
23
|
+
*
|
|
24
|
+
* `defaultValues` is read once, on the first render. If default values
|
|
25
|
+
* arrive asynchronously, mount the form once you have them (e.g. behind a
|
|
26
|
+
* loading check) rather than expecting the hook to pick up a later change.
|
|
27
|
+
*/
|
|
28
|
+
import { useCallback, useMemo, useReducer, useRef } from 'react';
|
|
29
|
+
|
|
30
|
+
export type FieldErrors<T> = Partial<Record<keyof T, string>>;
|
|
31
|
+
export type FieldTouched<T> = Partial<Record<keyof T, boolean>>;
|
|
32
|
+
|
|
33
|
+
export type Validator<T extends Record<string, any>, K extends keyof T> = (
|
|
34
|
+
value: T[K],
|
|
35
|
+
values: T
|
|
36
|
+
) => string | undefined | Promise<string | undefined>;
|
|
37
|
+
|
|
38
|
+
export interface FieldState<V> {
|
|
39
|
+
value: V;
|
|
40
|
+
error?: string;
|
|
41
|
+
touched: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface UseFormOptions<T extends Record<string, any>> {
|
|
45
|
+
defaultValues: T;
|
|
46
|
+
/** Whole-form validation, e.g. a rule that compares two fields. Runs on submit. */
|
|
47
|
+
validate?: (values: T) => FieldErrors<T> | Promise<FieldErrors<T>>;
|
|
48
|
+
onSubmit: (values: T) => void | Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface FormApi<T extends Record<string, any>> {
|
|
52
|
+
values: T;
|
|
53
|
+
errors: FieldErrors<T>;
|
|
54
|
+
touched: FieldTouched<T>;
|
|
55
|
+
isSubmitting: boolean;
|
|
56
|
+
/** No field currently carries a validation error. Fields validate on blur and
|
|
57
|
+
* submit, so this does not guarantee an untouched field would pass. */
|
|
58
|
+
isValid: boolean;
|
|
59
|
+
/** `values` differs from `defaultValues`, compared as JSON. */
|
|
60
|
+
isDirty: boolean;
|
|
61
|
+
setFieldValue: <K extends keyof T>(name: K, value: T[K]) => void;
|
|
62
|
+
setFieldTouched: <K extends keyof T>(name: K, touched?: boolean) => void;
|
|
63
|
+
setFieldError: <K extends keyof T>(name: K, error: string | undefined) => void;
|
|
64
|
+
getFieldState: <K extends keyof T>(name: K) => FieldState<T[K]>;
|
|
65
|
+
/** Used by `useField`/`Form.Field` to attach a field's validation rule. */
|
|
66
|
+
registerValidator: <K extends keyof T>(name: K, validator?: Validator<T, K>) => void;
|
|
67
|
+
/** Runs `name`'s registered validator (if any) and records the result. */
|
|
68
|
+
validateField: <K extends keyof T>(name: K) => Promise<boolean>;
|
|
69
|
+
handleSubmit: () => Promise<void>;
|
|
70
|
+
reset: (values?: T) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface State<T> {
|
|
74
|
+
values: T;
|
|
75
|
+
errors: FieldErrors<T>;
|
|
76
|
+
touched: FieldTouched<T>;
|
|
77
|
+
isSubmitting: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type Action<T> =
|
|
81
|
+
| { type: 'SET_VALUE'; name: keyof T; value: unknown }
|
|
82
|
+
| { type: 'SET_TOUCHED'; name: keyof T; touched: boolean }
|
|
83
|
+
| { type: 'SET_ERROR'; name: keyof T; error: string | undefined }
|
|
84
|
+
| { type: 'SET_ERRORS'; errors: FieldErrors<T> }
|
|
85
|
+
| { type: 'TOUCH_ALL'; names: (keyof T)[] }
|
|
86
|
+
| { type: 'SUBMIT_START' }
|
|
87
|
+
| { type: 'SUBMIT_END' }
|
|
88
|
+
| { type: 'RESET'; values: T };
|
|
89
|
+
|
|
90
|
+
function reducer<T extends Record<string, any>>(
|
|
91
|
+
state: State<T>,
|
|
92
|
+
action: Action<T>
|
|
93
|
+
): State<T> {
|
|
94
|
+
switch (action.type) {
|
|
95
|
+
case 'SET_VALUE':
|
|
96
|
+
return {
|
|
97
|
+
...state,
|
|
98
|
+
values: { ...state.values, [action.name]: action.value } as T,
|
|
99
|
+
errors: { ...state.errors, [action.name]: undefined },
|
|
100
|
+
};
|
|
101
|
+
case 'SET_TOUCHED':
|
|
102
|
+
return {
|
|
103
|
+
...state,
|
|
104
|
+
touched: { ...state.touched, [action.name]: action.touched },
|
|
105
|
+
};
|
|
106
|
+
case 'SET_ERROR':
|
|
107
|
+
return { ...state, errors: { ...state.errors, [action.name]: action.error } };
|
|
108
|
+
case 'SET_ERRORS':
|
|
109
|
+
return { ...state, errors: { ...state.errors, ...action.errors } };
|
|
110
|
+
case 'TOUCH_ALL': {
|
|
111
|
+
const touched = { ...state.touched };
|
|
112
|
+
for (const name of action.names) touched[name] = true;
|
|
113
|
+
return { ...state, touched };
|
|
114
|
+
}
|
|
115
|
+
case 'SUBMIT_START':
|
|
116
|
+
return { ...state, isSubmitting: true };
|
|
117
|
+
case 'SUBMIT_END':
|
|
118
|
+
return { ...state, isSubmitting: false };
|
|
119
|
+
case 'RESET':
|
|
120
|
+
return { values: action.values, errors: {}, touched: {}, isSubmitting: false };
|
|
121
|
+
default:
|
|
122
|
+
return state;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function useForm<T extends Record<string, any>>({
|
|
127
|
+
defaultValues,
|
|
128
|
+
validate,
|
|
129
|
+
onSubmit,
|
|
130
|
+
}: UseFormOptions<T>): FormApi<T> {
|
|
131
|
+
const defaultsRef = useRef(defaultValues);
|
|
132
|
+
const validatorsRef = useRef<
|
|
133
|
+
Partial<Record<keyof T, (value: any, values: T) => string | undefined | Promise<string | undefined>>>
|
|
134
|
+
>({});
|
|
135
|
+
|
|
136
|
+
const [state, dispatch] = useReducer(reducer<T>, {
|
|
137
|
+
values: defaultValues,
|
|
138
|
+
errors: {},
|
|
139
|
+
touched: {},
|
|
140
|
+
isSubmitting: false,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const setFieldValue = useCallback(<K extends keyof T>(name: K, value: T[K]) => {
|
|
144
|
+
dispatch({ type: 'SET_VALUE', name, value });
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const setFieldTouched = useCallback(<K extends keyof T>(name: K, touched = true) => {
|
|
148
|
+
dispatch({ type: 'SET_TOUCHED', name, touched });
|
|
149
|
+
}, []);
|
|
150
|
+
|
|
151
|
+
const setFieldError = useCallback(
|
|
152
|
+
<K extends keyof T>(name: K, error: string | undefined) => {
|
|
153
|
+
dispatch({ type: 'SET_ERROR', name, error });
|
|
154
|
+
},
|
|
155
|
+
[]
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const registerValidator = useCallback(
|
|
159
|
+
<K extends keyof T>(name: K, validator?: Validator<T, K>) => {
|
|
160
|
+
if (validator) {
|
|
161
|
+
validatorsRef.current[name] = validator;
|
|
162
|
+
} else {
|
|
163
|
+
delete validatorsRef.current[name];
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[]
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const getFieldState = useCallback(
|
|
170
|
+
<K extends keyof T>(name: K): FieldState<T[K]> => ({
|
|
171
|
+
value: state.values[name],
|
|
172
|
+
error: state.errors[name],
|
|
173
|
+
touched: !!state.touched[name],
|
|
174
|
+
}),
|
|
175
|
+
[state.values, state.errors, state.touched]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const validateField = useCallback(
|
|
179
|
+
async <K extends keyof T>(name: K) => {
|
|
180
|
+
const validator = validatorsRef.current[name];
|
|
181
|
+
if (!validator) return true;
|
|
182
|
+
const error = await validator(state.values[name], state.values);
|
|
183
|
+
dispatch({ type: 'SET_ERROR', name, error });
|
|
184
|
+
return !error;
|
|
185
|
+
},
|
|
186
|
+
[state.values]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const handleSubmit = useCallback(async () => {
|
|
190
|
+
const names = Object.keys(state.values) as (keyof T)[];
|
|
191
|
+
dispatch({ type: 'TOUCH_ALL', names });
|
|
192
|
+
|
|
193
|
+
const fieldErrorEntries = await Promise.all(
|
|
194
|
+
names.map(async (name) => {
|
|
195
|
+
const validator = validatorsRef.current[name];
|
|
196
|
+
const error = validator
|
|
197
|
+
? await validator(state.values[name], state.values)
|
|
198
|
+
: undefined;
|
|
199
|
+
return [name, error] as const;
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const formErrors = validate ? await validate(state.values) : {};
|
|
204
|
+
|
|
205
|
+
const cleared = Object.fromEntries(names.map((name) => [name, undefined]));
|
|
206
|
+
const nextErrors: FieldErrors<T> = {
|
|
207
|
+
...cleared,
|
|
208
|
+
...Object.fromEntries(fieldErrorEntries.filter(([, error]) => error !== undefined)),
|
|
209
|
+
...formErrors,
|
|
210
|
+
};
|
|
211
|
+
dispatch({ type: 'SET_ERRORS', errors: nextErrors });
|
|
212
|
+
|
|
213
|
+
if (Object.values(nextErrors).some(Boolean)) return;
|
|
214
|
+
|
|
215
|
+
dispatch({ type: 'SUBMIT_START' });
|
|
216
|
+
try {
|
|
217
|
+
await onSubmit(state.values);
|
|
218
|
+
} finally {
|
|
219
|
+
dispatch({ type: 'SUBMIT_END' });
|
|
220
|
+
}
|
|
221
|
+
}, [state.values, validate, onSubmit]);
|
|
222
|
+
|
|
223
|
+
const reset = useCallback((values: T = defaultsRef.current) => {
|
|
224
|
+
dispatch({ type: 'RESET', values });
|
|
225
|
+
}, []);
|
|
226
|
+
|
|
227
|
+
const isDirty = useMemo(
|
|
228
|
+
() => JSON.stringify(state.values) !== JSON.stringify(defaultsRef.current),
|
|
229
|
+
[state.values]
|
|
230
|
+
);
|
|
231
|
+
const isValid = useMemo(
|
|
232
|
+
() => Object.values(state.errors).every((error) => !error),
|
|
233
|
+
[state.errors]
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
values: state.values,
|
|
238
|
+
errors: state.errors,
|
|
239
|
+
touched: state.touched,
|
|
240
|
+
isSubmitting: state.isSubmitting,
|
|
241
|
+
isValid,
|
|
242
|
+
isDirty,
|
|
243
|
+
setFieldValue,
|
|
244
|
+
setFieldTouched,
|
|
245
|
+
setFieldError,
|
|
246
|
+
getFieldState,
|
|
247
|
+
registerValidator,
|
|
248
|
+
validateField,
|
|
249
|
+
handleSubmit,
|
|
250
|
+
reset,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
@@ -21,8 +21,14 @@
|
|
|
21
21
|
* <Frame.Action>All agents under 25% token limit</Frame.Action>
|
|
22
22
|
* </Frame.Header>
|
|
23
23
|
* <Frame.Panel>
|
|
24
|
-
* <Frame.Row
|
|
25
|
-
*
|
|
24
|
+
* <Frame.Row>
|
|
25
|
+
* <Frame.Media><PackageIcon /></Frame.Media>
|
|
26
|
+
* <Frame.Content>
|
|
27
|
+
* <Frame.Title>opus-4.6</Frame.Title>
|
|
28
|
+
* <Frame.Description>Indexing the repository</Frame.Description>
|
|
29
|
+
* </Frame.Content>
|
|
30
|
+
* <Frame.Actions><Chip>Running</Chip></Frame.Actions>
|
|
31
|
+
* </Frame.Row>
|
|
26
32
|
* </Frame.Panel>
|
|
27
33
|
* </Frame>
|
|
28
34
|
* ```
|
|
@@ -30,8 +36,24 @@
|
|
|
30
36
|
* The panel draws the hairlines between its own rows. React Native has no
|
|
31
37
|
* `:first-child`, so the alternative is every caller writing
|
|
32
38
|
* `divided={index > 0}` on every row and getting it wrong once.
|
|
39
|
+
*
|
|
40
|
+
* A row is three slots, and they exist because of one React Native detail:
|
|
41
|
+
* Yoga defaults `flexShrink` to `0`, the opposite of the web. A child that is
|
|
42
|
+
* not told to shrink never does, so a fourth thing in a row pushes the others
|
|
43
|
+
* past the edge — where the frame's `overflow-hidden` silently cuts them off
|
|
44
|
+
* rather than wrapping or truncating. `Frame.Media` and `Frame.Actions` hold
|
|
45
|
+
* their size, `Frame.Content` takes what is left and is allowed to shrink to
|
|
46
|
+
* nothing, and the row fits at any width without the caller measuring anything.
|
|
33
47
|
*/
|
|
34
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
Children,
|
|
50
|
+
cloneElement,
|
|
51
|
+
createContext,
|
|
52
|
+
forwardRef,
|
|
53
|
+
isValidElement,
|
|
54
|
+
useContext,
|
|
55
|
+
type ReactNode,
|
|
56
|
+
} from 'react';
|
|
35
57
|
import {
|
|
36
58
|
Pressable,
|
|
37
59
|
View,
|
|
@@ -74,6 +96,14 @@ const frameVariants = tv({
|
|
|
74
96
|
|
|
75
97
|
export type FrameVariant = 'default' | 'plain';
|
|
76
98
|
|
|
99
|
+
/**
|
|
100
|
+
* True inside a `Frame.Content`. The header's caption and a row's title are the
|
|
101
|
+
* same part in two places and only the placement decides the weight: a header
|
|
102
|
+
* labels the tray the card sits in, so it stays quiet, while a row title is the
|
|
103
|
+
* row's subject and has to carry it.
|
|
104
|
+
*/
|
|
105
|
+
const FrameSlotContext = createContext(false);
|
|
106
|
+
|
|
77
107
|
export interface FrameProps extends ViewProps {
|
|
78
108
|
className?: string;
|
|
79
109
|
}
|
|
@@ -107,6 +137,8 @@ const FrameHeader = forwardRef<View, FrameHeaderProps>(
|
|
|
107
137
|
<View
|
|
108
138
|
ref={ref}
|
|
109
139
|
className={cn(
|
|
140
|
+
// `min-w-0` on nothing here — the title itself takes the flexible side,
|
|
141
|
+
// so a long one truncates instead of shoving the action off the edge.
|
|
110
142
|
'flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',
|
|
111
143
|
className
|
|
112
144
|
)}
|
|
@@ -117,12 +149,31 @@ const FrameHeader = forwardRef<View, FrameHeaderProps>(
|
|
|
117
149
|
FrameHeader.displayName = 'Frame.Header';
|
|
118
150
|
|
|
119
151
|
/**
|
|
120
|
-
* Muted
|
|
121
|
-
* a
|
|
152
|
+
* Muted in a header, where it is a caption on the tray the card sits in. Inside
|
|
153
|
+
* a `Frame.Content` it is the row's subject instead, so it takes the foreground
|
|
154
|
+
* colour and medium weight, and truncates to one line rather than pushing the
|
|
155
|
+
* row's trailing slot out of view. Pass `numberOfLines` to override either way.
|
|
122
156
|
*/
|
|
123
|
-
const FrameTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) =>
|
|
124
|
-
|
|
125
|
-
|
|
157
|
+
const FrameTitle = forwardRef<RNText, TextProps>(({ className, ...props }, ref) => {
|
|
158
|
+
const inRow = useContext(FrameSlotContext);
|
|
159
|
+
|
|
160
|
+
if (inRow) {
|
|
161
|
+
return (
|
|
162
|
+
<Text
|
|
163
|
+
ref={ref}
|
|
164
|
+
size="sm"
|
|
165
|
+
weight="medium"
|
|
166
|
+
numberOfLines={1}
|
|
167
|
+
className={className}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Text ref={ref} size="sm" muted className={cn('min-w-0 shrink', className)} {...props} />
|
|
175
|
+
);
|
|
176
|
+
});
|
|
126
177
|
FrameTitle.displayName = 'Frame.Title';
|
|
127
178
|
|
|
128
179
|
export interface FrameActionProps extends FrameProps {
|
|
@@ -135,7 +186,11 @@ export interface FrameActionProps extends FrameProps {
|
|
|
135
186
|
*/
|
|
136
187
|
const FrameAction = forwardRef<View, FrameActionProps>(
|
|
137
188
|
({ className, children, ...props }, ref) => (
|
|
138
|
-
<View
|
|
189
|
+
<View
|
|
190
|
+
ref={ref}
|
|
191
|
+
className={cn('shrink-0 flex-row items-center gap-2', className)}
|
|
192
|
+
{...props}
|
|
193
|
+
>
|
|
139
194
|
{typeof children === 'string' ? (
|
|
140
195
|
<Text size="sm" muted>
|
|
141
196
|
{children}
|
|
@@ -148,13 +203,87 @@ const FrameAction = forwardRef<View, FrameActionProps>(
|
|
|
148
203
|
);
|
|
149
204
|
FrameAction.displayName = 'Frame.Action';
|
|
150
205
|
|
|
151
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Secondary line under the title — in a column-wrapped header, or under a row's
|
|
208
|
+
* title inside `Frame.Content`, where it drops a size and wraps to two lines.
|
|
209
|
+
*/
|
|
152
210
|
const FrameDescription = forwardRef<RNText, TextProps>(
|
|
211
|
+
({ className, ...props }, ref) => {
|
|
212
|
+
const inRow = useContext(FrameSlotContext);
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<Text
|
|
216
|
+
ref={ref}
|
|
217
|
+
size={inRow ? 'xs' : 'sm'}
|
|
218
|
+
muted
|
|
219
|
+
numberOfLines={inRow ? 2 : undefined}
|
|
220
|
+
className={className}
|
|
221
|
+
{...props}
|
|
222
|
+
/>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
FrameDescription.displayName = 'Frame.Description';
|
|
227
|
+
|
|
228
|
+
export interface FrameMediaProps extends FrameProps {
|
|
229
|
+
children?: ReactNode;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Leading slot on a row — an icon, an avatar, a status dot. Holds its size, so
|
|
234
|
+
* whatever it holds is never squeezed by the text beside it.
|
|
235
|
+
*/
|
|
236
|
+
const FrameMedia = forwardRef<View, FrameMediaProps>(
|
|
153
237
|
({ className, ...props }, ref) => (
|
|
154
|
-
<
|
|
238
|
+
<View
|
|
239
|
+
ref={ref}
|
|
240
|
+
className={cn('shrink-0 items-center justify-center', className)}
|
|
241
|
+
{...props}
|
|
242
|
+
/>
|
|
155
243
|
)
|
|
156
244
|
);
|
|
157
|
-
|
|
245
|
+
FrameMedia.displayName = 'Frame.Media';
|
|
246
|
+
|
|
247
|
+
export interface FrameContentProps extends FrameProps {
|
|
248
|
+
children?: ReactNode;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The flexible middle of a row — usually a `Frame.Title` over a
|
|
253
|
+
* `Frame.Description`. It takes whatever the media and actions leave and is
|
|
254
|
+
* allowed to shrink past its content, which is what keeps a long line from
|
|
255
|
+
* pushing the rest of the row off the edge.
|
|
256
|
+
*/
|
|
257
|
+
const FrameContent = forwardRef<View, FrameContentProps>(
|
|
258
|
+
({ className, children, ...props }, ref) => (
|
|
259
|
+
// `min-w-0` is the whole trick: a flex child's minimum size is its content
|
|
260
|
+
// unless told otherwise, so `flex-1` alone still refuses to go narrower
|
|
261
|
+
// than the longest word in it.
|
|
262
|
+
<View ref={ref} className={cn('min-w-0 flex-1 gap-0.5', className)} {...props}>
|
|
263
|
+
<FrameSlotContext.Provider value>{children}</FrameSlotContext.Provider>
|
|
264
|
+
</View>
|
|
265
|
+
)
|
|
266
|
+
);
|
|
267
|
+
FrameContent.displayName = 'Frame.Content';
|
|
268
|
+
|
|
269
|
+
export interface FrameActionsProps extends FrameProps {
|
|
270
|
+
children?: ReactNode;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Trailing slot on a row — a chip, a value, a switch, a small button. Holds its
|
|
275
|
+
* size, so it stays readable however long the content beside it runs.
|
|
276
|
+
*/
|
|
277
|
+
const FrameActions = forwardRef<View, FrameActionsProps>(
|
|
278
|
+
({ className, ...props }, ref) => (
|
|
279
|
+
<View
|
|
280
|
+
ref={ref}
|
|
281
|
+
className={cn('shrink-0 flex-row items-center gap-1.5', className)}
|
|
282
|
+
{...props}
|
|
283
|
+
/>
|
|
284
|
+
)
|
|
285
|
+
);
|
|
286
|
+
FrameActions.displayName = 'Frame.Actions';
|
|
158
287
|
|
|
159
288
|
/**
|
|
160
289
|
* Marks the parts that take part in the panel's own divider bookkeeping —
|
|
@@ -212,6 +341,17 @@ export interface FrameRowProps extends Omit<PressableProps, 'children'>, Dividab
|
|
|
212
341
|
divided?: boolean;
|
|
213
342
|
/** Trailing chevron marking the row as leading somewhere. */
|
|
214
343
|
chevron?: boolean;
|
|
344
|
+
/**
|
|
345
|
+
* Let the row run onto a second line instead of holding one. For a cluster
|
|
346
|
+
* of chips or tags, where the alternative is the last ones being clipped.
|
|
347
|
+
*/
|
|
348
|
+
wrap?: boolean;
|
|
349
|
+
/**
|
|
350
|
+
* Where the row's slots sit against each other. `start` for a row two or
|
|
351
|
+
* three lines tall, where centring an icon against a tall text column leaves
|
|
352
|
+
* it floating in the middle.
|
|
353
|
+
*/
|
|
354
|
+
align?: 'center' | 'start';
|
|
215
355
|
children?: ReactNode;
|
|
216
356
|
}
|
|
217
357
|
|
|
@@ -221,9 +361,14 @@ export interface FrameRowProps extends Omit<PressableProps, 'children'>, Dividab
|
|
|
221
361
|
* handler bolted on.
|
|
222
362
|
*/
|
|
223
363
|
const FrameRow = forwardRef<View, FrameRowProps>(
|
|
224
|
-
(
|
|
364
|
+
(
|
|
365
|
+
{ className, divided, chevron, wrap, align = 'center', children, onPress, ...props },
|
|
366
|
+
ref
|
|
367
|
+
) => {
|
|
225
368
|
const classes = cn(
|
|
226
|
-
'flex-row
|
|
369
|
+
'flex-row gap-3 px-4 py-3.5',
|
|
370
|
+
align === 'start' ? 'items-start' : 'items-center',
|
|
371
|
+
wrap && 'flex-wrap',
|
|
227
372
|
divided && 'border-t border-border',
|
|
228
373
|
onPress && 'active:bg-muted',
|
|
229
374
|
className
|
|
@@ -306,4 +451,7 @@ export const Frame = Object.assign(FrameRoot, {
|
|
|
306
451
|
Panel: FramePanel,
|
|
307
452
|
Section: FrameSection,
|
|
308
453
|
Row: FrameRow,
|
|
454
|
+
Media: FrameMedia,
|
|
455
|
+
Content: FrameContent,
|
|
456
|
+
Actions: FrameActions,
|
|
309
457
|
});
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
} from 'react';
|
|
32
32
|
import { View, type GestureResponderEvent, type ViewProps } from 'react-native';
|
|
33
33
|
import { tv, type VariantProps } from 'tailwind-variants';
|
|
34
|
+
import { useCSSVariable } from 'uniwind';
|
|
35
|
+
import { IconColorProvider } from '../../icons';
|
|
34
36
|
import { AnimatedPressable } from '../../primitives/animated-pressable';
|
|
35
37
|
import { Text, type TextProps } from '../../primitives/text';
|
|
36
38
|
import { cn } from '../../utils/cn';
|
|
@@ -257,9 +259,23 @@ const MessageBubble = forwardRef<View, MessageBubbleProps>(
|
|
|
257
259
|
const { align } = useMessageContext();
|
|
258
260
|
const { bubble } = messageVariants({ align });
|
|
259
261
|
|
|
262
|
+
/*
|
|
263
|
+
* A sent bubble is painted in the primary colour, and the theme's text
|
|
264
|
+
* colour is often that same colour — so anything inside it that resolves
|
|
265
|
+
* the foreground for itself draws black on black. Text has always been
|
|
266
|
+
* handled, through `Message.BubbleContent`; an icon or a waveform dropped
|
|
267
|
+
* into a bubble had no way to know. Publishing the bubble's own foreground
|
|
268
|
+
* here fixes all of them at once, the same way Button does for its label.
|
|
269
|
+
*/
|
|
270
|
+
const foreground = useCSSVariable(
|
|
271
|
+
align === 'end' ? '--color-primary-foreground' : '--color-foreground'
|
|
272
|
+
);
|
|
273
|
+
|
|
260
274
|
return (
|
|
261
275
|
<View ref={ref} className={bubble({ className })} {...props}>
|
|
262
|
-
{
|
|
276
|
+
<IconColorProvider color={typeof foreground === 'string' ? foreground : undefined}>
|
|
277
|
+
{children}
|
|
278
|
+
</IconColorProvider>
|
|
263
279
|
</View>
|
|
264
280
|
);
|
|
265
281
|
}
|