@douglasneuroinformatics/libui 4.2.0 → 4.3.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-DHTtLrqP.js';
6
+ import { T as TranslationNamespace, L as Language, a as TranslateFunction } from './types-DDyMlEuL.js';
7
7
 
8
8
  declare function useChart(): {
9
9
  config: ChartConfig;
@@ -64,7 +64,7 @@ type NotificationsStore = {
64
64
  declare const useNotificationsStore: zustand.UseBoundStore<zustand.StoreApi<NotificationsStore>>;
65
65
 
66
66
  type Handler = (event: MouseEvent) => void;
67
- declare function useOnClickOutside<T extends HTMLElement = HTMLElement>(ref: RefObject<T>, handler: Handler, mouseEvent?: 'mousedown' | 'mouseup'): void;
67
+ declare function useOnClickOutside<T extends HTMLElement | null = HTMLElement>(ref: RefObject<T>, handler: Handler, mouseEvent?: 'mousedown' | 'mouseup'): void;
68
68
 
69
69
  /** Custom hook that uses local storage to persist state across page reloads */
70
70
  declare function useLocalStorage<T>(key: string, initialValue: (() => T) | T, options?: UseStorageOptions<T>): [T, Dispatch<SetStateAction<T>>];
package/dist/hooks.js CHANGED
@@ -19,8 +19,8 @@ import {
19
19
  useTheme,
20
20
  useTranslation,
21
21
  useWindowSize
22
- } from "./chunk-ZIAKQCCQ.js";
23
- import "./chunk-ARKHRGTL.js";
22
+ } from "./chunk-LSGD4EQY.js";
23
+ import "./chunk-VFVO337W.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-DHTtLrqP.js';
4
- export { E as ExtractTranslationKey, c as LanguageOptions, d as TranslationKey, T as TranslationNamespace, U as UserConfig } from './types-DHTtLrqP.js';
3
+ import { L as Language, b as Translations, a as TranslateFunction } from './types-DDyMlEuL.js';
4
+ export { E as ExtractTranslationKey, c as LanguageOptions, d as TranslationKey, T as TranslationNamespace, U as UserConfig } from './types-DDyMlEuL.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-ARKHRGTL.js";
5
+ } from "./chunk-VFVO337W.js";
6
6
  export {
7
7
  i18n,
8
8
  translationStore
@@ -181,6 +181,11 @@
181
181
 
182
182
  --radius: 0.5rem;
183
183
  }
184
+
185
+ button:not(:disabled),
186
+ [role='button']:not(:disabled) {
187
+ cursor: pointer;
188
+ }
184
189
  }
185
190
 
186
191
  @layer base {
@@ -143,6 +143,10 @@ var pagination = {
143
143
  en: "<< First",
144
144
  fr: "<< Première"
145
145
  },
146
+ first: {
147
+ en: "First",
148
+ fr: "Première"
149
+ },
146
150
  info: {
147
151
  en: "Showing {{first}} to {{last}} of {{total}} results",
148
152
  fr: "Affichage de {{first}} à {{last}} sur {{total}} résultats"
@@ -151,6 +155,10 @@ var pagination = {
151
155
  en: "Last >>",
152
156
  fr: "Dernière >>"
153
157
  },
158
+ last: {
159
+ en: "Last",
160
+ fr: "Dernière"
161
+ },
154
162
  next: {
155
163
  en: "Next",
156
164
  fr: "Suivant"
@@ -166,6 +174,14 @@ var searchBar = {
166
174
  fr: "Rechercher..."
167
175
  }
168
176
  };
177
+ var yes = {
178
+ en: "Yes",
179
+ fr: "Oui"
180
+ };
181
+ var no = {
182
+ en: "No",
183
+ fr: "Non"
184
+ };
169
185
  var libuiTranslations = {
170
186
  days: days,
171
187
  form: form,
@@ -173,7 +189,9 @@ var libuiTranslations = {
173
189
  notifications: notifications,
174
190
  oneTimePasswordInput: oneTimePasswordInput,
175
191
  pagination: pagination,
176
- searchBar: searchBar
192
+ searchBar: searchBar,
193
+ yes: yes,
194
+ no: no
177
195
  };
178
196
 
179
197
  declare namespace UserConfig {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@douglasneuroinformatics/libui",
3
3
  "type": "module",
4
- "version": "4.2.0",
4
+ "version": "4.3.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",
@@ -92,6 +92,7 @@
92
92
  "@radix-ui/react-switch": "^1.1.3",
93
93
  "@radix-ui/react-tabs": "^1.1.3",
94
94
  "@radix-ui/react-tooltip": "^1.1.8",
95
+ "@tanstack/react-table": "^8.21.3",
95
96
  "class-variance-authority": "^0.7.1",
96
97
  "clsx": "^2.1.1",
97
98
  "cmdk": "^1.1.1",
@@ -0,0 +1,76 @@
1
+ import { range, toBasicISOString } from '@douglasneuroinformatics/libjs';
2
+ import { faker } from '@faker-js/faker';
3
+ import type { Meta, StoryObj } from '@storybook/react';
4
+
5
+ import { DataTable } from './DataTable';
6
+
7
+ import type { DataTableColumn } from './DataTable';
8
+
9
+ type User = {
10
+ birthday: Date;
11
+ email: string;
12
+ firstName: string;
13
+ lastName: string;
14
+ };
15
+
16
+ type Story = StoryObj<typeof DataTable<User>>;
17
+
18
+ const columns: DataTableColumn<User>[] = [
19
+ {
20
+ format: (value) => {
21
+ return toBasicISOString(value);
22
+ },
23
+ key: 'birthday',
24
+ label: 'Birthday'
25
+ },
26
+ {
27
+ format: 'email',
28
+ key: 'email',
29
+ label: 'Email',
30
+ sortable: true
31
+ }
32
+ ];
33
+
34
+ const data: User[] = range(60)
35
+ .unwrap()
36
+ .map(() => ({
37
+ birthday: faker.date.birthdate(),
38
+ email: faker.internet.email(),
39
+ firstName: faker.person.firstName(),
40
+ lastName: faker.person.lastName()
41
+ }));
42
+
43
+ export default { component: DataTable } as Meta<typeof DataTable<User>>;
44
+
45
+ export const Default: Story = {
46
+ args: {
47
+ columns,
48
+ data,
49
+ headerAction: {
50
+ label: 'Do Something',
51
+ onClick: () => {
52
+ alert('Something!');
53
+ }
54
+ },
55
+ rowActions: [
56
+ {
57
+ destructive: true,
58
+ label: 'Delete',
59
+ onSelect: (row) => {
60
+ alert(`Delete User: ${row.firstName} ${row.lastName}`);
61
+ }
62
+ }
63
+ ],
64
+ search: {
65
+ key: 'email',
66
+ placeholder: 'Filter emails...'
67
+ }
68
+ }
69
+ };
70
+
71
+ export const Empty: Story = {
72
+ args: {
73
+ columns,
74
+ data: []
75
+ }
76
+ };
@@ -0,0 +1,289 @@
1
+ import { Fragment, useEffect, useMemo, useState } from 'react';
2
+
3
+ import {
4
+ flexRender,
5
+ getCoreRowModel,
6
+ getFilteredRowModel,
7
+ getPaginationRowModel,
8
+ getSortedRowModel,
9
+ useReactTable
10
+ } from '@tanstack/react-table';
11
+ import type { ColumnDef, ColumnFiltersState, SortingState } from '@tanstack/react-table';
12
+ import { range } from 'lodash-es';
13
+ import { ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon } from 'lucide-react';
14
+ import type { Promisable } from 'type-fest';
15
+
16
+ import { useTranslation } from '@/hooks';
17
+
18
+ import { Button } from '../Button';
19
+ import { SearchBar } from '../SearchBar';
20
+ import { Table } from '../Table';
21
+ import { DestructiveActionDialog } from './DestructiveActionDialog';
22
+ import { RowActionsDropdown } from './RowActionsDropdown';
23
+
24
+ import type { RowAction } from './RowActionsDropdown';
25
+
26
+ type StaticDataTableColumn<TData extends { [key: string]: unknown }> = {
27
+ [K in Extract<keyof TData, string>]: {
28
+ format?: 'email' | ((val: TData[K]) => unknown);
29
+ key: K;
30
+ label: string;
31
+ sortable?: boolean;
32
+ };
33
+ }[Extract<keyof TData, string>];
34
+
35
+ type DynamicDataTableColumn<TData extends { [key: string]: unknown }> = {
36
+ compute: (row: TData) => unknown;
37
+ key?: never;
38
+ label: string;
39
+ };
40
+
41
+ type DataTableColumn<TData extends { [key: string]: unknown }> =
42
+ | DynamicDataTableColumn<TData>
43
+ | StaticDataTableColumn<TData>;
44
+
45
+ type DataTableProps<TData extends { [key: string]: unknown }> = {
46
+ columns: DataTableColumn<TData>[];
47
+ data: TData[];
48
+ headerAction?: {
49
+ label: string;
50
+ onClick: () => void;
51
+ };
52
+ rowActions?: RowAction<TData>[];
53
+ search?: {
54
+ key: Extract<keyof TData, string>;
55
+ placeholder?: string;
56
+ };
57
+ };
58
+
59
+ function isStaticColumn<TData extends { [key: string]: unknown }>(
60
+ column: DataTableColumn<TData>
61
+ ): column is StaticDataTableColumn<TData> {
62
+ return typeof column.key === 'string';
63
+ }
64
+
65
+ export const DataTable = <TData extends { [key: string]: unknown }>({
66
+ columns,
67
+ data,
68
+ headerAction,
69
+ rowActions,
70
+ search
71
+ }: DataTableProps<TData>) => {
72
+ const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
73
+ const [sorting, setSorting] = useState<SortingState>([]);
74
+ const [searchValue, setSearchValue] = useState('');
75
+ const [destructiveActionPending, setDestructiveActionPending] = useState<(() => Promisable<void>) | null>(null);
76
+ const { t } = useTranslation('libui');
77
+ const [pagination, setPagination] = useState({
78
+ pageIndex: 0,
79
+ pageSize: 10
80
+ });
81
+
82
+ const columnDefs = useMemo<ColumnDef<TData, unknown>[]>(() => {
83
+ const result: ColumnDef<TData, unknown>[] = columns.map((col) => {
84
+ let def: ColumnDef<TData, unknown>;
85
+ if (isStaticColumn(col)) {
86
+ def = {
87
+ accessorKey: col.key,
88
+ header: col.sortable
89
+ ? ({ column }) => (
90
+ <button
91
+ className="flex items-center justify-between gap-2"
92
+ onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
93
+ >
94
+ {col.label}
95
+ <ArrowUpDownIcon className="h-4 w-4" />
96
+ </button>
97
+ )
98
+ : col.label
99
+ };
100
+ if (col.format) {
101
+ def.cell = ({ getValue }) => {
102
+ const value = getValue() as TData[Extract<keyof TData, string>];
103
+ if (typeof col.format === 'function') {
104
+ return col.format(value);
105
+ } else if (col.format === 'email') {
106
+ return (
107
+ <a className="hover:underline" href={`mailto:${value as string}`}>
108
+ {value as string}
109
+ </a>
110
+ );
111
+ }
112
+ return value;
113
+ };
114
+ }
115
+ } else {
116
+ def = {
117
+ accessorFn: col.compute,
118
+ header: col.label
119
+ };
120
+ }
121
+ return def;
122
+ });
123
+
124
+ if (rowActions) {
125
+ result.push({
126
+ cell: ({ row }) => {
127
+ return (
128
+ <RowActionsDropdown
129
+ row={row}
130
+ rowActions={rowActions}
131
+ setDestructiveActionPending={setDestructiveActionPending}
132
+ />
133
+ );
134
+ },
135
+ id: '__actions'
136
+ });
137
+ }
138
+ return result;
139
+ }, [columns]);
140
+
141
+ const table = useReactTable({
142
+ columns: columnDefs,
143
+ data,
144
+ getCoreRowModel: getCoreRowModel(),
145
+ getFilteredRowModel: getFilteredRowModel(),
146
+ getPaginationRowModel: getPaginationRowModel(),
147
+ getSortedRowModel: getSortedRowModel(),
148
+ onColumnFiltersChange: setColumnFilters,
149
+ onPaginationChange: setPagination,
150
+ onSortingChange: setSorting,
151
+ state: {
152
+ columnFilters,
153
+ pagination,
154
+ sorting
155
+ }
156
+ });
157
+
158
+ useEffect(() => {
159
+ if (search) {
160
+ table.getColumn(search.key)?.setFilterValue(searchValue);
161
+ }
162
+ }, [table, searchValue]);
163
+
164
+ const headerGroups = table.getHeaderGroups();
165
+ const { rows } = table.getRowModel();
166
+
167
+ const pageCount = table.getPageCount();
168
+ const currentPage = pagination.pageIndex;
169
+
170
+ const start = Math.max(0, Math.min(currentPage - 1, pageCount - 3));
171
+ const end = Math.min(start + 3, pageCount);
172
+
173
+ const pageIndexOptions = range(start, end);
174
+
175
+ return (
176
+ <Fragment>
177
+ <DestructiveActionDialog
178
+ destructiveActionPending={destructiveActionPending}
179
+ setDestructiveActionPending={setDestructiveActionPending}
180
+ />
181
+ {search && (
182
+ <div className="flex items-center gap-4 py-4">
183
+ <SearchBar
184
+ className="grow"
185
+ placeholder={search.placeholder}
186
+ value={searchValue}
187
+ onValueChange={setSearchValue}
188
+ />
189
+ {headerAction && (
190
+ <Button type="button" variant="outline" onClick={headerAction.onClick}>
191
+ {headerAction.label}
192
+ </Button>
193
+ )}
194
+ </div>
195
+ )}
196
+ <div className="rounded-md border">
197
+ <Table>
198
+ <Table.Header>
199
+ {headerGroups.map((headerGroup) => (
200
+ <Table.Row key={headerGroup.id}>
201
+ {headerGroup.headers.map((header) => {
202
+ return (
203
+ <Table.Head key={header.id}>
204
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
205
+ </Table.Head>
206
+ );
207
+ })}
208
+ </Table.Row>
209
+ ))}
210
+ </Table.Header>
211
+ <Table.Body>
212
+ {rows?.length ? (
213
+ rows.map((row) => (
214
+ <Table.Row data-state={row.getIsSelected() && 'selected'} key={row.id}>
215
+ {row.getVisibleCells().map((cell) => (
216
+ <Table.Cell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</Table.Cell>
217
+ ))}
218
+ </Table.Row>
219
+ ))
220
+ ) : (
221
+ <Table.Row>
222
+ <Table.Cell className="h-24 text-center" colSpan={rowActions ? columns.length + 1 : columns.length}>
223
+ {t({
224
+ en: 'No Results',
225
+ fr: 'Aucun résultat'
226
+ })}
227
+ </Table.Cell>
228
+ </Table.Row>
229
+ )}
230
+ </Table.Body>
231
+ </Table>
232
+ </div>
233
+ <div className="mx-auto flex w-min pt-6 pb-4">
234
+ <Button
235
+ className="flex gap-1"
236
+ disabled={!table.getCanPreviousPage()}
237
+ type="button"
238
+ variant="ghost"
239
+ onClick={() => table.firstPage()}
240
+ >
241
+ <ChevronsLeftIcon className="-ml-1 h-4 w-4" />
242
+ <span>{t('pagination.first')}</span>
243
+ </Button>
244
+ <Button
245
+ className="mr-1 flex gap-1"
246
+ disabled={!table.getCanPreviousPage()}
247
+ type="button"
248
+ variant="ghost"
249
+ onClick={() => table.previousPage()}
250
+ >
251
+ <ChevronLeftIcon className="-ml-1 h-4 w-4" />
252
+ <span>{t('pagination.previous')}</span>
253
+ </Button>
254
+ {pageIndexOptions.map((index) => (
255
+ <Button
256
+ key={index}
257
+ type="button"
258
+ variant={index === pagination.pageIndex ? 'outline' : 'ghost'}
259
+ onClick={() => table.setPageIndex(index)}
260
+ >
261
+ {index + 1}
262
+ </Button>
263
+ ))}
264
+ <Button
265
+ className="ml-1 flex gap-1"
266
+ disabled={!table.getCanNextPage()}
267
+ type="button"
268
+ variant="ghost"
269
+ onClick={() => table.nextPage()}
270
+ >
271
+ <span>{t('pagination.next')}</span>
272
+ <ChevronRightIcon className="-mr-1 h-4 w-4" />
273
+ </Button>
274
+ <Button
275
+ className="flex gap-1"
276
+ disabled={!table.getCanNextPage()}
277
+ type="button"
278
+ variant="ghost"
279
+ onClick={() => table.lastPage()}
280
+ >
281
+ <span>{t('pagination.last')}</span>
282
+ <ChevronsRightIcon className="-mr-1 h-4 w-4" />
283
+ </Button>
284
+ </div>
285
+ </Fragment>
286
+ );
287
+ };
288
+
289
+ export type { DataTableColumn };
@@ -0,0 +1,71 @@
1
+ /* eslint-disable @typescript-eslint/no-misused-promises */
2
+ /* eslint-disable jsx-a11y/no-autofocus */
3
+
4
+ import type React from 'react';
5
+
6
+ import type { Promisable } from 'type-fest';
7
+
8
+ import { useTranslation } from '@/hooks';
9
+
10
+ import { Button } from '../Button';
11
+ import { Dialog } from '../Dialog';
12
+
13
+ export type DestructiveActionPending = (() => Promisable<void>) | null;
14
+
15
+ export const DestructiveActionDialog: React.FC<{
16
+ destructiveActionPending: DestructiveActionPending;
17
+ setDestructiveActionPending: React.Dispatch<React.SetStateAction<DestructiveActionPending>>;
18
+ }> = ({ destructiveActionPending, setDestructiveActionPending }) => {
19
+ const { t } = useTranslation();
20
+ return (
21
+ <Dialog
22
+ open={destructiveActionPending !== null}
23
+ onOpenChange={(open) => {
24
+ if (!open) {
25
+ setDestructiveActionPending(null);
26
+ }
27
+ }}
28
+ >
29
+ <Dialog.Content>
30
+ <Dialog.Header>
31
+ <Dialog.Title>
32
+ {t({
33
+ en: 'Confirm Action',
34
+ fr: "Confirmer l'action"
35
+ })}
36
+ </Dialog.Title>
37
+ <Dialog.Description>
38
+ {t({
39
+ en: 'This action cannot be reversed. Please confirm that you would like to continue.',
40
+ fr: 'Cette action ne peut être inversée. Veuillez confirmer que vous souhaitez poursuivre.'
41
+ })}
42
+ </Dialog.Description>
43
+ </Dialog.Header>
44
+ <Dialog.Footer>
45
+ <Button
46
+ className="min-w-16"
47
+ size="sm"
48
+ type="button"
49
+ variant="outline"
50
+ onClick={async () => {
51
+ await destructiveActionPending?.();
52
+ setDestructiveActionPending(null);
53
+ }}
54
+ >
55
+ {t('libui.yes')}
56
+ </Button>
57
+ <Button
58
+ autoFocus={true}
59
+ className="min-w-16"
60
+ size="sm"
61
+ type="button"
62
+ variant="primary"
63
+ onClick={() => setDestructiveActionPending(null)}
64
+ >
65
+ {t('libui.no')}
66
+ </Button>
67
+ </Dialog.Footer>
68
+ </Dialog.Content>
69
+ </Dialog>
70
+ );
71
+ };
@@ -0,0 +1,64 @@
1
+ import type React from 'react';
2
+
3
+ import type { Row } from '@tanstack/react-table';
4
+ import { MoreHorizontalIcon } from 'lucide-react';
5
+ import type { Promisable } from 'type-fest';
6
+
7
+ import { useTranslation } from '@/hooks';
8
+
9
+ import { Button } from '../Button';
10
+ import { DropdownMenu } from '../DropdownMenu';
11
+
12
+ import type { DestructiveActionPending } from './DestructiveActionDialog';
13
+
14
+ export type RowAction<TData extends { [key: string]: unknown }> = {
15
+ destructive?: boolean;
16
+ label: string;
17
+ onSelect: (row: TData) => Promisable<void>;
18
+ };
19
+
20
+ export const RowActionsDropdown = <TData extends { [key: string]: unknown }>({
21
+ row,
22
+ rowActions,
23
+ setDestructiveActionPending
24
+ }: {
25
+ row: Row<TData>;
26
+ rowActions: RowAction<TData>[];
27
+ setDestructiveActionPending: React.Dispatch<React.SetStateAction<DestructiveActionPending>>;
28
+ }) => {
29
+ const { t } = useTranslation();
30
+ return (
31
+ <div className="flex w-full justify-end">
32
+ <DropdownMenu>
33
+ <DropdownMenu.Trigger asChild>
34
+ <Button className="-m-1.5" size="icon" variant="ghost">
35
+ <MoreHorizontalIcon className="h-4 w-4" />
36
+ </Button>
37
+ </DropdownMenu.Trigger>
38
+ <DropdownMenu.Content align="end">
39
+ <DropdownMenu.Label>
40
+ {t({
41
+ en: 'Actions',
42
+ fr: 'Actions'
43
+ })}
44
+ </DropdownMenu.Label>
45
+ {rowActions.map(({ destructive, label, onSelect }, i) => (
46
+ <DropdownMenu.Item
47
+ className={destructive ? 'text-destructive' : undefined}
48
+ key={i}
49
+ onSelect={() => {
50
+ if (destructive) {
51
+ setDestructiveActionPending(() => () => void onSelect(row.original));
52
+ } else {
53
+ void onSelect(row.original);
54
+ }
55
+ }}
56
+ >
57
+ {label}
58
+ </DropdownMenu.Item>
59
+ ))}
60
+ </DropdownMenu.Content>
61
+ </DropdownMenu>
62
+ </div>
63
+ );
64
+ };
@@ -0,0 +1 @@
1
+ export * from './DataTable';
@@ -6,7 +6,7 @@ import { cn } from '@/utils';
6
6
 
7
7
  export const ErrorMessage: React.FC<{ className?: string; error?: null | string[] }> = ({ className, error }) => {
8
8
  return error ? (
9
- <div className="space-y-1.5">
9
+ <div className="flex flex-col gap-1.5">
10
10
  {error.map((message) => (
11
11
  <div className={cn('text-destructive flex w-full items-center text-sm font-medium', className)} key={message}>
12
12
  <CircleAlertIcon className="mr-1" style={{ strokeWidth: '2px' }} />
@@ -302,6 +302,9 @@ const ungroupedContent = {
302
302
  } as const;
303
303
 
304
304
  export default {
305
+ args: {
306
+ onBeforeSubmit: null
307
+ },
305
308
  component: Form,
306
309
  decorators: [
307
310
  (Story) => (