@lotics/ui 1.3.1 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -30,6 +30,7 @@
30
30
  "./menu_list_item": "./src/menu_list_item.tsx",
31
31
  "./pressable_highlight": "./src/pressable_highlight.tsx",
32
32
  "./icon_button": "./src/icon_button.tsx",
33
+ "./info_popover": "./src/info_popover.tsx",
33
34
  "./badge": "./src/badge.tsx",
34
35
  "./divider": "./src/divider.tsx",
35
36
  "./spacer": "./src/spacer.tsx",
@@ -83,6 +84,7 @@
83
84
  "default": "./src/wave_avatar.web.tsx"
84
85
  },
85
86
  "./use_async_fn": "./src/use_async_fn.ts",
87
+ "./use_form": "./src/use_form.ts",
86
88
  "./hover_action": "./src/hover_action.tsx",
87
89
  "./scroll_to_bottom": "./src/scroll_to_bottom.tsx",
88
90
  "./animation_fade_in": "./src/animation_fade_in.tsx",
@@ -13,6 +13,8 @@ export interface IconButtonProps {
13
13
  iconColor?: string;
14
14
  tooltip?: string;
15
15
  tooltipSide?: TooltipSide;
16
+ /** Accessible name for the button. Falls back to `tooltip` when omitted. */
17
+ accessibilityLabel?: string;
16
18
  onPress?: (event: GestureResponderEvent) => void;
17
19
  style?: StyleProp<ViewStyle>;
18
20
  disabled?: boolean;
@@ -28,6 +30,7 @@ export function IconButton(props: IconButtonProps) {
28
30
  color = "none",
29
31
  tooltip,
30
32
  tooltipSide,
33
+ accessibilityLabel,
31
34
  disabled,
32
35
  style,
33
36
  } = props;
@@ -45,6 +48,8 @@ export function IconButton(props: IconButtonProps) {
45
48
  testID={testID}
46
49
  tooltip={tooltip}
47
50
  tooltipSide={tooltipSide}
51
+ accessibilityRole="button"
52
+ accessibilityLabel={accessibilityLabel ?? tooltip}
48
53
  style={[styles.button, styles[color], disabled && styles.disabled, style]}
49
54
  onPress={handlePress}
50
55
  disabled={disabled}
@@ -0,0 +1,49 @@
1
+ import { StyleSheet } from "react-native";
2
+ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
3
+ import type { PopoverSide, PopoverAlign } from "./popover";
4
+ import { IconButton } from "./icon_button";
5
+ import { Text } from "./text";
6
+ import { colors } from "./colors";
7
+
8
+ export interface InfoPopoverProps {
9
+ /** Explanatory text shown when the popover opens. */
10
+ text: string;
11
+ /** Accessible name for the trigger button. */
12
+ accessibilityLabel?: string;
13
+ /** Popover placement relative to the trigger. */
14
+ side?: PopoverSide;
15
+ /** Popover alignment along the trigger edge. */
16
+ align?: PopoverAlign;
17
+ }
18
+
19
+ /**
20
+ * A "?" icon button that opens a popover explaining something — a metric, a
21
+ * field, a setting. The middle ground between Tooltip (a short hover label)
22
+ * and composing Popover directly (rich, interactive content): a labeled,
23
+ * keyboard-accessible help affordance for a sentence or two of text.
24
+ */
25
+ export function InfoPopover(props: InfoPopoverProps) {
26
+ const { text, accessibilityLabel = "More information", side = "bottom", align = "end" } = props;
27
+ return (
28
+ <Popover side={side} align={align}>
29
+ <PopoverTrigger>
30
+ <IconButton
31
+ icon="message-circle-question-mark"
32
+ iconColor={colors.zinc[500]}
33
+ accessibilityLabel={accessibilityLabel}
34
+ />
35
+ </PopoverTrigger>
36
+ <PopoverContent style={styles.content} disableBodyScroll>
37
+ <Text size="sm" color="muted">
38
+ {text}
39
+ </Text>
40
+ </PopoverContent>
41
+ </Popover>
42
+ );
43
+ }
44
+
45
+ const styles = StyleSheet.create({
46
+ content: {
47
+ width: 280,
48
+ },
49
+ });
@@ -0,0 +1,285 @@
1
+ import { useCallback, useMemo, useReducer, useRef } from "react";
2
+
3
+ export interface UseFormProps<T> {
4
+ initialValues: T;
5
+ validate?: (
6
+ values: T,
7
+ ) =>
8
+ | FormErrors<T>
9
+ | Promise<FormErrors<T>>
10
+ | undefined
11
+ | Promise<undefined>
12
+ | void
13
+ | Promise<void>;
14
+ onSubmit?: (values: T, helpers: FormSetters<T>) => Promise<void> | void;
15
+ onChange?: (values: T) => Promise<void> | void;
16
+ }
17
+
18
+ export interface FormHandler<T> extends FormSetters<T> {
19
+ submit: () => Promise<void>;
20
+ reset: () => void;
21
+ validate: () => void;
22
+ values: T;
23
+ errors: FormErrors<T>;
24
+ changes: FormChanged<T>;
25
+ submitting: boolean;
26
+ submitCount: number;
27
+ changed: boolean;
28
+ }
29
+
30
+ export interface FormSetters<T> {
31
+ setFieldError: (field: keyof T, message?: string) => void;
32
+ setFieldValue: {
33
+ (field: keyof T): (value: T[keyof T]) => void;
34
+ (field: keyof T, value: T[keyof T]): void;
35
+ };
36
+ setValues: (values: Partial<T>) => void;
37
+ setSubmitting: (submitting: boolean) => void;
38
+ setErrors: (errors: FormErrors<T>) => void;
39
+ reset: () => void;
40
+ }
41
+
42
+ interface FormState<T> {
43
+ edits: Partial<T>;
44
+ errors: FormErrors<T>;
45
+ submitting: boolean;
46
+ submitCount: number;
47
+ }
48
+
49
+ export type FormErrors<T> = { [key in keyof T]?: string };
50
+ export type FormChanged<T> = { [key in keyof T]?: boolean };
51
+
52
+ type Action<T> =
53
+ | {
54
+ type: "SET_FIELD_VALUE";
55
+ field: keyof T;
56
+ value: T[keyof T];
57
+ }
58
+ | {
59
+ type: "SET_VALUES";
60
+ values: Partial<T>;
61
+ }
62
+ | {
63
+ type: "SET_FIELD_ERROR";
64
+ field: keyof T;
65
+ message?: string;
66
+ }
67
+ | {
68
+ type: "SET_ERRORS";
69
+ errors: FormErrors<T>;
70
+ }
71
+ | {
72
+ type: "SUBMIT";
73
+ }
74
+ | {
75
+ type: "SET_SUBMITTING";
76
+ submitting: boolean;
77
+ }
78
+ | {
79
+ type: "RESET";
80
+ };
81
+
82
+ function reducer<T>(prevState: FormState<T>, action: Action<T>): FormState<T> {
83
+ switch (action.type) {
84
+ case "SET_FIELD_VALUE": {
85
+ const errors = { ...prevState.errors };
86
+ delete errors[action.field];
87
+ return {
88
+ ...prevState,
89
+ edits: { ...prevState.edits, [action.field]: action.value },
90
+ errors,
91
+ };
92
+ }
93
+ case "SET_VALUES": {
94
+ const errors = { ...prevState.errors };
95
+ for (const key of Object.keys(action.values)) {
96
+ delete errors[key as keyof T];
97
+ }
98
+ return {
99
+ ...prevState,
100
+ edits: { ...prevState.edits, ...action.values },
101
+ errors,
102
+ };
103
+ }
104
+ case "SET_ERRORS":
105
+ return { ...prevState, errors: { ...action.errors } };
106
+ case "SET_FIELD_ERROR": {
107
+ const errors = { ...prevState.errors };
108
+ if (action.message === undefined) {
109
+ delete errors[action.field];
110
+ } else {
111
+ errors[action.field] = action.message;
112
+ }
113
+ return { ...prevState, errors };
114
+ }
115
+ case "SET_SUBMITTING":
116
+ if (prevState.submitting === action.submitting) return prevState;
117
+ return { ...prevState, submitting: action.submitting };
118
+ case "SUBMIT":
119
+ return {
120
+ ...prevState,
121
+ errors: {},
122
+ submitting: false,
123
+ submitCount: prevState.submitCount + 1,
124
+ };
125
+ case "RESET":
126
+ return { edits: {}, errors: {}, submitting: false, submitCount: 0 };
127
+ default:
128
+ throw new Error("Action type not resolved");
129
+ }
130
+ }
131
+
132
+ function createEmptyState<T>(): FormState<T> {
133
+ return { edits: {}, errors: {}, submitting: false, submitCount: 0 };
134
+ }
135
+
136
+ export function useForm<T>(props: UseFormProps<T>): FormHandler<T> {
137
+ const { initialValues, validate, onSubmit, onChange } = props;
138
+ const [state, dispatch] = useReducer(reducer<T>, null, createEmptyState<T>);
139
+ const submittingRef = useRef(false);
140
+
141
+ // Values derived from props + user edits. When initialValues changes
142
+ // (e.g. SWR revalidation), non-edited fields automatically reflect
143
+ // the new server data. No synchronization needed.
144
+ const values = useMemo(
145
+ (): T => ({ ...initialValues, ...state.edits }),
146
+ [initialValues, state.edits],
147
+ );
148
+
149
+ const { errors, submitting, submitCount } = state;
150
+
151
+ const changes = useMemo((): FormChanged<T> => {
152
+ const result: FormChanged<T> = {};
153
+ for (const key of Object.keys(state.edits)) {
154
+ result[key as keyof T] = true;
155
+ }
156
+ return result;
157
+ }, [state.edits]);
158
+
159
+ const changed = Object.keys(state.edits).length > 0;
160
+
161
+ const setErrors = useCallback((nextErrors: FormErrors<T>) => {
162
+ dispatch({ type: "SET_ERRORS", errors: nextErrors });
163
+ }, []);
164
+
165
+ const setFieldError = useCallback((field: keyof T, message?: string): void => {
166
+ dispatch({ type: "SET_FIELD_ERROR", field, message });
167
+ }, []);
168
+
169
+ const setSubmitting = useCallback((newSubmitting: boolean) => {
170
+ dispatch({ type: "SET_SUBMITTING", submitting: newSubmitting });
171
+ }, []);
172
+
173
+ const setFieldValue = useCallback(
174
+ ((...args: [field: keyof T, value?: T[keyof T]]) => {
175
+ const [field, value] = args;
176
+
177
+ if (args.length === 1) {
178
+ return (val: T[keyof T]) => {
179
+ dispatch({ type: "SET_FIELD_VALUE", field, value: val });
180
+ onChange?.({ ...values, [field]: val });
181
+ };
182
+ } else {
183
+ dispatch({
184
+ type: "SET_FIELD_VALUE",
185
+ field,
186
+ value: value as T[keyof T],
187
+ });
188
+ onChange?.({ ...values, [field]: value });
189
+ }
190
+ }) as {
191
+ (field: keyof T): (value: T[keyof T]) => void;
192
+ (field: keyof T, value: T[keyof T]): void;
193
+ },
194
+ [onChange, values],
195
+ );
196
+
197
+ const setValues = useCallback(
198
+ (newValues: Partial<T>) => {
199
+ dispatch({ type: "SET_VALUES", values: newValues });
200
+ onChange?.({ ...values, ...newValues });
201
+ },
202
+ [onChange, values],
203
+ );
204
+
205
+ const reset = useCallback(() => {
206
+ dispatch({ type: "RESET" });
207
+ }, []);
208
+
209
+ const handleValidate = useCallback(async () => {
210
+ if (validate !== undefined) {
211
+ setErrors((await validate(values)) || {});
212
+ }
213
+ }, [validate, values, setErrors]);
214
+
215
+ const formHelpers = useMemo(
216
+ (): FormSetters<T> => ({
217
+ setFieldError,
218
+ setFieldValue,
219
+ setValues,
220
+ setErrors,
221
+ setSubmitting,
222
+ reset,
223
+ }),
224
+ [setValues, setFieldError, setFieldValue, setErrors, setSubmitting, reset],
225
+ );
226
+
227
+ const submit = useCallback(async () => {
228
+ if (!onSubmit) return;
229
+ if (submittingRef.current) return;
230
+
231
+ if (validate !== undefined) {
232
+ const nextErrors = await validate(values);
233
+
234
+ if (nextErrors && Object.keys(nextErrors).length) {
235
+ setErrors(nextErrors);
236
+ return;
237
+ }
238
+ }
239
+
240
+ submittingRef.current = true;
241
+ dispatch({ type: "SET_SUBMITTING", submitting: true });
242
+ try {
243
+ await onSubmit(values, formHelpers);
244
+ dispatch({ type: "SUBMIT" });
245
+ } finally {
246
+ submittingRef.current = false;
247
+ dispatch({ type: "SET_SUBMITTING", submitting: false });
248
+ }
249
+ }, [onSubmit, validate, setErrors, values, formHelpers]);
250
+
251
+ return useMemo(
252
+ () => ({
253
+ setValues,
254
+ setFieldValue,
255
+ setFieldError,
256
+ setErrors,
257
+ setSubmitting,
258
+ submit,
259
+ reset,
260
+ values,
261
+ errors,
262
+ changes,
263
+ submitting,
264
+ validate: handleValidate,
265
+ submitCount,
266
+ changed,
267
+ }),
268
+ [
269
+ setValues,
270
+ setFieldValue,
271
+ setFieldError,
272
+ setErrors,
273
+ setSubmitting,
274
+ submit,
275
+ reset,
276
+ values,
277
+ errors,
278
+ changes,
279
+ submitting,
280
+ handleValidate,
281
+ submitCount,
282
+ changed,
283
+ ],
284
+ );
285
+ }