@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.
- package/CHANGELOG.md +13 -0
- package/README.md +1 -1
- package/dist/index.cjs +371 -62
- package/dist/index.d.cts +107 -1
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +107 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +370 -63
- package/dist/index.js.map +1 -1
- package/dist/index.min.css +1 -1
- package/package.json +4 -3
- package/src/components/ds-drawer/ds-drawer.module.scss +14 -0
- package/src/components/ds-drawer/ds-drawer.tsx +20 -1
- package/src/components/ds-drawer/ds-drawer.types.ts +7 -0
- package/src/components/ds-file-upload/hooks/use-file-upload.ts +7 -0
- package/src/components/ds-progress-task-bar/ds-progress-task-bar.figma.ts +29 -0
- package/src/components/ds-progress-task-bar/ds-progress-task-bar.module.scss +39 -0
- package/src/components/ds-progress-task-bar/ds-progress-task-bar.tsx +122 -0
- package/src/components/ds-progress-task-bar/ds-progress-task-bar.types.ts +65 -0
- package/src/components/ds-progress-task-bar/index.ts +2 -0
- package/src/components/ds-progress-task-bar/utils.ts +51 -0
- package/src/components/ds-table/components/ds-table-row/ds-table-row.module.scss +1 -1
- package/src/components/ds-table/components/ds-table-row/ds-table-row.tsx +2 -0
- package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.module.scss +1 -1
- package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.tsx +2 -0
- package/src/components/ds-table/components/edit/ds-table-editable-cell/ds-table-editable-cell.module.scss +14 -2
- package/src/components/ds-table/components/edit/ds-table-editable-cell/ds-table-editable-cell.tsx +19 -0
- package/src/components/ds-table/ds-table-column.types.ts +24 -0
- package/src/components/ds-table/ds-table.tsx +28 -4
- package/src/components/ds-table/ds-table.types.ts +7 -0
- package/src/components/ds-table/index.ts +1 -0
- package/src/components/ds-table/utils/constants.ts +5 -0
- package/src/components/ds-tabs/figma/ds-tabs-tab-vertical.figma.ts +78 -0
- package/src/components/ds-tabs/figma/ds-tabs-tab.figma.ts +89 -0
- package/src/components/ds-tabs/figma/ds-tabs-vertical.figma.ts +31 -0
- package/src/components/ds-tabs/figma/ds-tabs.figma.ts +29 -0
- 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.
|
|
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/
|
|
108
|
-
"@drivenets/
|
|
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,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
|
+
};
|
|
@@ -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)}
|
package/src/components/ds-table/components/ds-table-row-virtualized/ds-table-row-virtualized.tsx
CHANGED
|
@@ -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
|
}
|
package/src/components/ds-table/components/edit/ds-table-editable-cell/ds-table-editable-cell.tsx
CHANGED
|
@@ -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
|
-
|
|
299
|
-
|
|
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
|
*/
|