panelui-native 0.16.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.
Files changed (66) hide show
  1. package/lib/module/components/bottom-sheet/index.js +38 -10
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/checkbox/index.js +24 -11
  4. package/lib/module/components/checkbox/index.js.map +1 -1
  5. package/lib/module/components/field/index.js +337 -0
  6. package/lib/module/components/field/index.js.map +1 -0
  7. package/lib/module/components/flow/flow-paths.js +378 -0
  8. package/lib/module/components/flow/flow-paths.js.map +1 -0
  9. package/lib/module/components/flow/index.js +1598 -0
  10. package/lib/module/components/flow/index.js.map +1 -0
  11. package/lib/module/components/form/index.js +77 -0
  12. package/lib/module/components/form/index.js.map +1 -0
  13. package/lib/module/components/form/use-field.js +50 -0
  14. package/lib/module/components/form/use-field.js.map +1 -0
  15. package/lib/module/components/form/use-form.js +217 -0
  16. package/lib/module/components/form/use-form.js.map +1 -0
  17. package/lib/module/components/frame/index.js +124 -22
  18. package/lib/module/components/frame/index.js.map +1 -1
  19. package/lib/module/components/otp-input/index.js +303 -0
  20. package/lib/module/components/otp-input/index.js.map +1 -0
  21. package/lib/module/components/signature/index.js +500 -0
  22. package/lib/module/components/signature/index.js.map +1 -0
  23. package/lib/module/icons/index.js +218 -0
  24. package/lib/module/icons/index.js.map +1 -1
  25. package/lib/module/index.js +6 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/bottom-sheet/index.d.ts +15 -1
  28. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/checkbox/index.d.ts +8 -0
  30. package/lib/typescript/src/components/checkbox/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/field/index.d.ts +94 -0
  32. package/lib/typescript/src/components/field/index.d.ts.map +1 -0
  33. package/lib/typescript/src/components/flow/flow-paths.d.ts +89 -0
  34. package/lib/typescript/src/components/flow/flow-paths.d.ts.map +1 -0
  35. package/lib/typescript/src/components/flow/index.d.ts +310 -0
  36. package/lib/typescript/src/components/flow/index.d.ts.map +1 -0
  37. package/lib/typescript/src/components/form/index.d.ts +64 -0
  38. package/lib/typescript/src/components/form/index.d.ts.map +1 -0
  39. package/lib/typescript/src/components/form/use-field.d.ts +16 -0
  40. package/lib/typescript/src/components/form/use-field.d.ts.map +1 -0
  41. package/lib/typescript/src/components/form/use-form.d.ts +37 -0
  42. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -0
  43. package/lib/typescript/src/components/frame/index.d.ts +39 -2
  44. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  45. package/lib/typescript/src/components/otp-input/index.d.ts +141 -0
  46. package/lib/typescript/src/components/otp-input/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/signature/index.d.ts +254 -0
  48. package/lib/typescript/src/components/signature/index.d.ts.map +1 -0
  49. package/lib/typescript/src/icons/index.d.ts +17 -0
  50. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  51. package/lib/typescript/src/index.d.ts +7 -2
  52. package/lib/typescript/src/index.d.ts.map +1 -1
  53. package/package.json +9 -1
  54. package/src/components/bottom-sheet/index.tsx +50 -12
  55. package/src/components/checkbox/index.tsx +40 -12
  56. package/src/components/field/index.tsx +329 -0
  57. package/src/components/flow/flow-paths.ts +348 -0
  58. package/src/components/flow/index.tsx +1924 -0
  59. package/src/components/form/index.tsx +85 -0
  60. package/src/components/form/use-field.ts +65 -0
  61. package/src/components/form/use-form.ts +252 -0
  62. package/src/components/frame/index.tsx +162 -14
  63. package/src/components/otp-input/index.tsx +396 -0
  64. package/src/components/signature/index.tsx +681 -0
  65. package/src/icons/index.tsx +159 -0
  66. package/src/index.ts +72 -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>…</Frame.Row>
25
- * <Frame.Row>…</Frame.Row>
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 { Children, cloneElement, forwardRef, isValidElement, type ReactNode } from 'react';
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 by default. The header is a caption on the tray the card sits in, not
121
- * a heading over a section — the card's own rows carry the weight.
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
- <Text ref={ref} size="sm" muted className={className} {...props} />
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 ref={ref} className={cn('flex-row items-center gap-2', className)} {...props}>
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
- /** Secondary line under the title, inside a column-wrapped header. */
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
- <Text ref={ref} size="sm" muted className={className} {...props} />
238
+ <View
239
+ ref={ref}
240
+ className={cn('shrink-0 items-center justify-center', className)}
241
+ {...props}
242
+ />
155
243
  )
156
244
  );
157
- FrameDescription.displayName = 'Frame.Description';
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
- ({ className, divided, chevron, children, onPress, ...props }, ref) => {
364
+ (
365
+ { className, divided, chevron, wrap, align = 'center', children, onPress, ...props },
366
+ ref
367
+ ) => {
225
368
  const classes = cn(
226
- 'flex-row items-center gap-3 px-4 py-3.5',
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
  });