superdesk-ui-framework 5.2.0 → 5.2.2
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/app/styles/app.scss +1 -0
- package/app/styles/components/_overflow-stack.scss +120 -0
- package/app-typescript/components/CopyableTextBox.tsx +99 -0
- package/app-typescript/components/IconButton.tsx +21 -27
- package/app-typescript/components/OverflowStack/OverflowStack.tsx +418 -0
- package/app-typescript/components/OverflowStack/OverflowStackPopover.tsx +45 -0
- package/app-typescript/components/OverflowStack/utils.tsx +16 -0
- package/app-typescript/components/SearchBar.tsx +149 -99
- package/app-typescript/index.ts +2 -0
- package/app-typescript/translations.ts +3 -0
- package/dist/components/CopyableTextBox.tsx +98 -0
- package/dist/components/Index.tsx +16 -1
- package/dist/components/OverflowStack.tsx +915 -0
- package/dist/components/SearchBar.tsx +394 -0
- package/dist/examples.bundle.js +5066 -3900
- package/dist/superdesk-ui.bundle.css +85 -0
- package/dist/superdesk-ui.bundle.js +4079 -3539
- package/dist/vendor.bundle.js +19 -19
- package/package.json +2 -2
- package/react/components/CopyableTextBox.d.ts +12 -0
- package/react/components/CopyableTextBox.js +88 -0
- package/react/components/IconButton.d.ts +1 -7
- package/react/components/IconButton.js +11 -36
- package/react/components/OverflowStack/OverflowStack.d.ts +87 -0
- package/react/components/OverflowStack/OverflowStack.js +305 -0
- package/react/components/OverflowStack/OverflowStackPopover.d.ts +14 -0
- package/react/components/OverflowStack/OverflowStackPopover.js +78 -0
- package/react/components/OverflowStack/utils.d.ts +4 -0
- package/react/components/OverflowStack/utils.js +49 -0
- package/react/components/SearchBar.d.ts +19 -19
- package/react/components/SearchBar.js +90 -83
- package/react/index.d.ts +2 -0
- package/react/index.js +7 -3
- package/react/translations.d.ts +3 -0
- package/react/translations.js +3 -0
package/app/styles/app.scss
CHANGED
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
@import 'components/card-item';
|
|
59
59
|
@import 'components/sd-grid-item';
|
|
60
60
|
@import 'components/sd-searchbar';
|
|
61
|
+
@import 'components/overflow-stack';
|
|
61
62
|
@import 'components/sd-collapse-box';
|
|
62
63
|
@import 'components/sd-photo-preview';
|
|
63
64
|
@import 'components/sd-media-carousel';
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Overflow Stack Component
|
|
2
|
+
// A generic component for stacking items with overflow indicator
|
|
3
|
+
|
|
4
|
+
.overflow-stack {
|
|
5
|
+
display: inline-flex;
|
|
6
|
+
align-items: center;
|
|
7
|
+
position: relative;
|
|
8
|
+
|
|
9
|
+
// Gap variations (for non-overlapping items)
|
|
10
|
+
&.overflow-stack--gap-compact {
|
|
11
|
+
gap: var(--gap-0-5); // 4px
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
&.overflow-stack--gap-loose {
|
|
15
|
+
gap: var(--gap-1); // 8px
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
&.overflow-stack--gap-none {
|
|
19
|
+
gap: 0; // no gap
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Overlapping mode (like avatars)
|
|
23
|
+
&.overflow-stack--overlap {
|
|
24
|
+
gap: 0;
|
|
25
|
+
|
|
26
|
+
.overflow-stack__item {
|
|
27
|
+
margin-inline-start: calc(var(--space--1) * -1);
|
|
28
|
+
transition: transform 0.2s ease, z-index 0.2s ease;
|
|
29
|
+
position: relative;
|
|
30
|
+
z-index: 1;
|
|
31
|
+
|
|
32
|
+
&:first-child {
|
|
33
|
+
margin-inline-start: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:hover {
|
|
37
|
+
transform: translateY(-2px);
|
|
38
|
+
z-index: 10;
|
|
39
|
+
> * {
|
|
40
|
+
outline: 2px solid var(--color-surface-base);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.overflow-stack__indicator {
|
|
46
|
+
margin-inline-start: calc(var(--space--1) * -1);
|
|
47
|
+
z-index: 0;
|
|
48
|
+
|
|
49
|
+
&:hover {
|
|
50
|
+
transform: translateY(-2px);
|
|
51
|
+
z-index: 10;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.overflow-stack__item {
|
|
58
|
+
display: inline-flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.overflow-stack__indicator {
|
|
63
|
+
display: inline-flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
min-width: 3.2rem;
|
|
67
|
+
height: 3.2rem;
|
|
68
|
+
padding: 0 var(--space--0-5);
|
|
69
|
+
background-color: var(--sd-colour-primary);
|
|
70
|
+
color: var(--white);
|
|
71
|
+
border: none;
|
|
72
|
+
border-radius: var(--border-radius--medium);
|
|
73
|
+
font-size: 1.3rem;
|
|
74
|
+
font-weight: 500;
|
|
75
|
+
cursor: pointer;
|
|
76
|
+
transition: background-color 0.2s ease, transform 0.2s ease;
|
|
77
|
+
position: relative;
|
|
78
|
+
|
|
79
|
+
&:hover {
|
|
80
|
+
background-color: var(--sd-colour-primary--darker);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
&:active {
|
|
84
|
+
transform: scale(0.95);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
&:focus-visible {
|
|
88
|
+
outline: 2px solid var(--sd-colour-primary);
|
|
89
|
+
outline-offset: 2px;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.overflow-stack__indicator-content {
|
|
94
|
+
font-size: var(--text-size--small);
|
|
95
|
+
color: var(--color-text-muted);
|
|
96
|
+
line-height: 1;
|
|
97
|
+
display: inline-flex;
|
|
98
|
+
align-items: center;
|
|
99
|
+
justify-content: center;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
padding: var(--space--0-5);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.overflow-stack__popover {
|
|
105
|
+
background-color: var(--color-dropdown-menu-Bg);
|
|
106
|
+
border-radius: var(--b-radius--large);
|
|
107
|
+
padding: var(--space--1-5);
|
|
108
|
+
box-shadow: var(--sd-shadow__dropdown);
|
|
109
|
+
display: flex;
|
|
110
|
+
flex-direction: column;
|
|
111
|
+
min-width: max-content;
|
|
112
|
+
overflow-y: auto;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.overflow-stack__popover-item {
|
|
116
|
+
display: flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
padding-block: var(--space--0-5);
|
|
119
|
+
transition: all 0.2s ease;
|
|
120
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import React, {useEffect} from 'react';
|
|
2
|
+
import {Button} from './Button';
|
|
3
|
+
import {gettext} from '../translations';
|
|
4
|
+
import {InputWrapper} from './Form';
|
|
5
|
+
import nextId from 'react-id-generator';
|
|
6
|
+
import {IInputCommon} from './Form/InputWrapper';
|
|
7
|
+
import {toasted} from './Toast';
|
|
8
|
+
|
|
9
|
+
interface ICopyableTextBoxProps extends IInputCommon {
|
|
10
|
+
value: string;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Defaults to medium
|
|
14
|
+
*/
|
|
15
|
+
size?: 'medium' | 'large';
|
|
16
|
+
|
|
17
|
+
'data-test-id'?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CopyableTextBox: React.FC<ICopyableTextBoxProps> = (props) => {
|
|
21
|
+
const htmlId = nextId();
|
|
22
|
+
const timeoutIdRef = React.useRef<number>();
|
|
23
|
+
const [copied, setCopied] = React.useState(false);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
return () => {
|
|
27
|
+
if (timeoutIdRef.current) {
|
|
28
|
+
window.clearTimeout(timeoutIdRef.current);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const handleCopy = () => {
|
|
34
|
+
navigator.clipboard
|
|
35
|
+
.writeText(props.value)
|
|
36
|
+
.then(() => {
|
|
37
|
+
toasted.notify(gettext('Copied to clipboard'), {
|
|
38
|
+
type: 'success',
|
|
39
|
+
duration: 500,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
setCopied(true);
|
|
43
|
+
|
|
44
|
+
if (timeoutIdRef.current) {
|
|
45
|
+
window.clearTimeout(timeoutIdRef.current);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
timeoutIdRef.current = window.setTimeout(() => {
|
|
49
|
+
setCopied(false);
|
|
50
|
+
}, 2000);
|
|
51
|
+
})
|
|
52
|
+
.catch(() => {
|
|
53
|
+
toasted.notify(gettext("Couldn't copy"), {
|
|
54
|
+
type: 'warning',
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<InputWrapper
|
|
61
|
+
label={props.label}
|
|
62
|
+
disabled={props.disabled}
|
|
63
|
+
value={props.value}
|
|
64
|
+
error={props.error}
|
|
65
|
+
invalid={props.error != null}
|
|
66
|
+
info={props.info}
|
|
67
|
+
size={props.size ?? 'medium'}
|
|
68
|
+
fullWidth={true}
|
|
69
|
+
htmlId={htmlId}
|
|
70
|
+
tabindex={props.tabindex}
|
|
71
|
+
>
|
|
72
|
+
<div className="d-flex items-center gap-1">
|
|
73
|
+
<input
|
|
74
|
+
id={htmlId}
|
|
75
|
+
aria-describedby={htmlId + 'label'}
|
|
76
|
+
type="text"
|
|
77
|
+
className="sd-input__input"
|
|
78
|
+
style={{
|
|
79
|
+
border: '1px solid var(--color-input-border)',
|
|
80
|
+
borderRadius: 'var(--b-radius--medium)',
|
|
81
|
+
}}
|
|
82
|
+
value={props.value}
|
|
83
|
+
disabled
|
|
84
|
+
data-test-id={props['data-test-id']}
|
|
85
|
+
/>
|
|
86
|
+
<Button
|
|
87
|
+
text={gettext('Copy')}
|
|
88
|
+
icon={copied ? 'ok' : 'copy'}
|
|
89
|
+
iconOnly={true}
|
|
90
|
+
onClick={handleCopy}
|
|
91
|
+
type="default"
|
|
92
|
+
style="hollow"
|
|
93
|
+
size={props.size === 'medium' ? 'normal' : (props.size ?? 'normal')}
|
|
94
|
+
data-test-id="copy-button"
|
|
95
|
+
/>
|
|
96
|
+
</div>
|
|
97
|
+
</InputWrapper>
|
|
98
|
+
);
|
|
99
|
+
};
|
|
@@ -8,37 +8,31 @@ interface IProps {
|
|
|
8
8
|
icon: string;
|
|
9
9
|
ariaValue: string;
|
|
10
10
|
toolTipFlow?: 'top' | 'left' | 'right' | 'down';
|
|
11
|
-
toolTipAppend?: boolean;
|
|
12
11
|
size?: 'default' | 'small' | 'x-large';
|
|
13
12
|
style?: 'default' | 'outline' | 'outlineWhite';
|
|
14
13
|
disabled?: boolean;
|
|
15
14
|
onClick(event: React.MouseEvent): void;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
[`icn-btn--${this.props.size}`]: this.props.size || this.props.size !== undefined,
|
|
25
|
-
[`icn-btn--${this.props.style}`]: this.props.style || this.props.style !== undefined,
|
|
26
|
-
'icn-btn--disabled': this.props.disabled,
|
|
27
|
-
});
|
|
17
|
+
export const IconButton: React.FC<IProps> = (props) => {
|
|
18
|
+
const classes = classNames('icn-btn', {
|
|
19
|
+
[`icn-btn--${props.size}`]: props.size || props.size !== undefined,
|
|
20
|
+
[`icn-btn--${props.style}`]: props.style || props.style !== undefined,
|
|
21
|
+
'icn-btn--disabled': props.disabled,
|
|
22
|
+
});
|
|
28
23
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
24
|
+
return (
|
|
25
|
+
<Tooltip text={props.disabled ? null : props.ariaValue} flow={props.toolTipFlow}>
|
|
26
|
+
<button
|
|
27
|
+
id={props.id}
|
|
28
|
+
tabIndex={0}
|
|
29
|
+
onClick={props.onClick}
|
|
30
|
+
className={classes}
|
|
31
|
+
disabled={props.disabled}
|
|
32
|
+
aria-label={props.ariaValue}
|
|
33
|
+
>
|
|
34
|
+
<Icon name={props.icon} ariaHidden={true} />
|
|
35
|
+
</button>
|
|
36
|
+
</Tooltip>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import debounce from 'lodash/debounce';
|
|
4
|
+
import {WithPopover} from '../WithPopover';
|
|
5
|
+
import {HeadlessButton} from '../HeadlessButton';
|
|
6
|
+
import {IconButton} from '../IconButton';
|
|
7
|
+
import {OverflowStackPopover} from './OverflowStackPopover';
|
|
8
|
+
import {defaultIndicator, HIDDEN_ITEM_STYLE} from './utils';
|
|
9
|
+
|
|
10
|
+
interface IPropsOverflowStackBase {
|
|
11
|
+
/**
|
|
12
|
+
* Default: {type: `fixed`, max: 4}
|
|
13
|
+
*/
|
|
14
|
+
overflow?:
|
|
15
|
+
| {
|
|
16
|
+
type: 'fixed';
|
|
17
|
+
max?: number | 'show-all';
|
|
18
|
+
}
|
|
19
|
+
| {
|
|
20
|
+
type: 'auto';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Defaults to `compact`
|
|
25
|
+
*/
|
|
26
|
+
gap?: 'compact' | 'loose' | 'none';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* When `true`, items will have negative margin and expand on hover
|
|
30
|
+
*/
|
|
31
|
+
overlap?: boolean;
|
|
32
|
+
|
|
33
|
+
showOnlyHiddenInPopover?: boolean;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Defaults to `count`
|
|
37
|
+
* `count`: Shows "+N" with the number of hidden items
|
|
38
|
+
* `dots`: Shows a three dots icon without the count
|
|
39
|
+
*/
|
|
40
|
+
indicatorStyle?: 'count' | 'dots';
|
|
41
|
+
|
|
42
|
+
renderIndicator?: (count: number) => React.ReactNode;
|
|
43
|
+
onIndicatorClick?: () => void;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Defaults to `full`
|
|
47
|
+
* Border radius for the indicator button
|
|
48
|
+
*/
|
|
49
|
+
indicatorRadius?: 'x-small' | 'small' | 'medium' | 'full';
|
|
50
|
+
|
|
51
|
+
containerClassName?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface IPropsOverflowStackSimple extends IPropsOverflowStackBase {
|
|
55
|
+
type: 'simple';
|
|
56
|
+
items: Array<React.ReactNode>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Custom render function for items in the popover.
|
|
60
|
+
* Useful if you just want to wrap children, but still keep everything else default.
|
|
61
|
+
* If not provided, items will be rendered as-is
|
|
62
|
+
*/
|
|
63
|
+
renderPopoverItem?: (item: React.ReactNode, index: number) => React.ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface IPropsOverflowStackData<T> extends IPropsOverflowStackBase {
|
|
67
|
+
type: 'data';
|
|
68
|
+
items: Array<T>;
|
|
69
|
+
|
|
70
|
+
renderVisibleItem: (data: T, index: number) => React.ReactNode;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* If not provided, `renderVisibleItem` will be used
|
|
74
|
+
*/
|
|
75
|
+
renderHiddenItem?: (data: T, index: number) => React.ReactNode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type IPropsOverflowStack<T> = IPropsOverflowStackSimple | IPropsOverflowStackData<T>;
|
|
79
|
+
|
|
80
|
+
interface IStateOverflowStack {
|
|
81
|
+
/**
|
|
82
|
+
* Number of visible items when using auto overflow
|
|
83
|
+
*/
|
|
84
|
+
visibleCount: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class OverflowStack<T> extends React.PureComponent<IPropsOverflowStack<T>, IStateOverflowStack> {
|
|
88
|
+
private containerRef = React.createRef<HTMLDivElement>();
|
|
89
|
+
private itemRefs: Array<React.RefObject<HTMLDivElement>> = [];
|
|
90
|
+
private indicatorRef = React.createRef<HTMLDivElement>();
|
|
91
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
92
|
+
private popoverContentRef = React.createRef<OverflowStackPopover<T>>();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Use a ref to avoid closure when passing props.
|
|
96
|
+
*/
|
|
97
|
+
private currentPropsRef: {current: IPropsOverflowStack<T> | null} = {current: null};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Debounced calculation to avoid excessive updates during resize
|
|
101
|
+
*/
|
|
102
|
+
private debouncedCalculate = debounce(() => {
|
|
103
|
+
this.calculateVisibleItems();
|
|
104
|
+
}, 50);
|
|
105
|
+
|
|
106
|
+
constructor(props: IPropsOverflowStack<T>) {
|
|
107
|
+
super(props);
|
|
108
|
+
const itemCount = this.getItemCount(props);
|
|
109
|
+
this.state = {
|
|
110
|
+
visibleCount: itemCount,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
this.itemRefs = Array.from({length: itemCount}, () => React.createRef<HTMLDivElement>());
|
|
114
|
+
this.currentPropsRef.current = props;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private getItemCount(props: IPropsOverflowStack<T>): number {
|
|
118
|
+
if (props.type === 'data') {
|
|
119
|
+
return props.items.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return props.items?.length ?? 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
componentDidMount() {
|
|
126
|
+
if (this.props.overflow?.type === 'auto') {
|
|
127
|
+
this.setupResizeObserver();
|
|
128
|
+
|
|
129
|
+
requestAnimationFrame(() => {
|
|
130
|
+
this.calculateVisibleItems();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
componentDidUpdate(prevProps: IPropsOverflowStack<T>) {
|
|
136
|
+
// Update the props ref for the popover
|
|
137
|
+
this.currentPropsRef.current = this.props;
|
|
138
|
+
this.popoverContentRef.current?.forceUpdate?.();
|
|
139
|
+
|
|
140
|
+
const isAutoMode = this.props.overflow?.type === 'auto';
|
|
141
|
+
|
|
142
|
+
if (prevProps.overflow !== this.props.overflow) {
|
|
143
|
+
if (isAutoMode) {
|
|
144
|
+
this.setupResizeObserver();
|
|
145
|
+
} else {
|
|
146
|
+
this.cleanupResizeObserver();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isAutoMode) {
|
|
151
|
+
const prevItemCount = this.getItemCount(prevProps);
|
|
152
|
+
const currentItemCount = this.getItemCount(this.props);
|
|
153
|
+
|
|
154
|
+
if (prevItemCount !== currentItemCount) {
|
|
155
|
+
// Recreate refs if item count changed
|
|
156
|
+
this.itemRefs = Array.from({length: currentItemCount}, () => React.createRef<HTMLDivElement>());
|
|
157
|
+
|
|
158
|
+
this.setState({visibleCount: currentItemCount}, () => {
|
|
159
|
+
requestAnimationFrame(() => this.calculateVisibleItems());
|
|
160
|
+
});
|
|
161
|
+
} else {
|
|
162
|
+
requestAnimationFrame(() => this.calculateVisibleItems());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
componentWillUnmount() {
|
|
168
|
+
this.cleanupResizeObserver();
|
|
169
|
+
this.debouncedCalculate.cancel();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private setupResizeObserver() {
|
|
173
|
+
if (this.resizeObserver) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
178
|
+
this.debouncedCalculate();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (this.containerRef.current) {
|
|
182
|
+
this.resizeObserver.observe(this.containerRef.current);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private cleanupResizeObserver() {
|
|
187
|
+
if (this.resizeObserver) {
|
|
188
|
+
this.resizeObserver.disconnect();
|
|
189
|
+
this.resizeObserver = null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private getGapSize(): number {
|
|
194
|
+
const {gap = 'compact', overlap = false} = this.props;
|
|
195
|
+
|
|
196
|
+
if (overlap) {
|
|
197
|
+
return -8; // Overlapping items have negative margin
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (gap) {
|
|
201
|
+
case 'compact':
|
|
202
|
+
return 4;
|
|
203
|
+
case 'loose':
|
|
204
|
+
return 8;
|
|
205
|
+
case 'none':
|
|
206
|
+
return 0;
|
|
207
|
+
default:
|
|
208
|
+
return 4;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private calculateVisibleItems() {
|
|
213
|
+
if (!this.containerRef.current || this.props.overflow?.type !== 'auto') {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const containerWidth = this.containerRef.current.offsetWidth;
|
|
218
|
+
|
|
219
|
+
// If container has no width yet, skip calculation
|
|
220
|
+
if (containerWidth === 0) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const gapSize = this.getGapSize();
|
|
225
|
+
const indicatorWidth = this.indicatorRef.current?.offsetWidth || 40;
|
|
226
|
+
const totalItemCount = this.getItemCount(this.props);
|
|
227
|
+
|
|
228
|
+
let totalWidth = 0;
|
|
229
|
+
let visibleCount = 0;
|
|
230
|
+
|
|
231
|
+
// Calculate how many items fit
|
|
232
|
+
for (let i = 0; i < this.itemRefs.length; i++) {
|
|
233
|
+
const itemRef = this.itemRefs[i];
|
|
234
|
+
|
|
235
|
+
if (!itemRef.current) {
|
|
236
|
+
visibleCount = totalItemCount;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const itemWidth = itemRef.current.offsetWidth;
|
|
241
|
+
|
|
242
|
+
if (itemWidth === 0) {
|
|
243
|
+
visibleCount = totalItemCount;
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const widthWithGap = itemWidth + (i > 0 ? gapSize : 0);
|
|
248
|
+
const needsIndicator = i < totalItemCount - 1;
|
|
249
|
+
const availableWidth = containerWidth - (needsIndicator ? indicatorWidth + gapSize : 0);
|
|
250
|
+
|
|
251
|
+
if (totalWidth + widthWithGap <= availableWidth) {
|
|
252
|
+
totalWidth += widthWithGap;
|
|
253
|
+
visibleCount = i + 1;
|
|
254
|
+
} else {
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (visibleCount === 0 && totalItemCount > 0) {
|
|
260
|
+
visibleCount = 1;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (this.state.visibleCount !== visibleCount) {
|
|
264
|
+
this.setState({visibleCount});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
render() {
|
|
269
|
+
const {
|
|
270
|
+
gap = 'compact',
|
|
271
|
+
overlap = false,
|
|
272
|
+
overflow = {
|
|
273
|
+
type: 'fixed',
|
|
274
|
+
max: 4,
|
|
275
|
+
},
|
|
276
|
+
showOnlyHiddenInPopover = false,
|
|
277
|
+
indicatorStyle = 'count',
|
|
278
|
+
renderIndicator,
|
|
279
|
+
onIndicatorClick,
|
|
280
|
+
indicatorRadius = 'full',
|
|
281
|
+
containerClassName: className,
|
|
282
|
+
} = this.props;
|
|
283
|
+
|
|
284
|
+
const itemCount = this.getItemCount(this.props);
|
|
285
|
+
|
|
286
|
+
let max: number;
|
|
287
|
+
|
|
288
|
+
if (overflow.type === 'auto') {
|
|
289
|
+
max = this.state.visibleCount;
|
|
290
|
+
} else if (overflow.max === 'show-all') {
|
|
291
|
+
max = itemCount;
|
|
292
|
+
} else {
|
|
293
|
+
max = overflow.max ?? 4;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const itemsOverLimit = itemCount - max;
|
|
297
|
+
|
|
298
|
+
const renderVisibleItems = () => {
|
|
299
|
+
const renderItemWrapper = (content: React.ReactNode, index: number) => {
|
|
300
|
+
const isVisible = index < max;
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div
|
|
304
|
+
key={index}
|
|
305
|
+
ref={overflow.type === 'auto' ? this.itemRefs[index] : undefined}
|
|
306
|
+
className="overflow-stack__item"
|
|
307
|
+
style={overflow.type === 'auto' && !isVisible ? HIDDEN_ITEM_STYLE : undefined}
|
|
308
|
+
>
|
|
309
|
+
{content}
|
|
310
|
+
</div>
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const {props} = this;
|
|
315
|
+
|
|
316
|
+
if (props.type === 'data') {
|
|
317
|
+
const itemsToRender = overflow.type === 'auto' ? props.items : props.items.slice(0, max);
|
|
318
|
+
|
|
319
|
+
return itemsToRender.map((data, index) =>
|
|
320
|
+
renderItemWrapper(props.renderVisibleItem(data, index), index),
|
|
321
|
+
);
|
|
322
|
+
} else {
|
|
323
|
+
const itemsToRender = overflow.type === 'auto' ? props.items : props.items.slice(0, max);
|
|
324
|
+
|
|
325
|
+
return itemsToRender.map((item, index) => renderItemWrapper(item, index));
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const ariaLabel = showOnlyHiddenInPopover
|
|
330
|
+
? `Show ${itemsOverLimit} hidden items`
|
|
331
|
+
: `Show ${itemsOverLimit} more items`;
|
|
332
|
+
|
|
333
|
+
const renderIndicatorButton = (onToggle: (ref: HTMLElement) => void) => {
|
|
334
|
+
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
|
|
335
|
+
e.stopPropagation();
|
|
336
|
+
|
|
337
|
+
if (onIndicatorClick == null) {
|
|
338
|
+
onToggle(e.currentTarget);
|
|
339
|
+
} else {
|
|
340
|
+
onIndicatorClick();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
let indicator: React.ReactNode;
|
|
345
|
+
|
|
346
|
+
if (indicatorStyle === 'dots') {
|
|
347
|
+
indicator = <IconButton size="small" icon="dots" ariaValue={ariaLabel} onClick={handleClick} />;
|
|
348
|
+
} else {
|
|
349
|
+
const content = renderIndicator?.(itemsOverLimit) ?? defaultIndicator(itemsOverLimit);
|
|
350
|
+
|
|
351
|
+
indicator = (
|
|
352
|
+
<HeadlessButton
|
|
353
|
+
radius={indicatorRadius}
|
|
354
|
+
onClick={handleClick}
|
|
355
|
+
ariaLabel={ariaLabel}
|
|
356
|
+
tooltip={ariaLabel}
|
|
357
|
+
>
|
|
358
|
+
{content}
|
|
359
|
+
</HeadlessButton>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Wrap in a div with ref for auto mode (needed for width measurement)
|
|
364
|
+
if (overflow.type === 'auto') {
|
|
365
|
+
return (
|
|
366
|
+
<div ref={this.indicatorRef} style={{display: 'inline-flex'}}>
|
|
367
|
+
{indicator}
|
|
368
|
+
</div>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return indicator;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const classes = classNames(
|
|
376
|
+
'overflow-stack',
|
|
377
|
+
{
|
|
378
|
+
'overflow-stack--overlap': overlap,
|
|
379
|
+
[`overflow-stack--gap-${gap}`]: !overlap,
|
|
380
|
+
},
|
|
381
|
+
className,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
return (
|
|
385
|
+
<WithPopover
|
|
386
|
+
component={({closePopup}) => (
|
|
387
|
+
<OverflowStackPopover
|
|
388
|
+
ref={this.popoverContentRef}
|
|
389
|
+
propsRef={this.currentPropsRef}
|
|
390
|
+
max={max}
|
|
391
|
+
showOnlyHiddenInPopover={showOnlyHiddenInPopover}
|
|
392
|
+
closePopup={closePopup}
|
|
393
|
+
/>
|
|
394
|
+
)}
|
|
395
|
+
>
|
|
396
|
+
{(onToggle) => {
|
|
397
|
+
const stackContent = (
|
|
398
|
+
<div
|
|
399
|
+
ref={this.containerRef}
|
|
400
|
+
className={classes}
|
|
401
|
+
role="group"
|
|
402
|
+
style={overflow.type === 'auto' ? {width: '100%'} : undefined}
|
|
403
|
+
>
|
|
404
|
+
{renderVisibleItems()}
|
|
405
|
+
{itemsOverLimit > 0 && renderIndicatorButton(onToggle)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
if (overflow.type === 'auto') {
|
|
410
|
+
return <div style={{display: 'flex', width: '100%', minWidth: 0}}>{stackContent}</div>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return stackContent;
|
|
414
|
+
}}
|
|
415
|
+
</WithPopover>
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|