@douglasneuroinformatics/libui 4.0.2 → 4.1.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/dist/hooks.d.ts CHANGED
@@ -3,7 +3,7 @@ export { D as DEFAULT_THEME, a as SYS_DARK_MEDIA_QUERY, S as StorageName, b as T
3
3
  import { Promisable } from 'type-fest';
4
4
  import { RefObject, useEffect, Dispatch, SetStateAction } from 'react';
5
5
  import * as zustand from 'zustand';
6
- import { T as TranslationNamespace, L as Language, a as TranslateFunction } from './types-CMuti1SJ.js';
6
+ import { T as TranslationNamespace, L as Language, a as TranslateFunction } from './types-Dm7os_cB.js';
7
7
 
8
8
  declare function useChart(): {
9
9
  config: ChartConfig;
package/dist/i18n.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as zustand from 'zustand';
2
2
  import { SetOptional } from 'type-fest';
3
- import { L as Language, b as Translations, a as TranslateFunction } from './types-CMuti1SJ.js';
4
- export { E as ExtractTranslationKey, c as LanguageOptions, d as TranslationKey, T as TranslationNamespace, U as UserConfig } from './types-CMuti1SJ.js';
3
+ import { L as Language, b as Translations, a as TranslateFunction } from './types-Dm7os_cB.js';
4
+ export { E as ExtractTranslationKey, c as LanguageOptions, d as TranslationKey, T as TranslationNamespace, U as UserConfig } from './types-Dm7os_cB.js';
5
5
 
6
6
  type InitOptions = {
7
7
  defaultLanguage?: Language;
@@ -118,24 +118,6 @@
118
118
  }
119
119
  }
120
120
 
121
- /*
122
- The default border color has changed to `currentColor` in Tailwind CSS v4,
123
- so we've added these compatibility styles to make sure everything still
124
- looks the same as it did with Tailwind CSS v3.
125
-
126
- If we ever want to remove these styles, we need to add an explicit border
127
- color utility to any element that depends on these defaults.
128
- */
129
- @layer base {
130
- *,
131
- ::after,
132
- ::before,
133
- ::backdrop,
134
- ::file-selector-button {
135
- border-color: var(--color-gray-200, currentColor);
136
- }
137
- }
138
-
139
121
  @layer base {
140
122
  :root {
141
123
  --background: var(--color-slate-100);
@@ -201,7 +201,7 @@ type TranslationKey<TNamespace> = TNamespace extends TranslationNamespace ? Extr
201
201
  interface TranslateFunction<TNamespace = undefined> {
202
202
  (key: TranslationKey<TNamespace>, ...args: Exclude<Primitive, symbol>[]): string;
203
203
  (translations: {
204
- [L in Language]: string;
204
+ [L in Language]?: string;
205
205
  }, ...args: Exclude<Primitive, symbol>[]): string;
206
206
  }
207
207
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@douglasneuroinformatics/libui",
3
3
  "type": "module",
4
- "version": "4.0.2",
4
+ "version": "4.1.0",
5
5
  "packageManager": "pnpm@10.7.1",
6
6
  "description": "Generic UI components for DNP projects, built using React and Tailwind CSS",
7
7
  "author": "Joshua Unrau",
@@ -68,7 +68,7 @@
68
68
  "zod": "^3.23.6"
69
69
  },
70
70
  "dependencies": {
71
- "@douglasneuroinformatics/libjs": "^2.7.0",
71
+ "@douglasneuroinformatics/libjs": "^2.8.0",
72
72
  "@douglasneuroinformatics/libui-form-types": "^0.11.0",
73
73
  "@radix-ui/react-accordion": "^1.2.3",
74
74
  "@radix-ui/react-alert-dialog": "^1.1.6",
@@ -14,7 +14,7 @@ export const BUTTON_ICON_SIZE = {
14
14
  };
15
15
 
16
16
  export const buttonVariants = cva(
17
- 'flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
17
+ 'flex items-center justify-center whitespace-nowrap cursor-pointer rounded-md text-sm font-medium transition-colors focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
18
18
  {
19
19
  defaultVariants: {
20
20
  size: 'md',
@@ -16,7 +16,7 @@ export const DialogContent = forwardRef<
16
16
  <DialogOverlay />
17
17
  <Content
18
18
  className={cn(
19
- 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
19
+ 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
20
20
  className
21
21
  )}
22
22
  ref={ref}
@@ -11,7 +11,7 @@ export const DialogOverlay = forwardRef<
11
11
  return (
12
12
  <Overlay
13
13
  className={cn(
14
- 'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
14
+ 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 duration-200',
15
15
  className
16
16
  )}
17
17
  ref={ref}
@@ -0,0 +1,14 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { ErrorFallback } from './ErrorFallback';
5
+
6
+ const TEST_ID = 'error-fallback';
7
+
8
+ describe('ErrorFallback', () => {
9
+ it('should render', () => {
10
+ const error = new Error('Something went wrong');
11
+ render(<ErrorFallback error={error} />);
12
+ expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
13
+ });
14
+ });
@@ -12,10 +12,13 @@ export const ErrorFallback = ({ error }: ErrorFallbackProps) => {
12
12
  }, [error]);
13
13
 
14
14
  return (
15
- <div className="flex min-h-screen flex-col items-center justify-center gap-1 p-3 text-center">
16
- <h1 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Unexpected Error</h1>
15
+ <div
16
+ className="flex min-h-screen flex-col items-center justify-center gap-1 p-3 text-center"
17
+ data-testid="error-fallback"
18
+ >
19
+ <h1 className="text-muted-foreground text-sm font-semibold tracking-wide uppercase">Unexpected Error</h1>
17
20
  <h3 className="text-3xl font-extrabold tracking-tight sm:text-4xl md:text-5xl">Something Went Wrong</h3>
18
- <p className="mt-2 max-w-prose text-sm text-muted-foreground sm:text-base">
21
+ <p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">
19
22
  We apologize for the inconvenience. Please contact us for further assistance.
20
23
  </p>
21
24
  <div className="mt-6">
@@ -2,13 +2,15 @@ import * as React from 'react';
2
2
 
3
3
  import { CircleAlertIcon } from 'lucide-react';
4
4
 
5
- export const ErrorMessage: React.FC<{ error?: null | string[] }> = ({ error }) => {
5
+ import { cn } from '@/utils';
6
+
7
+ export const ErrorMessage: React.FC<{ className?: string; error?: null | string[] }> = ({ className, error }) => {
6
8
  return error ? (
7
9
  <div className="space-y-1.5">
8
10
  {error.map((message) => (
9
- <div className="flex w-full items-center text-sm font-medium text-destructive" key={message}>
11
+ <div className={cn('text-destructive flex w-full items-center text-sm font-medium', className)} key={message}>
10
12
  <CircleAlertIcon className="mr-1" style={{ strokeWidth: '2px' }} />
11
- <span>{message}</span>
13
+ <span data-testid="error-message-text">{message}</span>
12
14
  </div>
13
15
  )) ?? null}
14
16
  </div>
@@ -530,3 +530,27 @@ export const WithSuspend: StoryObj<typeof Form<z.ZodType<FormTypes.Data>, { dela
530
530
  })
531
531
  }
532
532
  };
533
+
534
+ export const WithError: StoryObj<typeof Form> = {
535
+ args: {
536
+ content: {
537
+ name: {
538
+ kind: 'string',
539
+ label: 'Name',
540
+ variant: 'input'
541
+ }
542
+ },
543
+ beforeSubmit: (data) => {
544
+ if (data.name === 'Winston') {
545
+ return { success: true };
546
+ }
547
+ return { success: false, errorMessage: "Name must be 'Winston'" };
548
+ },
549
+ onSubmit: () => {
550
+ alert('Success!');
551
+ },
552
+ validationSchema: $SimpleExampleFormData.extend({
553
+ name: z.string().min(3)
554
+ })
555
+ }
556
+ };
@@ -1,6 +1,7 @@
1
1
  import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
2
  import { userEvent } from '@testing-library/user-event';
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import type { Mock } from 'vitest';
4
5
  import { z } from 'zod';
5
6
 
6
7
  import { Form } from './Form';
@@ -114,4 +115,68 @@ describe('Form', () => {
114
115
  expect(onSubmit.mock.lastCall?.[0].b).toBeUndefined();
115
116
  });
116
117
  });
118
+
119
+ describe('custom beforeSubmit error', () => {
120
+ let beforeSubmit: Mock;
121
+
122
+ beforeEach(() => {
123
+ beforeSubmit = vi.fn();
124
+ render(
125
+ <Form
126
+ beforeSubmit={beforeSubmit}
127
+ content={{
128
+ value: {
129
+ kind: 'number',
130
+ label: 'Value',
131
+ variant: 'input'
132
+ }
133
+ }}
134
+ data-testid={testid}
135
+ validationSchema={z.object({
136
+ value: z.number({ message: 'Please enter a number' })
137
+ })}
138
+ onError={onError}
139
+ onSubmit={onSubmit}
140
+ />
141
+ );
142
+ });
143
+
144
+ afterEach(() => {
145
+ vi.clearAllMocks();
146
+ });
147
+
148
+ it('should render', () => {
149
+ expect(screen.getByTestId(testid)).toBeInTheDocument();
150
+ });
151
+
152
+ it('should not allow submitting the form with a zod error', async () => {
153
+ fireEvent.submit(screen.getByTestId(testid));
154
+ await waitFor(() =>
155
+ expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject([
156
+ 'Please enter a number'
157
+ ])
158
+ );
159
+ expect(beforeSubmit).not.toHaveBeenCalled();
160
+ expect(onSubmit).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it('should not allow submitting the form with the beforeSubmit error', async () => {
164
+ beforeSubmit.mockResolvedValueOnce({ errorMessage: 'Invalid!', success: false });
165
+ const field: HTMLInputElement = screen.getByLabelText('Value');
166
+ await userEvent.type(field, '-1');
167
+ fireEvent.submit(screen.getByTestId(testid));
168
+ await waitFor(() =>
169
+ expect(screen.getAllByTestId('error-message-text').map((e) => e.innerHTML)).toMatchObject(['Invalid!'])
170
+ );
171
+ expect(onSubmit).not.toHaveBeenCalled();
172
+ });
173
+
174
+ it('should allow submitting the form if beforeSubmit returns true', async () => {
175
+ beforeSubmit.mockResolvedValueOnce({ success: true });
176
+ const field: HTMLInputElement = screen.getByLabelText('Value');
177
+ await userEvent.type(field, '-1');
178
+ fireEvent.submit(screen.getByTestId(testid));
179
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
180
+ });
181
+ });
117
182
  });
@@ -29,8 +29,13 @@ type FormProps<TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<T
29
29
  left?: React.ReactNode;
30
30
  right?: React.ReactNode;
31
31
  };
32
+ beforeSubmit?: (data: NoInfer<TData>) => Promisable<{ errorMessage: string; success: false } | { success: true }>;
32
33
  className?: string;
33
34
  content: FormContent<TData>;
35
+ customStyles?: {
36
+ resetBtn?: string;
37
+ submitBtn?: string;
38
+ };
34
39
  fieldsFooter?: React.ReactNode;
35
40
  id?: string;
36
41
  initialValues?: PartialNullableFormDataType<NoInfer<TData>>;
@@ -47,8 +52,10 @@ type FormProps<TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<T
47
52
 
48
53
  const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TSchema> = z.TypeOf<TSchema>>({
49
54
  additionalButtons,
55
+ beforeSubmit,
50
56
  className,
51
57
  content,
58
+ customStyles,
52
59
  fieldsFooter,
53
60
  id,
54
61
  initialValues,
@@ -73,6 +80,7 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
73
80
 
74
81
  const handleError = (error: z.ZodError<TData>) => {
75
82
  const fieldErrors: FormErrors<TData> = {};
83
+ const rootErrors: string[] = [];
76
84
  for (const issue of error.issues) {
77
85
  if (issue.path.length > 0) {
78
86
  const current = get(fieldErrors, issue.path) as string[] | undefined;
@@ -82,10 +90,11 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
82
90
  set(fieldErrors, issue.path, [issue.message]);
83
91
  }
84
92
  } else {
85
- setRootErrors((prevErrors) => [...prevErrors, issue.message]);
93
+ rootErrors.push(issue.message);
86
94
  }
87
95
  }
88
96
  setErrors(fieldErrors);
97
+ setRootErrors(rootErrors);
89
98
  if (onError) {
90
99
  onError(error);
91
100
  }
@@ -107,6 +116,15 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
107
116
  handleError(result.error);
108
117
  return;
109
118
  }
119
+ if (beforeSubmit) {
120
+ const beforeSubmitResult = await beforeSubmit(result.data);
121
+ if (!beforeSubmitResult.success) {
122
+ setErrors({});
123
+ setRootErrors([beforeSubmitResult.errorMessage]);
124
+ return;
125
+ }
126
+ }
127
+
110
128
  try {
111
129
  setIsSubmitting(true);
112
130
  await Promise.all([
@@ -164,7 +182,7 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
164
182
  </Heading>
165
183
  )}
166
184
  {fieldGroup.description && (
167
- <p className="text-sm italic leading-tight text-muted-foreground">{fieldGroup.description}</p>
185
+ <p className="text-muted-foreground text-sm leading-tight italic">{fieldGroup.description}</p>
168
186
  )}
169
187
  </div>
170
188
  <FieldsComponent
@@ -188,13 +206,14 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
188
206
  values={values}
189
207
  />
190
208
  )}
209
+ {Boolean(rootErrors.length) && <ErrorMessage className="-mt-3" error={rootErrors} />}
191
210
  {fieldsFooter}
192
211
  <div className="flex w-full gap-3">
193
212
  {additionalButtons?.left}
194
213
  {/** Note - aria-label is used for testing in downstream packages */}
195
214
  <Button
196
215
  aria-label="Submit"
197
- className="flex w-full items-center justify-center gap-2"
216
+ className={cn('flex w-full items-center justify-center gap-2', customStyles?.submitBtn)}
198
217
  disabled={readOnly || isSuspended}
199
218
  type="submit"
200
219
  variant="primary"
@@ -218,7 +237,7 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
218
237
  {resetBtn && (
219
238
  <Button
220
239
  aria-label="Reset"
221
- className="block w-full"
240
+ className={cn('block w-full', customStyles?.resetBtn)}
222
241
  disabled={readOnly}
223
242
  type="button"
224
243
  variant="secondary"
@@ -229,7 +248,6 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
229
248
  )}
230
249
  {additionalButtons?.right}
231
250
  </div>
232
- {Boolean(rootErrors.length) && <ErrorMessage error={rootErrors} />}
233
251
  </form>
234
252
  );
235
253
  };
@@ -1,4 +1,4 @@
1
- import { useEffect, useRef, useState } from 'react';
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
2
 
3
3
  import { parseNumber } from '@douglasneuroinformatics/libjs';
4
4
  import type { NumberFormField } from '@douglasneuroinformatics/libui-form-types';
@@ -27,6 +27,7 @@ export const NumberFieldInput = ({
27
27
  setValue,
28
28
  value
29
29
  }: NumberFieldInputProps) => {
30
+ const id = useId();
30
31
  const [inputValue, setInputValue] = useState(value?.toString() ?? '');
31
32
  const valueRef = useRef<number | undefined>(value);
32
33
 
@@ -65,11 +66,12 @@ export const NumberFieldInput = ({
65
66
  return (
66
67
  <FieldGroup name={name}>
67
68
  <FieldGroup.Row>
68
- <Label>{label}</Label>
69
+ <Label htmlFor={id}>{label}</Label>
69
70
  <FieldGroup.Description description={description} />
70
71
  </FieldGroup.Row>
71
72
  <Input
72
73
  disabled={disabled || readOnly}
74
+ id={id}
73
75
  max={max}
74
76
  min={min}
75
77
  name={name}
package/src/i18n/types.ts CHANGED
@@ -47,5 +47,5 @@ export type TranslationKey<TNamespace> = TNamespace extends TranslationNamespace
47
47
 
48
48
  export interface TranslateFunction<TNamespace = undefined> {
49
49
  (key: TranslationKey<TNamespace>, ...args: Exclude<Primitive, symbol>[]): string;
50
- (translations: { [L in Language]: string }, ...args: Exclude<Primitive, symbol>[]): string;
50
+ (translations: { [L in Language]?: string }, ...args: Exclude<Primitive, symbol>[]): string;
51
51
  }
@@ -118,24 +118,6 @@
118
118
  }
119
119
  }
120
120
 
121
- /*
122
- The default border color has changed to `currentColor` in Tailwind CSS v4,
123
- so we've added these compatibility styles to make sure everything still
124
- looks the same as it did with Tailwind CSS v3.
125
-
126
- If we ever want to remove these styles, we need to add an explicit border
127
- color utility to any element that depends on these defaults.
128
- */
129
- @layer base {
130
- *,
131
- ::after,
132
- ::before,
133
- ::backdrop,
134
- ::file-selector-button {
135
- border-color: var(--color-gray-200, currentColor);
136
- }
137
- }
138
-
139
121
  @layer base {
140
122
  :root {
141
123
  --background: var(--color-slate-100);