@drivenets/design-system 0.1.0 → 0.2.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 +10 -0
- package/dist/components/ds-avatar/ds-avatar.cjs +98 -0
- package/dist/components/ds-avatar/ds-avatar.d.cts +12 -0
- package/dist/components/ds-avatar/ds-avatar.d.cts.map +1 -0
- package/dist/components/ds-avatar/ds-avatar.d.ts +12 -0
- package/dist/components/ds-avatar/ds-avatar.d.ts.map +1 -0
- package/dist/components/ds-avatar/ds-avatar.js +97 -0
- package/dist/components/ds-avatar/ds-avatar.js.map +1 -0
- package/dist/components/ds-avatar/ds-avatar.module.cjs +17 -0
- package/dist/components/ds-avatar/ds-avatar.module.js +17 -0
- package/dist/components/ds-avatar/ds-avatar.module.js.map +1 -0
- package/dist/components/ds-avatar/ds-avatar.types.d.cts +44 -0
- package/dist/components/ds-avatar/ds-avatar.types.d.cts.map +1 -0
- package/dist/components/ds-avatar/ds-avatar.types.d.ts +44 -0
- package/dist/components/ds-avatar/ds-avatar.types.d.ts.map +1 -0
- package/dist/components/ds-avatar/index.cjs +1 -0
- package/dist/components/ds-avatar/index.d.ts +2 -0
- package/dist/components/ds-avatar/index.js +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.cjs +93 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.d.cts +12 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.d.cts.map +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.d.ts +12 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.d.ts.map +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.js +92 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.js.map +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.module.cjs +19 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.module.js +19 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.module.js.map +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.types.d.cts +31 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.types.d.cts.map +1 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.types.d.ts +32 -0
- package/dist/components/ds-avatar-group/ds-avatar-group.types.d.ts.map +1 -0
- package/dist/components/ds-avatar-group/index.cjs +1 -0
- package/dist/components/ds-avatar-group/index.d.ts +2 -0
- package/dist/components/ds-avatar-group/index.js +1 -0
- package/dist/components/ds-button/ds-button.d.ts +2 -2
- package/dist/components/ds-button/ds-button.d.ts.map +1 -1
- package/dist/components/ds-chip/ds-chip.cjs +2 -1
- package/dist/components/ds-chip/ds-chip.d.cts +2 -1
- package/dist/components/ds-chip/ds-chip.d.cts.map +1 -1
- package/dist/components/ds-chip/ds-chip.d.ts +2 -1
- package/dist/components/ds-chip/ds-chip.d.ts.map +1 -1
- package/dist/components/ds-chip/ds-chip.js +2 -1
- package/dist/components/ds-chip/ds-chip.js.map +1 -1
- package/dist/components/ds-chip/ds-chip.types.d.cts +13 -0
- package/dist/components/ds-chip/ds-chip.types.d.cts.map +1 -1
- package/dist/components/ds-chip/ds-chip.types.d.ts +13 -0
- package/dist/components/ds-chip/ds-chip.types.d.ts.map +1 -1
- package/dist/components/ds-chip-group/ds-chip-group.cjs +2 -2
- package/dist/components/ds-chip-group/ds-chip-group.d.cts +2 -2
- package/dist/components/ds-chip-group/ds-chip-group.d.ts +2 -2
- package/dist/components/ds-chip-group/ds-chip-group.js +2 -2
- package/dist/components/ds-chip-group/ds-chip-group.js.map +1 -1
- package/dist/components/ds-chip-group/ds-chip-group.types.d.cts +9 -0
- package/dist/components/ds-chip-group/ds-chip-group.types.d.cts.map +1 -1
- package/dist/components/ds-chip-group/ds-chip-group.types.d.ts +9 -0
- package/dist/components/ds-chip-group/ds-chip-group.types.d.ts.map +1 -1
- package/dist/components/ds-confirmation/ds-confirmation.d.cts +8 -8
- package/dist/components/ds-confirmation/ds-confirmation.d.ts +8 -8
- package/dist/components/ds-date-input/ds-date-input.d.cts +2 -2
- package/dist/components/ds-date-input/ds-date-input.d.cts.map +1 -1
- package/dist/components/ds-date-input/ds-date-input.d.ts +2 -2
- package/dist/components/ds-date-input/ds-date-input.js +2 -2
- package/dist/components/ds-date-input/ds-date-input.js.map +1 -1
- package/dist/components/ds-drawer/ds-drawer.d.cts +9 -9
- package/dist/components/ds-drawer/ds-drawer.d.ts +9 -9
- package/dist/components/ds-drawer/ds-drawer.js +2 -2
- package/dist/components/ds-dropdown-menu/ds-dropdown-menu.js +3 -3
- package/dist/components/ds-dropdown-menu/ds-dropdown-menu.js.map +1 -1
- package/dist/components/ds-expandable-text-input/ds-expandable-text-input.d.cts +2 -2
- package/dist/components/ds-expandable-text-input/ds-expandable-text-input.d.ts +2 -2
- package/dist/components/ds-file-upload/components/file-upload-item/file-upload-item.cjs +2 -2
- package/dist/components/ds-file-upload/components/file-upload-item/file-upload-item.js +2 -2
- package/dist/components/ds-form-control/ds-form-control.d.cts +8 -8
- package/dist/components/ds-modal/ds-modal.d.cts +8 -8
- package/dist/components/ds-panel/ds-panel.d.cts +2 -2
- package/dist/components/ds-panel/ds-panel.d.ts +2 -2
- package/dist/components/ds-select/ds-select.d.cts +2 -2
- package/dist/components/ds-select/ds-select.d.ts +2 -2
- package/dist/components/ds-smart-tabs/ds-smart-tabs.d.cts +2 -2
- package/dist/components/ds-smart-tabs/ds-smart-tabs.d.ts +2 -2
- package/dist/components/ds-smart-tabs/ds-smart-tabs.d.ts.map +1 -1
- package/dist/components/ds-stepper/components/ds-step-content.d.cts +2 -2
- package/dist/components/ds-stepper/components/ds-step-content.d.ts +2 -2
- package/dist/components/ds-stepper/components/ds-step-next-button.d.cts +2 -2
- package/dist/components/ds-stepper/components/ds-step-next-button.d.ts +2 -2
- package/dist/components/ds-stepper/components/ds-step.d.cts +2 -2
- package/dist/components/ds-stepper/components/ds-step.d.ts +2 -2
- package/dist/components/ds-stepper/ds-stepper.d.cts +2 -2
- package/dist/components/ds-stepper/ds-stepper.d.ts +2 -2
- package/dist/components/ds-table/ds-table.d.cts +2 -2
- package/dist/components/ds-table/ds-table.d.ts +2 -2
- package/dist/components/ds-tag/ds-tag.cjs +254 -0
- package/dist/components/ds-tag/ds-tag.d.cts +25 -0
- package/dist/components/ds-tag/ds-tag.d.cts.map +1 -0
- package/dist/components/ds-tag/ds-tag.d.ts +25 -0
- package/dist/components/ds-tag/ds-tag.d.ts.map +1 -0
- package/dist/components/ds-tag/ds-tag.js +253 -0
- package/dist/components/ds-tag/ds-tag.js.map +1 -0
- package/dist/components/ds-tag/ds-tag.module.cjs +17 -0
- package/dist/components/ds-tag/ds-tag.module.js +17 -0
- package/dist/components/ds-tag/ds-tag.module.js.map +1 -0
- package/dist/components/ds-tag/ds-tag.types.d.cts +68 -0
- package/dist/components/ds-tag/ds-tag.types.d.cts.map +1 -0
- package/dist/components/ds-tag/ds-tag.types.d.ts +68 -0
- package/dist/components/ds-tag/ds-tag.types.d.ts.map +1 -0
- package/dist/components/ds-tag/index.cjs +1 -0
- package/dist/components/ds-tag/index.d.ts +2 -0
- package/dist/components/ds-tag/index.js +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.cjs +325 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.d.cts +24 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.d.cts.map +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.d.ts +24 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.d.ts.map +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.js +324 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.js.map +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.module.cjs +17 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.module.js +17 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.module.js.map +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.types.d.cts +71 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.types.d.cts.map +1 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.types.d.ts +72 -0
- package/dist/components/ds-tag-filter/ds-tag-filter.types.d.ts.map +1 -0
- package/dist/components/ds-tag-filter/hooks/use-tag-overflow-calculation.cjs +137 -0
- package/dist/components/ds-tag-filter/hooks/use-tag-overflow-calculation.js +137 -0
- package/dist/components/ds-tag-filter/hooks/use-tag-overflow-calculation.js.map +1 -0
- package/dist/components/ds-tag-filter/index.cjs +1 -0
- package/dist/components/ds-tag-filter/index.d.ts +2 -0
- package/dist/components/ds-tag-filter/index.js +1 -0
- package/dist/components/ds-tag-filter/utils.cjs +54 -0
- package/dist/components/ds-tag-filter/utils.js +52 -0
- package/dist/components/ds-tag-filter/utils.js.map +1 -0
- package/dist/components/ds-text-input/ds-text-input.js +2 -2
- package/dist/components/ds-toggle/ds-toggle.d.cts +2 -2
- package/dist/components/ds-toggle/ds-toggle.d.ts +2 -2
- package/dist/components/ds-vertical-tabs/ds-vertical-tabs.d.cts +5 -5
- package/dist/components/ds-vertical-tabs/ds-vertical-tabs.d.ts +5 -5
- package/dist/index.cjs +14 -2
- package/dist/index.d.cts +9 -1
- package/dist/index.d.ts +13 -1
- package/dist/index.js +11 -3
- package/dist/index.min.css +1 -1
- package/package.json +6 -2
- package/src/components/ds-avatar/_mixins.scss +45 -0
- package/src/components/ds-avatar/ds-avatar.module.scss +52 -0
- package/src/components/ds-avatar/ds-avatar.tsx +42 -0
- package/src/components/ds-avatar/ds-avatar.types.ts +41 -0
- package/src/components/ds-avatar/index.ts +2 -0
- package/src/components/ds-avatar-group/ds-avatar-group.module.scss +70 -0
- package/src/components/ds-avatar-group/ds-avatar-group.tsx +51 -0
- package/src/components/ds-avatar-group/ds-avatar-group.types.ts +27 -0
- package/src/components/ds-avatar-group/index.ts +2 -0
- package/src/components/ds-chip/ds-chip.tsx +2 -1
- package/src/components/ds-chip/ds-chip.types.tsx +13 -0
- package/src/components/ds-chip/index.tsx +8 -0
- package/src/components/ds-chip-group/ds-chip-group.tsx +2 -2
- package/src/components/ds-chip-group/ds-chip-group.types.ts +8 -0
- package/src/components/ds-chip-group/index.ts +1 -0
- package/src/components/ds-tag/ds-tag.module.scss +112 -0
- package/src/components/ds-tag/ds-tag.tsx +124 -0
- package/src/components/ds-tag/ds-tag.types.tsx +67 -0
- package/src/components/ds-tag/index.tsx +2 -0
- package/src/components/ds-tag-filter/ds-tag-filter.module.scss +72 -0
- package/src/components/ds-tag-filter/ds-tag-filter.tsx +178 -0
- package/src/components/ds-tag-filter/ds-tag-filter.types.ts +68 -0
- package/src/components/ds-tag-filter/hooks/use-tag-overflow-calculation.ts +135 -0
- package/src/components/ds-tag-filter/index.ts +2 -0
- package/src/components/ds-tag-filter/utils.test.ts +401 -0
- package/src/components/ds-tag-filter/utils.ts +65 -0
- package/src/index.ts +4 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { type KeyboardEvent, type MouseEvent, type Ref } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import styles from './ds-tag.module.scss';
|
|
4
|
+
import type { DsTagProps } from './ds-tag.types';
|
|
5
|
+
import { DsIcon } from '../ds-icon';
|
|
6
|
+
import { DsTypography } from '../ds-typography';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Design system Tag component
|
|
10
|
+
*/
|
|
11
|
+
const DsTag = ({
|
|
12
|
+
ref,
|
|
13
|
+
label,
|
|
14
|
+
className,
|
|
15
|
+
style = {},
|
|
16
|
+
size = 'medium',
|
|
17
|
+
selected = false,
|
|
18
|
+
variant = 'default',
|
|
19
|
+
disabled = false,
|
|
20
|
+
locale = {},
|
|
21
|
+
onClick,
|
|
22
|
+
onDelete,
|
|
23
|
+
slots,
|
|
24
|
+
...rest
|
|
25
|
+
}: DsTagProps) => {
|
|
26
|
+
const tagClass = classNames(
|
|
27
|
+
styles.tag,
|
|
28
|
+
{
|
|
29
|
+
[styles.clickable]: onClick !== undefined && !disabled,
|
|
30
|
+
[styles.selected]: selected && !disabled,
|
|
31
|
+
[styles.include]: variant === 'include',
|
|
32
|
+
[styles.exclude]: variant === 'exclude',
|
|
33
|
+
[styles.disabled]: disabled,
|
|
34
|
+
},
|
|
35
|
+
className,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const handleDeleteClick = (event: MouseEvent<HTMLElement>) => {
|
|
39
|
+
event.stopPropagation();
|
|
40
|
+
onDelete?.(event);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleKeyDownTag = (event: KeyboardEvent<HTMLElement>) => {
|
|
44
|
+
if (onClick && (event.key === 'Enter' || event.key === ' ')) {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
onClick(event as KeyboardEvent<HTMLDivElement>);
|
|
47
|
+
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (onDelete && (event.key === 'Backspace' || event.key === 'Delete')) {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
onDelete(event);
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const handleKeyDownDelete = (event: KeyboardEvent<HTMLElement>) => {
|
|
58
|
+
event.stopPropagation();
|
|
59
|
+
|
|
60
|
+
if (onDelete && (event.key === 'Enter' || event.key === ' ')) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
onDelete(event);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const handleClick = (event: MouseEvent<HTMLElement>) => {
|
|
67
|
+
if (onClick) {
|
|
68
|
+
event.preventDefault();
|
|
69
|
+
onClick(event as MouseEvent<HTMLDivElement>);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let iconVariant: 'do_not_disturb_on' | 'check_circle' | null = null;
|
|
74
|
+
|
|
75
|
+
if (variant === 'include') {
|
|
76
|
+
iconVariant = 'check_circle';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (variant === 'exclude') {
|
|
80
|
+
iconVariant = 'do_not_disturb_on';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
ref={ref as Ref<HTMLDivElement>}
|
|
86
|
+
className={tagClass}
|
|
87
|
+
style={style}
|
|
88
|
+
onClick={disabled ? undefined : handleClick}
|
|
89
|
+
onKeyDown={disabled ? undefined : handleKeyDownTag}
|
|
90
|
+
role={onClick || onDelete ? 'button' : undefined}
|
|
91
|
+
tabIndex={disabled || (!onClick && !onDelete) ? undefined : 0}
|
|
92
|
+
aria-label={typeof label === 'string' ? label : undefined}
|
|
93
|
+
aria-pressed={onClick && selected && !disabled ? 'true' : undefined}
|
|
94
|
+
aria-disabled={disabled}
|
|
95
|
+
{...rest}
|
|
96
|
+
>
|
|
97
|
+
{(slots?.icon || iconVariant) && (
|
|
98
|
+
<span className={classNames(styles.icon)}>
|
|
99
|
+
{slots?.icon ??
|
|
100
|
+
(iconVariant && (
|
|
101
|
+
<DsIcon icon={iconVariant} size="tiny" className={classNames(styles.variantIcon)} />
|
|
102
|
+
))}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
<DsTypography variant={size === 'small' ? 'body-xs-reg' : 'body-sm-reg'} className={styles.label}>
|
|
106
|
+
{label}
|
|
107
|
+
</DsTypography>
|
|
108
|
+
{onDelete && !disabled && (
|
|
109
|
+
<button
|
|
110
|
+
type="button"
|
|
111
|
+
className={styles.deleteButton}
|
|
112
|
+
onClick={handleDeleteClick}
|
|
113
|
+
onKeyDown={handleKeyDownDelete}
|
|
114
|
+
aria-label={locale.deleteAriaLabel ?? 'Delete tag'}
|
|
115
|
+
tabIndex={0}
|
|
116
|
+
>
|
|
117
|
+
<DsIcon icon="close" size="tiny" />
|
|
118
|
+
</button>
|
|
119
|
+
)}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export default DsTag;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode, Ref } from 'react';
|
|
2
|
+
|
|
3
|
+
export const tagSizes = ['medium', 'small'] as const;
|
|
4
|
+
export type TagSize = (typeof tagSizes)[number];
|
|
5
|
+
|
|
6
|
+
export const tagVariants = ['default', 'include', 'exclude'] as const;
|
|
7
|
+
export type TagVariant = (typeof tagVariants)[number];
|
|
8
|
+
|
|
9
|
+
export interface DsTagProps {
|
|
10
|
+
/**
|
|
11
|
+
* Ref to the tag element
|
|
12
|
+
*/
|
|
13
|
+
ref?: Ref<HTMLElement>;
|
|
14
|
+
/**
|
|
15
|
+
* The label text to display in the tag
|
|
16
|
+
*/
|
|
17
|
+
label: ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* Additional CSS class names
|
|
20
|
+
*/
|
|
21
|
+
className?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Locale object (you can pass custom strings for localization)
|
|
24
|
+
*/
|
|
25
|
+
locale?: {
|
|
26
|
+
deleteAriaLabel?: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Additional styles to apply to the component
|
|
30
|
+
*/
|
|
31
|
+
style?: CSSProperties;
|
|
32
|
+
/**
|
|
33
|
+
* Optional click handler, if not present Tag will not be in "clickable" state
|
|
34
|
+
*/
|
|
35
|
+
onClick?: (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
|
36
|
+
/**
|
|
37
|
+
* Callback function when the delete icon is clicked
|
|
38
|
+
*/
|
|
39
|
+
onDelete?: (event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => void;
|
|
40
|
+
/**
|
|
41
|
+
* Size of the tag
|
|
42
|
+
* @default 'medium'
|
|
43
|
+
*/
|
|
44
|
+
size?: TagSize;
|
|
45
|
+
/**
|
|
46
|
+
* Whether the tag is in a selected/pressed state
|
|
47
|
+
* @default false
|
|
48
|
+
*/
|
|
49
|
+
selected?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Variant of the tag
|
|
52
|
+
* @default 'default'
|
|
53
|
+
*/
|
|
54
|
+
variant?: TagVariant;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the tag is disabled
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
|
|
61
|
+
slots?: {
|
|
62
|
+
/**
|
|
63
|
+
* Icon to display at the start of the input
|
|
64
|
+
*/
|
|
65
|
+
icon?: ReactNode;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--spacing-xs);
|
|
5
|
+
padding: var(--spacing-xs) var(--spacing-standard);
|
|
6
|
+
background: var(--color-background-selected-weak);
|
|
7
|
+
border: 1px solid var(--color-outline-weak);
|
|
8
|
+
border-radius: var(--spacing-3xs);
|
|
9
|
+
max-height: 88px; // hardcoded value required in the design
|
|
10
|
+
overflow: hidden;
|
|
11
|
+
|
|
12
|
+
&.expanded {
|
|
13
|
+
max-height: none;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.row1 {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
width: 100%;
|
|
21
|
+
gap: var(--spacing-xs);
|
|
22
|
+
flex-shrink: 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.row2 {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
width: 100%;
|
|
29
|
+
gap: var(--spacing-xs);
|
|
30
|
+
flex-wrap: wrap;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.label {
|
|
34
|
+
color: var(--color-font-secondary);
|
|
35
|
+
white-space: nowrap;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.expandTag {
|
|
40
|
+
cursor: pointer;
|
|
41
|
+
flex-shrink: 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.itemCheckbox {
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.clearContent {
|
|
50
|
+
background: transparent !important;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.clearButton {
|
|
54
|
+
margin-left: auto;
|
|
55
|
+
padding: 0;
|
|
56
|
+
flex-shrink: 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Hidden container for measuring element widths
|
|
60
|
+
// Must render all elements to get accurate measurements
|
|
61
|
+
.measurementContainer {
|
|
62
|
+
pointer-events: none;
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: var(--spacing-xs);
|
|
66
|
+
|
|
67
|
+
// Ensure it doesn't affect layout
|
|
68
|
+
height: 0;
|
|
69
|
+
overflow: hidden;
|
|
70
|
+
position: absolute;
|
|
71
|
+
visibility: hidden;
|
|
72
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
import styles from './ds-tag-filter.module.scss';
|
|
5
|
+
import type { DsTagFilterProps, TagFilterItem } from './ds-tag-filter.types';
|
|
6
|
+
import { useTagOverflowCalculation } from './hooks/use-tag-overflow-calculation';
|
|
7
|
+
import { DsTypography } from '../ds-typography';
|
|
8
|
+
import { DsTag } from '../ds-tag';
|
|
9
|
+
import { DsButton } from '../ds-button';
|
|
10
|
+
import { DsIcon } from '../ds-icon';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Design system TagFilter component
|
|
14
|
+
*
|
|
15
|
+
* A component for displaying active filters as tags with overflow handling.
|
|
16
|
+
* Shows visible tags in up to 2 rows, with the Expand button to show hidden items.
|
|
17
|
+
*/
|
|
18
|
+
const DsTagFilter = ({
|
|
19
|
+
items,
|
|
20
|
+
locale = {},
|
|
21
|
+
className,
|
|
22
|
+
style,
|
|
23
|
+
onClearAll,
|
|
24
|
+
onItemDelete,
|
|
25
|
+
onItemSelect,
|
|
26
|
+
onExpand,
|
|
27
|
+
}: DsTagFilterProps) => {
|
|
28
|
+
const [expanded, setExpanded] = useState(false);
|
|
29
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
30
|
+
const measurementRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
|
|
32
|
+
const { row1TagCount, row2TagCount, hasOverflow } = useTagOverflowCalculation({
|
|
33
|
+
containerRef,
|
|
34
|
+
measurementRef,
|
|
35
|
+
totalItems: items.length,
|
|
36
|
+
expanded,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
label = 'Filtered by:',
|
|
41
|
+
clearButton = 'Clear all filters',
|
|
42
|
+
hiddenCountPlural = 'filters',
|
|
43
|
+
hiddenCountSingular = 'filter',
|
|
44
|
+
collapseTagLabel = 'Collapse',
|
|
45
|
+
} = locale;
|
|
46
|
+
|
|
47
|
+
// Split items into row sections
|
|
48
|
+
const row1Tags = items.slice(0, row1TagCount);
|
|
49
|
+
const row2Tags = items.slice(row1TagCount, row1TagCount + row2TagCount);
|
|
50
|
+
const hiddenTags = expanded ? [] : items.slice(row1TagCount + row2TagCount);
|
|
51
|
+
|
|
52
|
+
if (items.length === 0) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const hiddenCount = hiddenTags.length;
|
|
57
|
+
const hasRow2Content = row2Tags.length > 0 || hasOverflow;
|
|
58
|
+
|
|
59
|
+
const renderTag = (item: TagFilterItem) => {
|
|
60
|
+
const tagProps = item.slotProps?.tag || {};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<DsTag
|
|
64
|
+
{...tagProps}
|
|
65
|
+
key={item.id}
|
|
66
|
+
label={item.label}
|
|
67
|
+
selected={item.selected}
|
|
68
|
+
onClick={onItemSelect ? () => onItemSelect(item) : undefined}
|
|
69
|
+
onDelete={onItemDelete ? () => onItemDelete(item) : undefined}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const handleExpandToggle = () => {
|
|
75
|
+
const newExpanded = !expanded;
|
|
76
|
+
setExpanded(newExpanded);
|
|
77
|
+
onExpand?.(newExpanded);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Measurement container rendered via portal to keep it outside the component's DOM tree
|
|
81
|
+
// This prevents Testing Library from finding duplicate elements
|
|
82
|
+
// We use it to calculate the number of remaining tags to render
|
|
83
|
+
const measurementContainer = (
|
|
84
|
+
<div ref={measurementRef} className={styles.measurementContainer} aria-hidden="true">
|
|
85
|
+
{label && (
|
|
86
|
+
<span data-measure-label="">
|
|
87
|
+
<DsTypography variant="body-sm-reg" className={styles.label}>
|
|
88
|
+
{label}
|
|
89
|
+
</DsTypography>
|
|
90
|
+
</span>
|
|
91
|
+
)}
|
|
92
|
+
{items.map((item) => (
|
|
93
|
+
<DsTag
|
|
94
|
+
key={item.id}
|
|
95
|
+
data-measure-tag=""
|
|
96
|
+
label={item.label}
|
|
97
|
+
selected={item.selected}
|
|
98
|
+
onClick={onItemSelect ? () => onItemSelect(item) : undefined}
|
|
99
|
+
onDelete={onItemDelete ? () => onItemDelete(item) : undefined}
|
|
100
|
+
/>
|
|
101
|
+
))}
|
|
102
|
+
{onClearAll && (
|
|
103
|
+
<DsButton
|
|
104
|
+
data-measure-clear=""
|
|
105
|
+
design="v1.2"
|
|
106
|
+
buttonType="tertiary"
|
|
107
|
+
variant="ghost"
|
|
108
|
+
className={styles.clearButton}
|
|
109
|
+
contentClassName={styles.clearContent}
|
|
110
|
+
size="small"
|
|
111
|
+
>
|
|
112
|
+
<DsIcon icon="close" />
|
|
113
|
+
{clearButton}
|
|
114
|
+
</DsButton>
|
|
115
|
+
)}
|
|
116
|
+
<DsTag data-measure-expand="" label="+99 filters" className={styles.expandTag} />
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* IMPORTANT!
|
|
122
|
+
* If you make any changes to this layout (styling, JSX, anything that affects it.)
|
|
123
|
+
* please, apply the exact same changes to the "measurementContainer"
|
|
124
|
+
*/
|
|
125
|
+
return (
|
|
126
|
+
<>
|
|
127
|
+
<div
|
|
128
|
+
ref={containerRef}
|
|
129
|
+
className={classNames(styles.container, expanded && styles.expanded, className)}
|
|
130
|
+
style={style}
|
|
131
|
+
>
|
|
132
|
+
<div className={styles.row1}>
|
|
133
|
+
{label && (
|
|
134
|
+
<DsTypography variant="body-sm-reg" className={styles.label}>
|
|
135
|
+
{label}
|
|
136
|
+
</DsTypography>
|
|
137
|
+
)}
|
|
138
|
+
{row1Tags.map((item) => renderTag(item))}
|
|
139
|
+
{onClearAll && (
|
|
140
|
+
<DsButton
|
|
141
|
+
design="v1.2"
|
|
142
|
+
buttonType="tertiary"
|
|
143
|
+
variant="ghost"
|
|
144
|
+
className={styles.clearButton}
|
|
145
|
+
contentClassName={styles.clearContent}
|
|
146
|
+
size="small"
|
|
147
|
+
onClick={onClearAll}
|
|
148
|
+
>
|
|
149
|
+
<DsIcon icon="close" />
|
|
150
|
+
{clearButton}
|
|
151
|
+
</DsButton>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{hasRow2Content && (
|
|
156
|
+
<div className={styles.row2}>
|
|
157
|
+
{row2Tags.map((item) => renderTag(item))}
|
|
158
|
+
{hasOverflow && (
|
|
159
|
+
<DsTag
|
|
160
|
+
label={
|
|
161
|
+
expanded
|
|
162
|
+
? collapseTagLabel
|
|
163
|
+
: `+${String(hiddenCount)} ${hiddenCount === 1 ? hiddenCountSingular : hiddenCountPlural}`
|
|
164
|
+
}
|
|
165
|
+
selected={expanded}
|
|
166
|
+
className={styles.expandTag}
|
|
167
|
+
onClick={handleExpandToggle}
|
|
168
|
+
/>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
{createPortal(measurementContainer, document.body)}
|
|
174
|
+
</>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export default DsTagFilter;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
import type { DsTagProps } from '../ds-tag';
|
|
3
|
+
|
|
4
|
+
export interface TagFilterItem {
|
|
5
|
+
/**
|
|
6
|
+
* Unique identifier for the tag
|
|
7
|
+
*/
|
|
8
|
+
id: string;
|
|
9
|
+
/**
|
|
10
|
+
* The label text to display in the tag
|
|
11
|
+
*/
|
|
12
|
+
label: string;
|
|
13
|
+
/**
|
|
14
|
+
* Additional metadata to store with the tag
|
|
15
|
+
*/
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Whether the tag is selected/checked
|
|
19
|
+
*/
|
|
20
|
+
selected?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Additional props to pass to the components
|
|
23
|
+
*/
|
|
24
|
+
slotProps?: {
|
|
25
|
+
tag?: Partial<DsTagProps>;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DsTagFilterProps {
|
|
30
|
+
/**
|
|
31
|
+
* Array of tag items to display
|
|
32
|
+
*/
|
|
33
|
+
items: TagFilterItem[];
|
|
34
|
+
/**
|
|
35
|
+
* Locale object (you can pass custom strings for localization)
|
|
36
|
+
*/
|
|
37
|
+
locale?: {
|
|
38
|
+
label?: string;
|
|
39
|
+
clearButton?: string;
|
|
40
|
+
collapseTagLabel?: string;
|
|
41
|
+
hiddenCountSingular?: string;
|
|
42
|
+
hiddenCountPlural?: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Callback when "Clear all" is clicked
|
|
46
|
+
*/
|
|
47
|
+
onClearAll?: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Callback when a tag is deleted/removed
|
|
50
|
+
*/
|
|
51
|
+
onItemDelete?: (item: TagFilterItem) => void;
|
|
52
|
+
/**
|
|
53
|
+
* Callback when a tag is selected
|
|
54
|
+
*/
|
|
55
|
+
onItemSelect?: (item: TagFilterItem) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Callback when the Expand button is clicked
|
|
58
|
+
*/
|
|
59
|
+
onExpand?: (expanded?: boolean) => void;
|
|
60
|
+
/**
|
|
61
|
+
* Additional CSS class names
|
|
62
|
+
*/
|
|
63
|
+
className?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Additional styles to apply to the component
|
|
66
|
+
*/
|
|
67
|
+
style?: CSSProperties;
|
|
68
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { type RefObject, useCallback, useLayoutEffect, useState } from 'react';
|
|
2
|
+
import { fitTagsInRow, getContainerAvailableWidth, getElementMeasurements } from '../utils';
|
|
3
|
+
|
|
4
|
+
interface UseTagOverflowCalculationOptions {
|
|
5
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
6
|
+
measurementRef: RefObject<HTMLDivElement | null>;
|
|
7
|
+
totalItems: number;
|
|
8
|
+
expanded: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UseTagOverflowCalculationResult {
|
|
12
|
+
row1TagCount: number;
|
|
13
|
+
row2TagCount: number;
|
|
14
|
+
hasOverflow: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Extra space needed per row for animated delete button that appears on hover
|
|
18
|
+
const TAG_HOVER_BUTTON_SPACE = 20;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Custom hook to calculate how many tags can fit in 2 rows.
|
|
22
|
+
*
|
|
23
|
+
* Row 1: Label + Tags + Clear Button
|
|
24
|
+
* Row 2: Remaining Tags + Expand Tag (only if overflow)
|
|
25
|
+
*
|
|
26
|
+
* Measures actual element widths to determine optimal placement.
|
|
27
|
+
*/
|
|
28
|
+
export const useTagOverflowCalculation = ({
|
|
29
|
+
containerRef,
|
|
30
|
+
measurementRef,
|
|
31
|
+
totalItems,
|
|
32
|
+
expanded,
|
|
33
|
+
}: UseTagOverflowCalculationOptions): UseTagOverflowCalculationResult => {
|
|
34
|
+
const [state, setState] = useState<UseTagOverflowCalculationResult>({
|
|
35
|
+
row1TagCount: 0,
|
|
36
|
+
row2TagCount: 0,
|
|
37
|
+
hasOverflow: false,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const calculateLayout = useCallback(() => {
|
|
41
|
+
if (!containerRef.current || !measurementRef.current) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const container = containerRef.current;
|
|
46
|
+
const measurementContainer = measurementRef.current;
|
|
47
|
+
|
|
48
|
+
const { tagWidths, labelWidth, clearButtonWidth, expandTagWidth, gap } =
|
|
49
|
+
getElementMeasurements(measurementContainer);
|
|
50
|
+
|
|
51
|
+
if (tagWidths.length === 0) {
|
|
52
|
+
setState({ row1TagCount: 0, row2TagCount: 0, hasOverflow: false });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const containerWidth = getContainerAvailableWidth(container);
|
|
57
|
+
|
|
58
|
+
// Calculate Row 1 available space (Label + Tags + Clear Button)
|
|
59
|
+
const row1AvailableWidth = containerWidth - labelWidth - clearButtonWidth - TAG_HOVER_BUTTON_SPACE;
|
|
60
|
+
|
|
61
|
+
// Calculate how many tags fit in Row 1
|
|
62
|
+
const row1Result = fitTagsInRow(tagWidths, row1AvailableWidth, gap);
|
|
63
|
+
const remainingTags = tagWidths.slice(row1Result.count);
|
|
64
|
+
|
|
65
|
+
if (remainingTags.length === 0) {
|
|
66
|
+
// All tags fit in Row 1, no overflow
|
|
67
|
+
setState({ row1TagCount: row1Result.count, row2TagCount: 0, hasOverflow: false });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Calculate with expand tag reserved (pessimistic)
|
|
72
|
+
const row2AvailableWithExpand = containerWidth - expandTagWidth - TAG_HOVER_BUTTON_SPACE;
|
|
73
|
+
const row2WithExpandResult = fitTagsInRow(remainingTags, row2AvailableWithExpand, gap);
|
|
74
|
+
|
|
75
|
+
// Check if there's overflow (more remaining tags than fit in Row 2 with expand)
|
|
76
|
+
const overflowExists = remainingTags.length > row2WithExpandResult.count;
|
|
77
|
+
|
|
78
|
+
if (overflowExists) {
|
|
79
|
+
// Use the count with expand tag space reserved
|
|
80
|
+
setState({
|
|
81
|
+
row1TagCount: row1Result.count,
|
|
82
|
+
row2TagCount: row2WithExpandResult.count,
|
|
83
|
+
hasOverflow: true,
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
// No overflow - recalculate Row 2 without reserving expand tag space
|
|
87
|
+
const row2AvailableWithoutExpand = containerWidth - TAG_HOVER_BUTTON_SPACE;
|
|
88
|
+
const row2FullResult = fitTagsInRow(remainingTags, row2AvailableWithoutExpand, gap);
|
|
89
|
+
|
|
90
|
+
// Double-check: if even without expand tag we can't fit all, we have overflow
|
|
91
|
+
if (row2FullResult.count < remainingTags.length) {
|
|
92
|
+
setState({
|
|
93
|
+
row1TagCount: row1Result.count,
|
|
94
|
+
row2TagCount: row2WithExpandResult.count,
|
|
95
|
+
hasOverflow: true,
|
|
96
|
+
});
|
|
97
|
+
} else {
|
|
98
|
+
setState({ row1TagCount: row1Result.count, row2TagCount: row2FullResult.count, hasOverflow: false });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}, [containerRef, measurementRef]);
|
|
102
|
+
|
|
103
|
+
useLayoutEffect(() => {
|
|
104
|
+
// Use requestAnimationFrame to ensure DOM is fully laid out
|
|
105
|
+
const rafId = requestAnimationFrame(() => {
|
|
106
|
+
calculateLayout();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
110
|
+
requestAnimationFrame(() => {
|
|
111
|
+
calculateLayout();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (containerRef.current) {
|
|
116
|
+
resizeObserver.observe(containerRef.current);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return () => {
|
|
120
|
+
cancelAnimationFrame(rafId);
|
|
121
|
+
resizeObserver.disconnect();
|
|
122
|
+
};
|
|
123
|
+
}, [containerRef, measurementRef, totalItems, expanded, calculateLayout]);
|
|
124
|
+
|
|
125
|
+
// When expanded, show all tags
|
|
126
|
+
if (expanded) {
|
|
127
|
+
return {
|
|
128
|
+
row1TagCount: state.row1TagCount,
|
|
129
|
+
row2TagCount: totalItems - state.row1TagCount,
|
|
130
|
+
hasOverflow: true, // Keep expand button visible to allow collapse
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return state;
|
|
135
|
+
};
|