@douglasneuroinformatics/libui 4.1.0 → 4.2.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-Dm7os_cB.js';
6
+ import { T as TranslationNamespace, L as Language, a as TranslateFunction } from './types-DHTtLrqP.js';
7
7
 
8
8
  declare function useChart(): {
9
9
  config: ChartConfig;
package/dist/hooks.js CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  useTheme,
20
20
  useTranslation,
21
21
  useWindowSize
22
- } from "./chunk-KI6BSSS6.js";
23
- import "./chunk-655XRTXX.js";
22
+ } from "./chunk-ZIAKQCCQ.js";
23
+ import "./chunk-ARKHRGTL.js";
24
24
  import "./chunk-HCQE34RL.js";
25
25
  export {
26
26
  DEFAULT_THEME,
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-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';
3
+ import { L as Language, b as Translations, a as TranslateFunction } from './types-DHTtLrqP.js';
4
+ export { E as ExtractTranslationKey, c as LanguageOptions, d as TranslationKey, T as TranslationNamespace, U as UserConfig } from './types-DHTtLrqP.js';
5
5
 
6
6
  type InitOptions = {
7
7
  defaultLanguage?: Language;
package/dist/i18n.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  i18n,
4
4
  translationStore
5
- } from "./chunk-655XRTXX.js";
5
+ } from "./chunk-ARKHRGTL.js";
6
6
  export {
7
7
  i18n,
8
8
  translationStore
@@ -132,11 +132,25 @@ var notifications = {
132
132
  }
133
133
  }
134
134
  };
135
+ var oneTimePasswordInput = {
136
+ invalidCodeFormat: {
137
+ en: "Invalid code format",
138
+ fr: "Format de code invalide"
139
+ }
140
+ };
135
141
  var pagination = {
142
+ firstPage: {
143
+ en: "<< First",
144
+ fr: "<< Première"
145
+ },
136
146
  info: {
137
147
  en: "Showing {{first}} to {{last}} of {{total}} results",
138
148
  fr: "Affichage de {{first}} à {{last}} sur {{total}} résultats"
139
149
  },
150
+ lastPage: {
151
+ en: "Last >>",
152
+ fr: "Dernière >>"
153
+ },
140
154
  next: {
141
155
  en: "Next",
142
156
  fr: "Suivant"
@@ -144,14 +158,6 @@ var pagination = {
144
158
  previous: {
145
159
  en: "Previous",
146
160
  fr: "Précédent"
147
- },
148
- firstPage: {
149
- en: "<< First",
150
- fr: "<< Première"
151
- },
152
- lastPage: {
153
- en: "Last >>",
154
- fr: "Dernière >>"
155
161
  }
156
162
  };
157
163
  var searchBar = {
@@ -165,6 +171,7 @@ var libuiTranslations = {
165
171
  form: form,
166
172
  months: months,
167
173
  notifications: notifications,
174
+ oneTimePasswordInput: oneTimePasswordInput,
168
175
  pagination: pagination,
169
176
  searchBar: searchBar
170
177
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@douglasneuroinformatics/libui",
3
3
  "type": "module",
4
- "version": "4.1.0",
4
+ "version": "4.2.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",
@@ -53,7 +53,7 @@
53
53
  "scripts": {
54
54
  "build": "rm -rf dist && tsup --config tsup.config.mts",
55
55
  "format": "prettier --write src",
56
- "format:translations": "find src/translations -name '*.json' -exec pnpm exec sort-json {} \\;",
56
+ "format:translations": "find src/i18n/translations -name '*.json' -exec pnpm exec sort-json {} \\;",
57
57
  "lint": "tsc && eslint --fix src",
58
58
  "prepare": "husky",
59
59
  "storybook": "storybook dev --no-open -p 6006",
@@ -540,7 +540,7 @@ export const WithError: StoryObj<typeof Form> = {
540
540
  variant: 'input'
541
541
  }
542
542
  },
543
- beforeSubmit: (data) => {
543
+ onBeforeSubmit: (data) => {
544
544
  if (data.name === 'Winston') {
545
545
  return { success: true };
546
546
  }
@@ -116,14 +116,13 @@ describe('Form', () => {
116
116
  });
117
117
  });
118
118
 
119
- describe('custom beforeSubmit error', () => {
120
- let beforeSubmit: Mock;
119
+ describe('custom onBeforeSubmit error', () => {
120
+ let onBeforeSubmit: Mock;
121
121
 
122
122
  beforeEach(() => {
123
- beforeSubmit = vi.fn();
123
+ onBeforeSubmit = vi.fn();
124
124
  render(
125
125
  <Form
126
- beforeSubmit={beforeSubmit}
127
126
  content={{
128
127
  value: {
129
128
  kind: 'number',
@@ -135,6 +134,7 @@ describe('Form', () => {
135
134
  validationSchema={z.object({
136
135
  value: z.number({ message: 'Please enter a number' })
137
136
  })}
137
+ onBeforeSubmit={onBeforeSubmit}
138
138
  onError={onError}
139
139
  onSubmit={onSubmit}
140
140
  />
@@ -156,12 +156,12 @@ describe('Form', () => {
156
156
  'Please enter a number'
157
157
  ])
158
158
  );
159
- expect(beforeSubmit).not.toHaveBeenCalled();
159
+ expect(onBeforeSubmit).not.toHaveBeenCalled();
160
160
  expect(onSubmit).not.toHaveBeenCalled();
161
161
  });
162
162
 
163
- it('should not allow submitting the form with the beforeSubmit error', async () => {
164
- beforeSubmit.mockResolvedValueOnce({ errorMessage: 'Invalid!', success: false });
163
+ it('should not allow submitting the form with the onBeforeSubmit error', async () => {
164
+ onBeforeSubmit.mockResolvedValueOnce({ errorMessage: 'Invalid!', success: false });
165
165
  const field: HTMLInputElement = screen.getByLabelText('Value');
166
166
  await userEvent.type(field, '-1');
167
167
  fireEvent.submit(screen.getByTestId(testid));
@@ -171,8 +171,8 @@ describe('Form', () => {
171
171
  expect(onSubmit).not.toHaveBeenCalled();
172
172
  });
173
173
 
174
- it('should allow submitting the form if beforeSubmit returns true', async () => {
175
- beforeSubmit.mockResolvedValueOnce({ success: true });
174
+ it('should allow submitting the form if onBeforeSubmit returns true', async () => {
175
+ onBeforeSubmit.mockResolvedValueOnce({ success: true });
176
176
  const field: HTMLInputElement = screen.getByLabelText('Value');
177
177
  await userEvent.type(field, '-1');
178
178
  fireEvent.submit(screen.getByTestId(testid));
@@ -29,7 +29,6 @@ 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 }>;
33
32
  className?: string;
34
33
  content: FormContent<TData>;
35
34
  customStyles?: {
@@ -39,6 +38,7 @@ type FormProps<TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<T
39
38
  fieldsFooter?: React.ReactNode;
40
39
  id?: string;
41
40
  initialValues?: PartialNullableFormDataType<NoInfer<TData>>;
41
+ onBeforeSubmit?: (data: NoInfer<TData>) => Promisable<{ errorMessage: string; success: false } | { success: true }>;
42
42
  onError?: (error: z.ZodError<NoInfer<TData>>) => void;
43
43
  onSubmit: (data: NoInfer<TData>) => Promisable<void>;
44
44
  preventResetValuesOnReset?: boolean;
@@ -52,13 +52,13 @@ type FormProps<TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<T
52
52
 
53
53
  const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TSchema> = z.TypeOf<TSchema>>({
54
54
  additionalButtons,
55
- beforeSubmit,
56
55
  className,
57
56
  content,
58
57
  customStyles,
59
58
  fieldsFooter,
60
59
  id,
61
60
  initialValues,
61
+ onBeforeSubmit,
62
62
  onError,
63
63
  onSubmit,
64
64
  preventResetValuesOnReset,
@@ -116,8 +116,8 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
116
116
  handleError(result.error);
117
117
  return;
118
118
  }
119
- if (beforeSubmit) {
120
- const beforeSubmitResult = await beforeSubmit(result.data);
119
+ if (onBeforeSubmit) {
120
+ const beforeSubmitResult = await onBeforeSubmit(result.data);
121
121
  if (!beforeSubmitResult.success) {
122
122
  setErrors({});
123
123
  setRootErrors([beforeSubmitResult.errorMessage]);
@@ -142,7 +142,7 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
142
142
  const isGrouped = Array.isArray(content);
143
143
 
144
144
  const revalidate = () => {
145
- const hasErrors = Object.keys(errors).length > 0;
145
+ const hasErrors = Object.keys(errors).length > 0 || rootErrors.length;
146
146
  if (hasErrors) {
147
147
  validationSchema
148
148
  .safeParseAsync(values)
@@ -156,7 +156,8 @@ const Form = <TSchema extends z.ZodType<FormDataType>, TData extends z.TypeOf<TS
156
156
  };
157
157
 
158
158
  useEffect(() => {
159
- revalidate();
159
+ setErrors({});
160
+ setRootErrors([]);
160
161
  }, [resolvedLanguage]);
161
162
 
162
163
  const isSuspended = Boolean(suspendWhileSubmitting && isSubmitting);
@@ -0,0 +1,19 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { OneTimePasswordInput } from './OneTimePasswordInput';
5
+
6
+ type Props = React.ComponentPropsWithoutRef<typeof OneTimePasswordInput>;
7
+
8
+ const TEST_ID = 'OneTimePasswordInput';
9
+
10
+ const TestOneTimePasswordInput: React.FC<Partial<Props>> = (props) => {
11
+ return <OneTimePasswordInput data-testid={TEST_ID} {...(props as Props)} />;
12
+ };
13
+
14
+ describe('OneTimePasswordInput', () => {
15
+ it('should render', () => {
16
+ render(<TestOneTimePasswordInput />);
17
+ expect(screen.getByTestId(TEST_ID)).toBeInTheDocument();
18
+ });
19
+ });
@@ -0,0 +1,27 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+
3
+ import { NotificationHub } from '../NotificationHub';
4
+ import { OneTimePasswordInput } from './OneTimePasswordInput';
5
+
6
+ type Story = StoryObj<typeof OneTimePasswordInput>;
7
+
8
+ export default {
9
+ args: {
10
+ onComplete: (code) => {
11
+ alert(`Code: ${code}`);
12
+ }
13
+ },
14
+ component: OneTimePasswordInput,
15
+ decorators: [
16
+ (Story) => {
17
+ return (
18
+ <>
19
+ <NotificationHub />
20
+ <Story />
21
+ </>
22
+ );
23
+ }
24
+ ]
25
+ } as Meta<typeof OneTimePasswordInput>;
26
+
27
+ export const Default: Story = {};
@@ -0,0 +1,109 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { ChangeEvent, ClipboardEvent, KeyboardEvent } from 'react';
3
+
4
+ import type { Promisable } from 'type-fest';
5
+
6
+ import { useNotificationsStore, useTranslation } from '@/hooks';
7
+ import { cn } from '@/utils';
8
+
9
+ const CODE_LENGTH = 6;
10
+
11
+ const EMPTY_CODE = Object.freeze(Array<null>(CODE_LENGTH).fill(null));
12
+
13
+ type OneTimePasswordInputProps = {
14
+ [key: `data-${string}`]: unknown;
15
+ className?: string;
16
+ onComplete: (code: number) => Promisable<void>;
17
+ };
18
+
19
+ function getUpdatedDigits(digits: (null | number)[], index: number, value: null | number) {
20
+ const updatedDigits = [...digits];
21
+ updatedDigits[index] = value;
22
+ return updatedDigits;
23
+ }
24
+
25
+ export const OneTimePasswordInput = ({ className, onComplete, ...props }: OneTimePasswordInputProps) => {
26
+ const notifications = useNotificationsStore();
27
+ const { t } = useTranslation('libui');
28
+ const [digits, setDigits] = useState<(null | number)[]>([...EMPTY_CODE]);
29
+ const inputRefs = digits.map(() => useRef<HTMLInputElement>(null));
30
+
31
+ useEffect(() => {
32
+ const isComplete = digits.every((value) => Number.isInteger(value));
33
+ if (isComplete) {
34
+ void onComplete(parseInt(digits.join('')));
35
+ setDigits([...EMPTY_CODE]);
36
+ }
37
+ }, [digits]);
38
+
39
+ const focusNext = (index: number) => inputRefs[index + 1 === digits.length ? 0 : index + 1]?.current?.focus();
40
+
41
+ const focusPrev = (index: number) => inputRefs[index - 1 >= 0 ? index - 1 : digits.length - 1]?.current?.focus();
42
+
43
+ const handleChange = (e: ChangeEvent<HTMLInputElement>, index: number) => {
44
+ let value: null | number;
45
+ if (e.target.value === '') {
46
+ value = null;
47
+ } else if (Number.isInteger(parseInt(e.target.value))) {
48
+ value = parseInt(e.target.value);
49
+ } else {
50
+ return;
51
+ }
52
+ setDigits((prevDigits) => getUpdatedDigits(prevDigits, index, value));
53
+ focusNext(index);
54
+ };
55
+
56
+ const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>, index: number) => {
57
+ switch (e.key) {
58
+ case 'ArrowLeft':
59
+ focusPrev(index);
60
+ break;
61
+ case 'ArrowRight':
62
+ focusNext(index);
63
+ break;
64
+ case 'Backspace':
65
+ setDigits((prevDigits) => getUpdatedDigits(prevDigits, index - 1, null));
66
+ focusPrev(index);
67
+ }
68
+ };
69
+
70
+ const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
71
+ e.preventDefault();
72
+ const pastedDigits = e.clipboardData
73
+ .getData('text/plain')
74
+ .split('')
75
+ .slice(0, CODE_LENGTH)
76
+ .map((value) => parseInt(value));
77
+ const isValid = pastedDigits.length === CODE_LENGTH && pastedDigits.every((value) => Number.isInteger(value));
78
+ if (isValid) {
79
+ setDigits(pastedDigits);
80
+ } else {
81
+ notifications.addNotification({
82
+ message: t('oneTimePasswordInput.invalidCodeFormat'),
83
+ type: 'warning'
84
+ });
85
+ }
86
+ };
87
+
88
+ return (
89
+ <div className={cn('flex gap-2', className)} {...props}>
90
+ {digits.map((_, index) => (
91
+ <input
92
+ className="w-1/6 rounded-md border border-slate-300 bg-transparent p-2 shadow-xs hover:border-slate-300 focus:border-sky-800 focus:outline-hidden dark:border-slate-600 dark:hover:border-slate-400 dark:focus:border-sky-500"
93
+ key={index}
94
+ maxLength={1}
95
+ ref={inputRefs[index]}
96
+ type="text"
97
+ value={digits[index] ?? ''}
98
+ onChange={(e) => {
99
+ handleChange(e, index);
100
+ }}
101
+ onKeyDown={(e) => {
102
+ handleKeyDown(e, index);
103
+ }}
104
+ onPaste={handlePaste}
105
+ />
106
+ ))}
107
+ </div>
108
+ );
109
+ };
@@ -0,0 +1 @@
1
+ export * from './OneTimePasswordInput';
@@ -32,6 +32,7 @@ export * from './LineGraph';
32
32
  export * from './ListboxDropdown';
33
33
  export * from './MenuBar';
34
34
  export * from './NotificationHub';
35
+ export * from './OneTimePasswordInput';
35
36
  export * from './Pagination';
36
37
  export * from './Popover';
37
38
  export * from './Progress';
@@ -131,11 +131,25 @@
131
131
  }
132
132
  }
133
133
  },
134
+ "oneTimePasswordInput": {
135
+ "invalidCodeFormat": {
136
+ "en": "Invalid code format",
137
+ "fr": "Format de code invalide"
138
+ }
139
+ },
134
140
  "pagination": {
141
+ "firstPage": {
142
+ "en": "<< First",
143
+ "fr": "<< Première"
144
+ },
135
145
  "info": {
136
146
  "en": "Showing {{first}} to {{last}} of {{total}} results",
137
147
  "fr": "Affichage de {{first}} à {{last}} sur {{total}} résultats"
138
148
  },
149
+ "lastPage": {
150
+ "en": "Last >>",
151
+ "fr": "Dernière >>"
152
+ },
139
153
  "next": {
140
154
  "en": "Next",
141
155
  "fr": "Suivant"
@@ -143,14 +157,6 @@
143
157
  "previous": {
144
158
  "en": "Previous",
145
159
  "fr": "Précédent"
146
- },
147
- "firstPage": {
148
- "en": "<< First",
149
- "fr": "<< Première"
150
- },
151
- "lastPage": {
152
- "en": "Last >>",
153
- "fr": "Dernière >>"
154
160
  }
155
161
  },
156
162
  "searchBar": {