@umituz/web-design-system 4.12.0 → 4.13.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": "@umituz/web-design-system",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Web Design System - Tailwind v4 authored, precompiled CSS shipped. Atomic Design components (Atoms, Molecules, Organisms, Templates) for React applications.",
|
|
@@ -346,6 +346,75 @@ describe('DataTable', () => {
|
|
|
346
346
|
expect(container.querySelector('nav')).toBeNull();
|
|
347
347
|
expect(container.textContent).toContain('3 results');
|
|
348
348
|
});
|
|
349
|
+
|
|
350
|
+
it('selectable rows fire onSelectionChange and expose the bulkBar selection', () => {
|
|
351
|
+
const calls: string[][] = [];
|
|
352
|
+
render(
|
|
353
|
+
<DataTable<Row>
|
|
354
|
+
data={rows}
|
|
355
|
+
rowKey={(r) => r.id}
|
|
356
|
+
selectable
|
|
357
|
+
onSelectionChange={(ids) => calls.push([...ids])}
|
|
358
|
+
bulkBar={({ ids, clear }) => (
|
|
359
|
+
<div>
|
|
360
|
+
<span>{ids.length} selected</span>
|
|
361
|
+
<button type="button" onClick={clear}>
|
|
362
|
+
clear
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
)}
|
|
366
|
+
columns={[{ id: 'name', header: 'Name', accessor: 'name' }]}
|
|
367
|
+
/>,
|
|
368
|
+
);
|
|
369
|
+
const boxes = screen.getAllByRole('checkbox');
|
|
370
|
+
expect(boxes.length).toBe(3);
|
|
371
|
+
expect(screen.queryByText('1 selected')).toBeNull();
|
|
372
|
+
fireEvent.click(boxes[1]!);
|
|
373
|
+
expect(calls[calls.length - 1]).toEqual(['r2']);
|
|
374
|
+
expect(screen.getByText('1 selected')).toBeTruthy();
|
|
375
|
+
fireEvent.click(screen.getByRole('button', { name: 'clear' }));
|
|
376
|
+
expect(calls[calls.length - 1]).toEqual([]);
|
|
377
|
+
expect(screen.queryByText('1 selected')).toBeNull();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('header checkbox toggles the page and reflects partial selection', () => {
|
|
381
|
+
render(
|
|
382
|
+
<DataTable<Row>
|
|
383
|
+
data={rows}
|
|
384
|
+
rowKey={(r) => r.id}
|
|
385
|
+
selectable
|
|
386
|
+
columns={[{ id: 'name', header: 'Name', accessor: 'name' }]}
|
|
387
|
+
/>,
|
|
388
|
+
);
|
|
389
|
+
const header = screen.getAllByRole('checkbox')[0] as HTMLInputElement;
|
|
390
|
+
const first = screen.getAllByRole('checkbox')[1] as HTMLInputElement;
|
|
391
|
+
fireEvent.click(first);
|
|
392
|
+
expect(header.indeterminate).toBe(true);
|
|
393
|
+
fireEvent.click(header);
|
|
394
|
+
const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
|
|
395
|
+
expect(boxes.map((box) => box.checked)).toEqual([true, true, true]);
|
|
396
|
+
fireEvent.click(header);
|
|
397
|
+
expect(boxes.slice(1).every((box) => !box.checked)).toBe(true);
|
|
398
|
+
expect(header.indeterminate).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('hides optional columns until enabled and offers the columns menu with the toolbar', () => {
|
|
402
|
+
render(
|
|
403
|
+
<DataTable<Row>
|
|
404
|
+
data={rows}
|
|
405
|
+
toolbar={<input aria-label="filter rows" />}
|
|
406
|
+
columns={[
|
|
407
|
+
{ id: 'name', header: 'Name', accessor: 'name' },
|
|
408
|
+
{ id: 'qty', header: 'Qty', accessor: 'qty', optional: true },
|
|
409
|
+
]}
|
|
410
|
+
/>,
|
|
411
|
+
);
|
|
412
|
+
expect(screen.getByRole('columnheader', { name: /name/i })).toBeTruthy();
|
|
413
|
+
expect(screen.queryByRole('columnheader', { name: /qty/i })).toBeNull();
|
|
414
|
+
expect(screen.queryByText('10')).toBeNull();
|
|
415
|
+
expect(screen.getByRole('button', { name: /columns/i })).toBeTruthy();
|
|
416
|
+
expect(screen.getByLabelText('filter rows')).toBeTruthy();
|
|
417
|
+
});
|
|
349
418
|
});
|
|
350
419
|
|
|
351
420
|
describe('controlled surfaces', () => {
|
|
@@ -3,7 +3,15 @@
|
|
|
3
3
|
* @description Checkbox input element with optional label
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
forwardRef,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useId,
|
|
11
|
+
useRef,
|
|
12
|
+
type InputHTMLAttributes,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from 'react';
|
|
7
15
|
import { cn } from '../../infrastructure/utils';
|
|
8
16
|
import type { BaseProps } from '../../domain/types';
|
|
9
17
|
|
|
@@ -13,6 +21,7 @@ export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement
|
|
|
13
21
|
size?: 'sm' | 'md' | 'lg';
|
|
14
22
|
label?: ReactNode;
|
|
15
23
|
hint?: ReactNode;
|
|
24
|
+
indeterminate?: boolean;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
const sizeStyles = {
|
|
@@ -22,10 +31,22 @@ const sizeStyles = {
|
|
|
22
31
|
};
|
|
23
32
|
|
|
24
33
|
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|
25
|
-
({ className, checked, onCheckedChange, size = 'md', disabled, onChange, label, hint, id, ...props }, ref) => {
|
|
34
|
+
({ className, checked, onCheckedChange, size = 'md', disabled, onChange, label, hint, id, indeterminate, ...props }, ref) => {
|
|
26
35
|
const generatedId = useId();
|
|
27
36
|
const checkboxId = id ?? generatedId;
|
|
28
37
|
const hintId = `${checkboxId}-hint`;
|
|
38
|
+
const innerRef = useRef<HTMLInputElement | null>(null);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (innerRef.current) innerRef.current.indeterminate = indeterminate === true;
|
|
41
|
+
}, [indeterminate]);
|
|
42
|
+
const setRef = useCallback(
|
|
43
|
+
(node: HTMLInputElement | null) => {
|
|
44
|
+
innerRef.current = node;
|
|
45
|
+
if (typeof ref === 'function') ref(node);
|
|
46
|
+
else if (ref) ref.current = node;
|
|
47
|
+
},
|
|
48
|
+
[ref],
|
|
49
|
+
);
|
|
29
50
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
30
51
|
onCheckedChange?.(e.target.checked);
|
|
31
52
|
onChange?.(e);
|
|
@@ -33,7 +54,7 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
|
|
33
54
|
|
|
34
55
|
const control = (
|
|
35
56
|
<input
|
|
36
|
-
ref={
|
|
57
|
+
ref={setRef}
|
|
37
58
|
id={checkboxId}
|
|
38
59
|
type="checkbox"
|
|
39
60
|
checked={checked}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ChevronsLeft,
|
|
9
9
|
ChevronsRight,
|
|
10
10
|
ChevronsUpDown,
|
|
11
|
+
Columns3,
|
|
11
12
|
} from 'lucide-react';
|
|
12
13
|
import { cn } from '../../infrastructure/utils';
|
|
13
14
|
import {
|
|
@@ -22,6 +23,13 @@ import {
|
|
|
22
23
|
} from './Table';
|
|
23
24
|
import { Button } from '../atoms/Button';
|
|
24
25
|
import { Skeleton } from '../atoms/Skeleton';
|
|
26
|
+
import { Checkbox } from '../atoms/Checkbox';
|
|
27
|
+
import {
|
|
28
|
+
DropdownMenu,
|
|
29
|
+
DropdownMenuTrigger,
|
|
30
|
+
DropdownMenuContent,
|
|
31
|
+
DropdownMenuCheckboxItem,
|
|
32
|
+
} from './DropdownMenu';
|
|
25
33
|
import {
|
|
26
34
|
computeTotalPages,
|
|
27
35
|
clampPageNumber,
|
|
@@ -51,6 +59,8 @@ export interface Column<T> {
|
|
|
51
59
|
hideBelow?: ColumnHideBelow;
|
|
52
60
|
width?: string;
|
|
53
61
|
className?: string;
|
|
62
|
+
/** Hidden until the reader enables it from the built-in columns menu. */
|
|
63
|
+
optional?: boolean;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
export interface TablePaginationProps extends BaseProps {
|
|
@@ -72,6 +82,11 @@ export interface DataTableProps<T> extends BaseProps {
|
|
|
72
82
|
onSortChange?: (id: string) => void;
|
|
73
83
|
rowKey?: (row: T) => string;
|
|
74
84
|
selectedId?: string;
|
|
85
|
+
selectable?: boolean;
|
|
86
|
+
selectedIds?: string[];
|
|
87
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
88
|
+
bulkBar?: (selection: { ids: string[]; clear: () => void }) => ReactNode;
|
|
89
|
+
toolbar?: ReactNode;
|
|
75
90
|
dense?: boolean;
|
|
76
91
|
loading?: boolean;
|
|
77
92
|
empty?: ReactNode;
|
|
@@ -239,6 +254,11 @@ export function DataTable<T>({
|
|
|
239
254
|
onSortChange,
|
|
240
255
|
rowKey,
|
|
241
256
|
selectedId,
|
|
257
|
+
selectable = false,
|
|
258
|
+
selectedIds,
|
|
259
|
+
onSelectionChange,
|
|
260
|
+
bulkBar,
|
|
261
|
+
toolbar,
|
|
242
262
|
dense = false,
|
|
243
263
|
loading = false,
|
|
244
264
|
empty,
|
|
@@ -254,10 +274,28 @@ export function DataTable<T>({
|
|
|
254
274
|
}: DataTableProps<T>) {
|
|
255
275
|
const [internalSort, setInternalSort] = useState<SortState | null>(initialSort ?? null);
|
|
256
276
|
const [currentPage, setCurrentPage] = useState(1);
|
|
277
|
+
const [internalSelection, setInternalSelection] = useState<string[]>([]);
|
|
278
|
+
const [hiddenColumnIds, setHiddenColumnIds] = useState<string[]>(() =>
|
|
279
|
+
columns.filter((column) => column.optional).map((column) => column.id),
|
|
280
|
+
);
|
|
257
281
|
|
|
258
282
|
const activeSort = sort !== undefined ? sort : internalSort;
|
|
259
283
|
const controlled = sort !== undefined;
|
|
260
284
|
|
|
285
|
+
const selectionEnabled = selectable && rowKey !== undefined;
|
|
286
|
+
const selectionControlled = selectedIds !== undefined;
|
|
287
|
+
const selection = selectionControlled ? selectedIds : internalSelection;
|
|
288
|
+
const selectionSet = useMemo(() => new Set(selection), [selection]);
|
|
289
|
+
|
|
290
|
+
const commitSelection = (next: string[]) => {
|
|
291
|
+
if (!selectionControlled) setInternalSelection(next);
|
|
292
|
+
onSelectionChange?.(next);
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const toggleRowSelection = (key: string) => {
|
|
296
|
+
commitSelection(selectionSet.has(key) ? selection.filter((id) => id !== key) : [...selection, key]);
|
|
297
|
+
};
|
|
298
|
+
|
|
261
299
|
const handleSort = (id: string) => {
|
|
262
300
|
if (!sortable) return;
|
|
263
301
|
if (controlled) {
|
|
@@ -292,6 +330,29 @@ export function DataTable<T>({
|
|
|
292
330
|
const safePage = clampPageNumber(currentPage, totalPages);
|
|
293
331
|
const pageBounds = computePageBounds(currentPage, pageSize, data.length);
|
|
294
332
|
|
|
333
|
+
const knownColumnIds = useMemo(() => new Set(columns.map((column) => column.id)), [columns]);
|
|
334
|
+
const effectiveHiddenColumnIds = useMemo(
|
|
335
|
+
() => hiddenColumnIds.filter((id) => knownColumnIds.has(id)),
|
|
336
|
+
[hiddenColumnIds, knownColumnIds],
|
|
337
|
+
);
|
|
338
|
+
const hasOptionalColumns = useMemo(() => columns.some((column) => column.optional), [columns]);
|
|
339
|
+
const visibleColumns = useMemo(
|
|
340
|
+
() => columns.filter((column) => !effectiveHiddenColumnIds.includes(column.id)),
|
|
341
|
+
[columns, effectiveHiddenColumnIds],
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const pageKeys = selectionEnabled ? paginatedData.map((row) => rowKey!(row)) : [];
|
|
345
|
+
const allPageSelected = pageKeys.length > 0 && pageKeys.every((key) => selectionSet.has(key));
|
|
346
|
+
const somePageSelected = pageKeys.some((key) => selectionSet.has(key));
|
|
347
|
+
const togglePageSelection = () => {
|
|
348
|
+
if (!selectionEnabled || pageKeys.length === 0) return;
|
|
349
|
+
commitSelection(
|
|
350
|
+
allPageSelected
|
|
351
|
+
? selection.filter((id) => !pageKeys.includes(id))
|
|
352
|
+
: [...new Set([...selection, ...pageKeys])],
|
|
353
|
+
);
|
|
354
|
+
};
|
|
355
|
+
|
|
295
356
|
const sizeStyles: Record<MediumSizes, string> = {
|
|
296
357
|
sm: 'text-xs',
|
|
297
358
|
md: 'text-sm',
|
|
@@ -341,11 +402,71 @@ export function DataTable<T>({
|
|
|
341
402
|
|
|
342
403
|
return (
|
|
343
404
|
<div className={cn('space-y-4', stackable && 'ds-datatable-stack', className)} {...props}>
|
|
405
|
+
{(toolbar !== undefined || hasOptionalColumns) && (
|
|
406
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
407
|
+
{toolbar !== undefined && <div className="flex flex-wrap items-center gap-2">{toolbar}</div>}
|
|
408
|
+
{hasOptionalColumns && (
|
|
409
|
+
<DropdownMenu>
|
|
410
|
+
<DropdownMenuTrigger asChild>
|
|
411
|
+
<Button variant="outline" size="sm">
|
|
412
|
+
<Columns3 className="h-4 w-4" aria-hidden="true" />
|
|
413
|
+
Columns
|
|
414
|
+
</Button>
|
|
415
|
+
</DropdownMenuTrigger>
|
|
416
|
+
<DropdownMenuContent align="end">
|
|
417
|
+
{columns.map((column) => (
|
|
418
|
+
<DropdownMenuCheckboxItem
|
|
419
|
+
key={column.id}
|
|
420
|
+
checked={!effectiveHiddenColumnIds.includes(column.id)}
|
|
421
|
+
disabled={!column.optional}
|
|
422
|
+
onCheckedChange={(checked) =>
|
|
423
|
+
setHiddenColumnIds((prev) =>
|
|
424
|
+
checked
|
|
425
|
+
? prev.filter((id) => id !== column.id)
|
|
426
|
+
: prev.includes(column.id)
|
|
427
|
+
? prev
|
|
428
|
+
: [...prev, column.id],
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
onSelect={(event) => event.preventDefault()}
|
|
432
|
+
>
|
|
433
|
+
{typeof column.header === 'string' ? column.header : column.id}
|
|
434
|
+
</DropdownMenuCheckboxItem>
|
|
435
|
+
))}
|
|
436
|
+
</DropdownMenuContent>
|
|
437
|
+
</DropdownMenu>
|
|
438
|
+
)}
|
|
439
|
+
</div>
|
|
440
|
+
)}
|
|
441
|
+
{selectionEnabled && bulkBar && selection.length > 0 && (
|
|
442
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm">
|
|
443
|
+
{bulkBar({ ids: selection, clear: () => commitSelection([]) })}
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
344
446
|
<Table>
|
|
345
447
|
{caption && <TableCaption>{caption}</TableCaption>}
|
|
346
448
|
<TableHeader>
|
|
347
449
|
<TableRow>
|
|
348
|
-
{
|
|
450
|
+
{selectionEnabled && (
|
|
451
|
+
<TableHead
|
|
452
|
+
className={cn(
|
|
453
|
+
'w-10',
|
|
454
|
+
pxStyles[size],
|
|
455
|
+
pyStyles[size],
|
|
456
|
+
sticky && 'sticky top-0 z-[1] bg-background/95 backdrop-blur',
|
|
457
|
+
)}
|
|
458
|
+
>
|
|
459
|
+
<Checkbox
|
|
460
|
+
size="sm"
|
|
461
|
+
checked={allPageSelected}
|
|
462
|
+
indeterminate={!allPageSelected && somePageSelected}
|
|
463
|
+
onCheckedChange={togglePageSelection}
|
|
464
|
+
disabled={pageKeys.length === 0}
|
|
465
|
+
aria-label="Select all rows on this page"
|
|
466
|
+
/>
|
|
467
|
+
</TableHead>
|
|
468
|
+
)}
|
|
469
|
+
{visibleColumns.map((column) => {
|
|
349
470
|
const isActiveSort = activeSort?.id === column.id;
|
|
350
471
|
return (
|
|
351
472
|
<TableHead
|
|
@@ -404,6 +525,7 @@ export function DataTable<T>({
|
|
|
404
525
|
{paginatedData.map((row, index) => {
|
|
405
526
|
const key = rowKey ? rowKey(row) : index;
|
|
406
527
|
const selected = rowKey !== undefined && selectedId !== undefined && rowKey(row) === selectedId;
|
|
528
|
+
const rowSelected = selectionEnabled && typeof key === 'string' && selectionSet.has(key);
|
|
407
529
|
const handleRowKeyDown = (event: ReactKeyboardEvent<HTMLTableRowElement>) => {
|
|
408
530
|
if (event.target !== event.currentTarget) return;
|
|
409
531
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
@@ -418,7 +540,7 @@ export function DataTable<T>({
|
|
|
418
540
|
aria-label={onRowClick && rowLabel ? rowLabel(row) : undefined}
|
|
419
541
|
className={cn(
|
|
420
542
|
onRowClick && 'cursor-pointer hover:bg-muted/50',
|
|
421
|
-
selected && 'bg-muted/60',
|
|
543
|
+
(selected || rowSelected) && 'bg-muted/60',
|
|
422
544
|
rowClass?.(row)
|
|
423
545
|
)}
|
|
424
546
|
onClick={
|
|
@@ -430,7 +552,17 @@ export function DataTable<T>({
|
|
|
430
552
|
}
|
|
431
553
|
onKeyDown={onRowClick ? handleRowKeyDown : undefined}
|
|
432
554
|
>
|
|
433
|
-
{
|
|
555
|
+
{selectionEnabled && typeof key === 'string' && (
|
|
556
|
+
<TableCell className={cn('w-10', pxStyles[size], pyStyles[size])}>
|
|
557
|
+
<Checkbox
|
|
558
|
+
size="sm"
|
|
559
|
+
checked={rowSelected}
|
|
560
|
+
onCheckedChange={() => toggleRowSelection(key)}
|
|
561
|
+
aria-label={rowLabel ? `Select ${rowLabel(row)}` : 'Select row'}
|
|
562
|
+
/>
|
|
563
|
+
</TableCell>
|
|
564
|
+
)}
|
|
565
|
+
{visibleColumns.map((column) => (
|
|
434
566
|
<TableCell
|
|
435
567
|
key={column.id}
|
|
436
568
|
data-label={stackable ? stackLabel(column) : undefined}
|
|
@@ -452,7 +584,7 @@ export function DataTable<T>({
|
|
|
452
584
|
{paginated && (
|
|
453
585
|
<TableFooter>
|
|
454
586
|
<TableRow>
|
|
455
|
-
<TableCell colSpan={
|
|
587
|
+
<TableCell colSpan={visibleColumns.length + (selectionEnabled ? 1 : 0)}>
|
|
456
588
|
<div className="flex items-center justify-between gap-2">
|
|
457
589
|
<p className={cn('text-muted-foreground', sizeStyles[size])}>
|
|
458
590
|
Showing {pageBounds.startIndex} to {pageBounds.endIndex} of {data.length} results
|