@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.12.0",
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 { forwardRef, useId, type InputHTMLAttributes, type ReactNode, useCallback } from 'react';
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={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
- {columns.map((column) => {
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
- {columns.map((column) => (
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={columns.length}>
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