@drivenets/design-system 0.14.0 → 0.15.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -1
  3. package/dist/index.cjs +371 -62
  4. package/dist/index.d.cts +107 -1
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.ts +107 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +370 -63
  9. package/dist/index.js.map +1 -1
  10. package/dist/index.min.css +1 -1
  11. package/package.json +4 -3
  12. package/src/components/ds-drawer/ds-drawer.module.scss +14 -0
  13. package/src/components/ds-drawer/ds-drawer.tsx +20 -1
  14. package/src/components/ds-drawer/ds-drawer.types.ts +7 -0
  15. package/src/components/ds-file-upload/hooks/use-file-upload.ts +7 -0
  16. package/src/components/ds-progress-task-bar/ds-progress-task-bar.figma.ts +29 -0
  17. package/src/components/ds-progress-task-bar/ds-progress-task-bar.module.scss +39 -0
  18. package/src/components/ds-progress-task-bar/ds-progress-task-bar.tsx +122 -0
  19. package/src/components/ds-progress-task-bar/ds-progress-task-bar.types.ts +65 -0
  20. package/src/components/ds-progress-task-bar/index.ts +2 -0
  21. package/src/components/ds-progress-task-bar/utils.ts +51 -0
  22. package/src/components/ds-table/components/ds-table-row/ds-table-row.module.scss +1 -1
  23. package/src/components/ds-table/components/ds-table-row/ds-table-row.tsx +2 -0
  24. package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.module.scss +1 -1
  25. package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.tsx +2 -0
  26. package/src/components/ds-table/components/edit/ds-table-editable-cell/ds-table-editable-cell.module.scss +14 -2
  27. package/src/components/ds-table/components/edit/ds-table-editable-cell/ds-table-editable-cell.tsx +19 -0
  28. package/src/components/ds-table/ds-table-column.types.ts +24 -0
  29. package/src/components/ds-table/ds-table.tsx +28 -4
  30. package/src/components/ds-table/ds-table.types.ts +7 -0
  31. package/src/components/ds-table/index.ts +1 -0
  32. package/src/components/ds-table/utils/constants.ts +5 -0
  33. package/src/components/ds-tabs/figma/ds-tabs-tab-vertical.figma.ts +78 -0
  34. package/src/components/ds-tabs/figma/ds-tabs-tab.figma.ts +89 -0
  35. package/src/components/ds-tabs/figma/ds-tabs-vertical.figma.ts +31 -0
  36. package/src/components/ds-tabs/figma/ds-tabs.figma.ts +29 -0
  37. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drivenets/design-system",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "types": "./dist/index.d.cts",
@@ -64,6 +64,7 @@
64
64
  "@eslint/compat": "^2.1.0",
65
65
  "@faker-js/faker": "^10.3.0",
66
66
  "@figma/code-connect": "^1.4.8",
67
+ "@github-ui/storybook-addon-performance-panel": "^1.1.4",
67
68
  "@hookform/resolvers": "^5.4.0",
68
69
  "@rolldown/plugin-babel": "^0.2.3",
69
70
  "@storybook/addon-a11y": "^10.4.1",
@@ -104,8 +105,8 @@
104
105
  "vitest": "^4.1.7",
105
106
  "vitest-browser-react": "^2.1.0",
106
107
  "zod": "^4.4.3",
107
- "@drivenets/eslint-plugin-internal": "0.0.0",
108
- "@drivenets/vite-plugin-design-system": "0.0.9"
108
+ "@drivenets/vite-plugin-design-system": "0.0.10",
109
+ "@drivenets/eslint-plugin-internal": "0.0.0"
109
110
  },
110
111
  "scripts": {
111
112
  "start": "storybook dev -p 6006",
@@ -56,6 +56,20 @@
56
56
  transform 0.2s ease,
57
57
  width 0.2s ease;
58
58
 
59
+ // A closed (or closing) drawer is invisible but stays mounted and transformed.
60
+ // Without this it still hit-tests and can swallow clicks on neighboring UI when
61
+ // docked at a non-zero offset. Keep it click-through whenever it is not open.
62
+ //
63
+ // `!important` is required: when this drawer is stacked with other open Ark
64
+ // dialogs (e.g. a non-modal drawer opened over another dialog), Ark's layer
65
+ // management writes an inline `pointer-events: auto` onto the content, which
66
+ // would otherwise win over this rule and let the closed drawer keep eating
67
+ // clicks. Overriding a third-party inline style is the canonical use of
68
+ // `!important`, and a closed drawer must never be a pointer target.
69
+ &[data-state='closed'] {
70
+ pointer-events: none !important;
71
+ }
72
+
59
73
  &.position-end {
60
74
  right: 0;
61
75
  transform-origin: 100% 50%;
@@ -1,5 +1,5 @@
1
1
  import { type CSSProperties, type ReactNode, Fragment } from 'react';
2
- import { Dialog } from '@ark-ui/react/dialog';
2
+ import { Dialog, type DialogRootProps } from '@ark-ui/react/dialog';
3
3
  import { Portal } from '@ark-ui/react/portal';
4
4
  import classNames from 'classnames';
5
5
  import type { DsDrawerProps } from './ds-drawer.types';
@@ -12,6 +12,7 @@ import { useResponsiveValue } from '../../utils/responsive';
12
12
  const DsDrawer = ({
13
13
  open,
14
14
  onOpenChange,
15
+ onOpenAutoFocus,
15
16
  columns = 4,
16
17
  position = 'end',
17
18
  backdrop = false,
@@ -29,6 +30,23 @@ const DsDrawer = ({
29
30
  onOpenChange(details.open);
30
31
  };
31
32
 
33
+ // Mirrors Radix's `onOpenAutoFocus`. Ark reads `initialFocusEl` when the drawer
34
+ // opens to decide what to focus. To honor `preventDefault()`, we point initial
35
+ // focus back at the element that was focused when the drawer opened (e.g. the
36
+ // input that triggered it), so the caret stays put. Returning `undefined` keeps
37
+ // Ark's default of focusing the first element inside the drawer. The cast covers
38
+ // the `undefined` branch, which sits outside Ark's `MaybeElement` return type.
39
+ const initialFocusEl = (
40
+ onOpenAutoFocus
41
+ ? () => {
42
+ const event = new Event('openAutoFocus', { cancelable: true });
43
+ onOpenAutoFocus(event);
44
+
45
+ return event.defaultPrevented ? (document.activeElement as HTMLElement | null) : undefined;
46
+ }
47
+ : undefined
48
+ ) as DialogRootProps['initialFocusEl'];
49
+
32
50
  const Wrapper = portal ? Portal : Fragment;
33
51
 
34
52
  return (
@@ -39,6 +57,7 @@ const DsDrawer = ({
39
57
  closeOnEscape={closeOnEscape}
40
58
  closeOnInteractOutside={closeOnInteractOutside}
41
59
  preventScroll={backdrop}
60
+ initialFocusEl={initialFocusEl}
42
61
  >
43
62
  <Wrapper>
44
63
  {backdrop && <Dialog.Backdrop className={styles.backdrop} />}
@@ -20,6 +20,13 @@ export interface DsDrawerProps extends Pick<DialogProps, 'closeOnEscape' | 'clos
20
20
  * Callback when the drawer open state changes
21
21
  */
22
22
  onOpenChange: (open: boolean) => void;
23
+ /**
24
+ * Called when focus is about to move into the drawer as it opens.
25
+ * Call `event.preventDefault()` to stop the drawer from stealing focus,
26
+ * keeping the caret where it was (e.g. in a text input that triggered the
27
+ * drawer). Useful for type-ahead / popover patterns.
28
+ */
29
+ onOpenAutoFocus?: (event: Event) => void;
23
30
  /**
24
31
  * Number of grid columns the drawer should span (1-12).
25
32
  * Accepts a responsive value, e.g. `{ lg: 4, md: 6 }`.
@@ -224,6 +224,13 @@ export function useFileUpload({
224
224
  updateFileProgress(fileId, 100);
225
225
  onFileUploadComplete?.(fileId, result);
226
226
  } catch (error) {
227
+ // A fired abort signal means the upload was cancelled or removed by the
228
+ // user. Adapters reject with a (retryable) error on abort, but that must
229
+ // not overwrite the terminal status owned by cancelUpload/removeFile.
230
+ if (abortController.signal.aborted) {
231
+ return;
232
+ }
233
+
227
234
  const errorMessage = error instanceof Error ? error.message : 'Upload failed';
228
235
  // Determine status based on error type
229
236
  // Unknown errors are treated as retryable (network errors, etc.)
@@ -0,0 +1,29 @@
1
+ // url=https://www.figma.com/design/nha3m67y7S57cHCSuQO2gp/DAP-Design-System-1.2?node-id=36987-43620
2
+ // source=https://github.com/drivenets/design-system/tree/main/packages/design-system/src/components/ds-progress-task-bar
3
+ // component=DsProgressTaskBar
4
+ import figma from 'figma';
5
+
6
+ const instance = figma.selectedInstance;
7
+
8
+ // Boolean property names are case-sensitive: `Running` / `Failed` are capitalized.
9
+ const zero = instance.getBoolean('zero');
10
+ const completed = instance.getBoolean('completed');
11
+ const running = instance.getBoolean('Running');
12
+ const failed = instance.getBoolean('Failed');
13
+
14
+ // Booleans only toggle a status on/off, so emit a representative count per status.
15
+ const attrs = [
16
+ completed ? 'completed={300}' : '',
17
+ running ? 'running={100}' : '',
18
+ failed ? 'failed={100}' : '',
19
+ 'total={1000}',
20
+ ]
21
+ .filter(Boolean)
22
+ .join(' ');
23
+
24
+ export default {
25
+ example: zero ? figma.code`<DsProgressTaskBar total={999} />` : figma.code`<DsProgressTaskBar ${attrs} />`,
26
+ imports: ["import { DsProgressTaskBar } from '@drivenets/design-system'"],
27
+ id: 'ds-progress-task-bar',
28
+ metadata: { nestable: false },
29
+ };
@@ -0,0 +1,39 @@
1
+ $bar-height: 8px;
2
+ $segment-min-width: 4px;
3
+
4
+ .statusIcon {
5
+ display: inline-flex;
6
+ }
7
+
8
+ .total {
9
+ flex-shrink: 0;
10
+ }
11
+
12
+ .bar {
13
+ display: flex;
14
+ align-items: center;
15
+ width: 100%;
16
+ height: $bar-height;
17
+ overflow: hidden;
18
+ background-color: var(--background-action-secondary-hover);
19
+ border-radius: var(--3xs);
20
+ }
21
+
22
+ .segment {
23
+ flex: none;
24
+ min-width: $segment-min-width;
25
+ height: 100%;
26
+ transition: width 0.2s ease;
27
+ }
28
+
29
+ .segmentCompleted {
30
+ background-color: var(--background-success-medium);
31
+ }
32
+
33
+ .segmentRunning {
34
+ background-color: var(--background-light-primary);
35
+ }
36
+
37
+ .segmentFailed {
38
+ background-color: var(--background-error);
39
+ }
@@ -0,0 +1,122 @@
1
+ import classNames from 'classnames';
2
+
3
+ import { DsIcon, type IconColor, type IconName } from '../ds-icon';
4
+ import { DsStack } from '../ds-stack';
5
+ import { DsTooltip } from '../ds-tooltip';
6
+ import { DsTypography, type TypographyColor } from '../ds-typography';
7
+
8
+ import styles from './ds-progress-task-bar.module.scss';
9
+ import {
10
+ type DsProgressTaskBarLocale,
11
+ type DsProgressTaskBarProps,
12
+ type TaskBarStatus,
13
+ taskBarStatuses,
14
+ } from './ds-progress-task-bar.types';
15
+ import { formatCount } from './utils';
16
+
17
+ const statusIcon: Record<TaskBarStatus, IconName> = Object.freeze({
18
+ completed: 'check_circle',
19
+ running: 'play_circle',
20
+ failed: 'cancel',
21
+ });
22
+
23
+ const statusIconColor: Record<TaskBarStatus, IconColor> = Object.freeze({
24
+ completed: 'success',
25
+ running: 'action',
26
+ failed: 'error',
27
+ });
28
+
29
+ const statusTextColor: Record<TaskBarStatus, TypographyColor> = Object.freeze({
30
+ completed: 'success',
31
+ running: 'action',
32
+ failed: 'error',
33
+ });
34
+
35
+ const statusSegmentClass: Record<TaskBarStatus, string> = Object.freeze({
36
+ completed: styles.segmentCompleted,
37
+ running: styles.segmentRunning,
38
+ failed: styles.segmentFailed,
39
+ });
40
+
41
+ const defaultLocale: Required<DsProgressTaskBarLocale> = Object.freeze({
42
+ completed: 'Completed',
43
+ running: 'Running',
44
+ failed: 'Failed',
45
+ total: (total: string) => `of ${total}`,
46
+ });
47
+
48
+ const DsProgressTaskBar = ({
49
+ completed = 0,
50
+ running = 0,
51
+ failed = 0,
52
+ total,
53
+ locale,
54
+ className,
55
+ style,
56
+ ref,
57
+ }: DsProgressTaskBarProps) => {
58
+ const counts: Record<TaskBarStatus, number> = {
59
+ completed: Math.max(0, completed),
60
+ running: Math.max(0, running),
61
+ failed: Math.max(0, failed),
62
+ };
63
+
64
+ const sum = counts.completed + counts.running + counts.failed;
65
+ const denominator = Math.max(total ?? sum, sum);
66
+
67
+ const mergedLocale = { ...defaultLocale, ...locale };
68
+ const activeStatuses = taskBarStatuses.filter((status) => counts[status] > 0);
69
+
70
+ const segmentWidth = (status: TaskBarStatus) =>
71
+ denominator > 0 ? `${String((counts[status] / denominator) * 100)}%` : '0%';
72
+
73
+ return (
74
+ <DsStack ref={ref} direction="column" gap="var(--xs)" width="100%" className={className} style={style}>
75
+ <DsStack direction="row" alignItems="center" justifyContent="space-between">
76
+ <DsStack direction="row" alignItems="center" gap="var(--3xs)">
77
+ {activeStatuses.length === 0 ? (
78
+ <DsTypography variant="body-xs-reg" color="secondary">
79
+ 0
80
+ </DsTypography>
81
+ ) : (
82
+ activeStatuses.map((status) => (
83
+ <DsStack key={status} direction="row" alignItems="center" gap="var(--4xs)">
84
+ <DsTooltip content={mergedLocale[status]}>
85
+ <span className={styles.statusIcon}>
86
+ <DsIcon icon={statusIcon[status]} size="tiny" color={statusIconColor[status]} filled />
87
+ </span>
88
+ </DsTooltip>
89
+ <DsTypography variant="body-xs-reg" color={statusTextColor[status]}>
90
+ {formatCount(counts[status])}
91
+ </DsTypography>
92
+ </DsStack>
93
+ ))
94
+ )}
95
+ </DsStack>
96
+
97
+ <DsTypography variant="body-xs-reg" color="secondary" className={styles.total}>
98
+ {mergedLocale.total(formatCount(denominator))}
99
+ </DsTypography>
100
+ </DsStack>
101
+
102
+ <div
103
+ className={styles.bar}
104
+ role="progressbar"
105
+ aria-valuemin={0}
106
+ aria-valuemax={denominator}
107
+ aria-valuenow={sum}
108
+ >
109
+ {activeStatuses.map((status) => (
110
+ <DsTooltip key={status} content={mergedLocale[status]}>
111
+ <span
112
+ className={classNames(styles.segment, statusSegmentClass[status])}
113
+ style={{ width: segmentWidth(status) }}
114
+ />
115
+ </DsTooltip>
116
+ ))}
117
+ </div>
118
+ </DsStack>
119
+ );
120
+ };
121
+
122
+ export default DsProgressTaskBar;
@@ -0,0 +1,65 @@
1
+ import type { CSSProperties, Ref } from 'react';
2
+
3
+ export const taskBarStatuses = ['completed', 'running', 'failed'] as const;
4
+ export type TaskBarStatus = (typeof taskBarStatuses)[number];
5
+
6
+ export interface DsProgressTaskBarLocale {
7
+ /** @default 'Completed' */
8
+ completed?: string;
9
+ /** @default 'Running' */
10
+ running?: string;
11
+ /** @default 'Failed' */
12
+ failed?: string;
13
+ /**
14
+ * Builds the trailing total label from the already-formatted total value.
15
+ * @default (total) => `of ${total}`
16
+ */
17
+ total?: (total: string) => string;
18
+ }
19
+
20
+ export interface DsProgressTaskBarProps {
21
+ /**
22
+ * Number of completed tasks.
23
+ * @default 0
24
+ */
25
+ completed?: number;
26
+
27
+ /**
28
+ * Number of running tasks.
29
+ * @default 0
30
+ */
31
+ running?: number;
32
+
33
+ /**
34
+ * Number of failed tasks.
35
+ * @default 0
36
+ */
37
+ failed?: number;
38
+
39
+ /**
40
+ * Total task count used as the denominator for segment widths and shown as
41
+ * the trailing "of {total}" label. Defaults to the sum of the status counts
42
+ * (fully filled bar).
43
+ */
44
+ total?: number;
45
+
46
+ /**
47
+ * Localized strings (status tooltips and total label).
48
+ */
49
+ locale?: DsProgressTaskBarLocale;
50
+
51
+ /**
52
+ * Additional CSS class names
53
+ */
54
+ className?: string;
55
+
56
+ /**
57
+ * Additional styles to apply to the component
58
+ */
59
+ style?: CSSProperties;
60
+
61
+ /**
62
+ * Ref to the root element
63
+ */
64
+ ref?: Ref<HTMLDivElement>;
65
+ }
@@ -0,0 +1,2 @@
1
+ export { default as DsProgressTaskBar } from './ds-progress-task-bar';
2
+ export * from './ds-progress-task-bar.types';
@@ -0,0 +1,51 @@
1
+ interface CountUnit {
2
+ value: number;
3
+ suffix: string;
4
+ }
5
+
6
+ const COUNT_UNITS: readonly CountUnit[] = [
7
+ { value: 1_000_000_000, suffix: 'B' },
8
+ { value: 1_000_000, suffix: 'M' },
9
+ { value: 1_000, suffix: 'K' },
10
+ ];
11
+
12
+ // Counts below this are shown verbatim; at or above it they are abbreviated.
13
+ const ABBREVIATION_FLOOR = 1_000;
14
+
15
+ // Below this multiple of a unit, show one decimal place (e.g. 1.1K); at or
16
+ // above it, show a whole number (e.g. 87K).
17
+ const DECIMAL_THRESHOLD = 10;
18
+
19
+ const stripTrailingZero = (value: number): string => (value % 1 === 0 ? String(value) : value.toFixed(1));
20
+
21
+ /**
22
+ * Format a task count for the legend, following the design's abbreviation rules:
23
+ * - `< 1000` → exact number (e.g. `999`)
24
+ * - single-digit unit → one decimal (e.g. `1.1K`, `1.2M`)
25
+ * - multi-digit unit → whole number, truncated so `999,999 → 999K`
26
+ */
27
+ export const formatCount = (count: number): string => {
28
+ if (!Number.isFinite(count)) {
29
+ return '0';
30
+ }
31
+
32
+ const rounded = Math.max(0, Math.round(count));
33
+
34
+ if (rounded < ABBREVIATION_FLOOR) {
35
+ return String(rounded);
36
+ }
37
+
38
+ const unit = COUNT_UNITS.find(({ value }) => rounded >= value);
39
+
40
+ if (unit === undefined) {
41
+ return String(rounded);
42
+ }
43
+
44
+ const scaled = rounded / unit.value;
45
+
46
+ if (scaled < DECIMAL_THRESHOLD) {
47
+ return `${stripTrailingZero(Math.round(scaled * 10) / 10)}${unit.suffix}`;
48
+ }
49
+
50
+ return `${String(Math.floor(scaled))}${unit.suffix}`;
51
+ };
@@ -9,7 +9,7 @@
9
9
  }
10
10
  }
11
11
 
12
- &:hover {
12
+ &:not(.loadingRow):hover {
13
13
  background-color: var(--background-secondary);
14
14
  }
15
15
 
@@ -51,6 +51,7 @@ const DsTableRow = <TData,>({ ref, row, isSelected }: DsTableRowProps<TData>) =>
51
51
  primaryRowActions,
52
52
  secondaryRowActions,
53
53
  activeRowId,
54
+ loading,
54
55
  } = useDsTableContext<TData, unknown>();
55
56
  const isExpanded = row.getIsExpanded();
56
57
  const isActive = activeRowId === row.id;
@@ -90,6 +91,7 @@ const DsTableRow = <TData,>({ ref, row, isSelected }: DsTableRowProps<TData>) =>
90
91
  onRowClick && styles.clickableRow,
91
92
  !bordered && styles.rowNoBorder,
92
93
  isExpanded && styles.expanded,
94
+ loading && styles.loadingRow,
93
95
  )}
94
96
  style={rowStyle}
95
97
  onClick={() => onRowClick?.(row.original)}
@@ -7,7 +7,7 @@
7
7
  position: absolute;
8
8
  width: 100%;
9
9
 
10
- &:hover {
10
+ &:not(.loadingRow):hover {
11
11
  background-color: var(--background-secondary);
12
12
  }
13
13
 
@@ -26,6 +26,7 @@ export const DsTableRowVirtualized = <TData,>({
26
26
  renderExpandedRow,
27
27
  onRowClick,
28
28
  onRowDoubleClick,
29
+ loading,
29
30
  } = useDsTableContext<TData, unknown>();
30
31
  const isExpanded = row.getIsExpanded();
31
32
  const isActive = activeRowId === row.id;
@@ -58,6 +59,7 @@ export const DsTableRowVirtualized = <TData,>({
58
59
  onRowClick && styles.clickableRow,
59
60
  !bordered && styles.rowNoBorder,
60
61
  isExpanded && styles.expanded,
62
+ loading && styles.loadingRow,
61
63
  )}
62
64
  onClick={isExpandedRowContent ? undefined : () => onRowClick?.(row.original)}
63
65
  onDoubleClick={isExpandedRowContent ? undefined : () => onRowDoubleClick?.(row.original)}
@@ -11,18 +11,30 @@
11
11
  outline: none;
12
12
  }
13
13
 
14
- .editButton {
14
+ .editButton,
15
+ .lockIcon {
15
16
  flex-shrink: 0;
16
17
  opacity: 0;
17
18
  pointer-events: none;
18
19
  }
19
20
 
21
+ .lockIcon {
22
+ display: inline-flex;
23
+ align-items: center;
24
+ justify-content: center;
25
+ }
26
+
20
27
  &:hover,
21
28
  &:focus-within,
22
29
  &[data-active] {
23
- .editButton {
30
+ .editButton,
31
+ .lockIcon {
24
32
  opacity: 1;
25
33
  pointer-events: auto;
26
34
  }
27
35
  }
36
+
37
+ &[data-locked] {
38
+ cursor: default;
39
+ }
28
40
  }
@@ -1,5 +1,7 @@
1
1
  import type { MouseEvent } from 'react';
2
2
  import { DsButtonV3 } from '../../../../ds-button-v3';
3
+ import { DsIcon } from '../../../../ds-icon';
4
+ import { DsTooltip } from '../../../../ds-tooltip';
3
5
  import styles from './ds-table-editable-cell.module.scss';
4
6
  import type { DsTableEditableCellProps } from './ds-table-editable-cell.types';
5
7
  import { useDsTableContext } from '../../../context/ds-table-context';
@@ -16,6 +18,23 @@ export const DsTableEditableCell = <TData, TValue>({
16
18
  beginEdit(cell);
17
19
  };
18
20
 
21
+ const editDisabled = cell.column.columnDef.editDisabled?.(cell.getContext());
22
+ const locked = editDisabled === true || typeof editDisabled === 'object';
23
+ const reason = typeof editDisabled === 'object' ? editDisabled.reason : undefined;
24
+
25
+ if (locked) {
26
+ return (
27
+ <div className={styles.root} data-locked="">
28
+ {children}
29
+ <DsTooltip content={reason}>
30
+ <span className={styles.lockIcon} aria-label="Editing disabled" role="img">
31
+ <DsIcon icon="lock" size="small" color="secondary" />
32
+ </span>
33
+ </DsTooltip>
34
+ </div>
35
+ );
36
+ }
37
+
19
38
  return (
20
39
  <div
21
40
  className={styles.root}
@@ -1,6 +1,15 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { CellContext, RowData } from '@tanstack/react-table';
3
3
 
4
+ /**
5
+ * Result of a column's `editDisabled` resolver for a single cell.
6
+ *
7
+ * - `false` (or `undefined`) keeps the cell editable.
8
+ * - `true` locks the cell with no tooltip.
9
+ * - `{ reason }` locks the cell and shows `reason` in a tooltip on hover.
10
+ */
11
+ export type DsTableEditDisabledResult = boolean | { reason?: ReactNode };
12
+
4
13
  declare module '@tanstack/react-table' {
5
14
  interface ColumnDefBase<TData extends RowData, TValue> {
6
15
  /**
@@ -8,5 +17,20 @@ declare module '@tanstack/react-table' {
8
17
  * When omitted, the column is not editable.
9
18
  */
10
19
  editCell?: (context: CellContext<TData, TValue>) => ReactNode;
20
+
21
+ /**
22
+ * Locks individual cells of an otherwise editable column (one that defines
23
+ * `editCell`). When this returns a truthy value for a cell, that cell shows a
24
+ * lock icon instead of the edit affordance and cannot enter edit mode. Return
25
+ * `{ reason }` to surface an optional tooltip explaining why. Has no effect on
26
+ * columns without `editCell`.
27
+ */
28
+ editDisabled?: (context: CellContext<TData, TValue>) => DsTableEditDisabledResult;
29
+
30
+ /**
31
+ * Renders this column's cell content while the table is `loading`.
32
+ * When omitted, a default text skeleton is rendered.
33
+ */
34
+ loadingCell?: (context: CellContext<TData, TValue>) => ReactNode;
11
35
  }
12
36
  }
@@ -26,6 +26,7 @@ import { useDragAndDrop } from './hooks/use-drag-and-drop';
26
26
  import { type DsTableContextType, DsTableContext, useEditingState } from './context/ds-table-context';
27
27
  import { DsTableBodyVirtualized } from './components/ds-table-body-virtualized';
28
28
  import { useColumnGroups } from './grouping';
29
+ import { DsSkeletonText } from '../ds-skeleton';
29
30
  import {
30
31
  EMPTY_TABLE_STATE_TEXT,
31
32
  EXPANDER_COLUMN_ID,
@@ -34,6 +35,7 @@ import {
34
35
  REORDER_COLUMN_WIDTH,
35
36
  SELECT_COLUMN_ID,
36
37
  SELECT_COLUMN_WIDTH,
38
+ SKELETON_ROW_COUNT,
37
39
  } from './utils/constants';
38
40
 
39
41
  // Row size to pixel height mapping (matches CSS variables)
@@ -59,6 +61,7 @@ const DsTable = <TData extends { id: string }, TValue>({
59
61
  bordered = true,
60
62
  fullWidth = true,
61
63
  rowSize = 'medium',
64
+ loading = false,
62
65
  expandable = false,
63
66
  renderExpandedRow,
64
67
  selectable = false,
@@ -201,9 +204,29 @@ const DsTable = <TData extends { id: string }, TValue>({
201
204
  return augmentedColumns;
202
205
  }, [columnsProp, hasExpanderColumn, hasReorderColumn, hasSelectColumn, showSelectAllCheckbox]);
203
206
 
207
+ const skeletonColumns = useMemo<ColumnDef<TData, TValue>[]>(() => {
208
+ const toSkeleton = (cols: ColumnDef<TData, TValue>[]): ColumnDef<TData, TValue>[] =>
209
+ cols.map((column) =>
210
+ 'columns' in column && column.columns
211
+ ? { ...column, columns: toSkeleton(column.columns) }
212
+ : { ...column, cell: column.loadingCell ?? (() => <DsSkeletonText width="60%" />) },
213
+ );
214
+
215
+ return toSkeleton(columns);
216
+ }, [columns]);
217
+
218
+ const skeletonData = useMemo<TData[]>(
219
+ () =>
220
+ Array.from(
221
+ { length: SKELETON_ROW_COUNT },
222
+ (_, i) => ({ id: `ds-table-skeleton-${String(i)}` }) as TData,
223
+ ),
224
+ [],
225
+ );
226
+
204
227
  const table = useReactTable({
205
- data: reorderable ? data : tableData,
206
- columns,
228
+ data: loading ? skeletonData : reorderable ? data : tableData,
229
+ columns: loading ? skeletonColumns : columns,
207
230
  getCoreRowModel: getCoreRowModel(),
208
231
  onSortingChange: handleSortingChange,
209
232
  getSortedRowModel: getSortedRowModel(),
@@ -295,8 +318,9 @@ const DsTable = <TData extends { id: string }, TValue>({
295
318
  selectable,
296
319
  reorderable,
297
320
  showSelectAllCheckbox,
298
- onRowClick,
299
- onRowDoubleClick,
321
+ loading,
322
+ onRowClick: loading ? undefined : onRowClick,
323
+ onRowDoubleClick: loading ? undefined : onRowDoubleClick,
300
324
  primaryRowActions,
301
325
  secondaryRowActions,
302
326
  renderExpandedRow,
@@ -314,6 +314,13 @@ export interface DsDataTableProps<TData, TValue> {
314
314
  */
315
315
  rowSize?: DsTableRowSize;
316
316
 
317
+ /**
318
+ * When `true`, renders placeholder skeleton rows instead of the data. Column
319
+ * headers stay intact and row interactions are disabled while loading.
320
+ * @default false
321
+ */
322
+ loading?: boolean;
323
+
317
324
  /**
318
325
  * Whether the table is expandable or if an individual row should be expandable
319
326
  */