uikit-react-public 0.29.5 → 0.29.6
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/dist/components/Overlay/Overlay.d.ts +14 -3
- package/dist/components/Overlay/Overlay.stories.d.ts +31 -2
- package/dist/components/Overlay/__tests__/Overlay.test.d.ts +1 -0
- package/dist/components/Overlay/index.d.ts +1 -1
- package/dist/components/Select/Select.stories.d.ts +12 -0
- package/dist/components/Select/Select.types.d.ts +7 -0
- package/dist/components/Select/subcomponents/CustomSelect.d.ts +1 -1
- package/dist/components/Select/subcomponents/Panel.d.ts +8 -2
- package/dist/index.js +4780 -4662
- package/lib/components/Overlay/Overlay.tsx +67 -21
- package/lib/components/Overlay/__tests__/Overlay.test.tsx +81 -0
- package/lib/components/Overlay/index.ts +1 -1
- package/lib/components/Select/Select.stories.tsx +7 -0
- package/lib/components/Select/Select.tsx +7 -0
- package/lib/components/Select/Select.types.ts +8 -0
- package/lib/components/Select/__tests__/Select.test.tsx +181 -1
- package/lib/components/Select/subcomponents/CustomSelect.tsx +109 -27
- package/lib/components/Select/subcomponents/Panel.tsx +40 -10
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
HTMLAttributes,
|
|
3
|
+
CSSProperties,
|
|
3
4
|
forwardRef,
|
|
4
5
|
memo,
|
|
5
6
|
useLayoutEffect,
|
|
@@ -15,8 +16,11 @@ import {
|
|
|
15
16
|
flip as fuiFlip,
|
|
16
17
|
shift as fuiShift,
|
|
17
18
|
offset as fuiOffset,
|
|
19
|
+
size as fuiSize,
|
|
18
20
|
autoUpdate,
|
|
19
21
|
Placement,
|
|
22
|
+
type FlipOptions,
|
|
23
|
+
type ShiftOptions,
|
|
20
24
|
// Strategy,
|
|
21
25
|
} from '@floating-ui/react-dom';
|
|
22
26
|
|
|
@@ -26,19 +30,29 @@ import Blanket from '../Blanket';
|
|
|
26
30
|
|
|
27
31
|
export const NAME = 'ucl-overlay';
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
type OverlaySizeOptions = {
|
|
34
|
+
padding?: number;
|
|
35
|
+
matchReferenceWidth?: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type OverlaySize = {
|
|
39
|
+
referenceWidth: number;
|
|
40
|
+
availableWidth: number;
|
|
41
|
+
availableHeight: number;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface OverlayProps extends HTMLAttributes<HTMLDivElement> {
|
|
31
45
|
reference: RefObject<HTMLElement | null>;
|
|
32
46
|
placement?: Placement;
|
|
33
47
|
blanket?: boolean;
|
|
34
|
-
flip?: boolean;
|
|
35
|
-
shift?: boolean;
|
|
48
|
+
flip?: boolean | FlipOptions;
|
|
49
|
+
shift?: boolean | ShiftOptions;
|
|
36
50
|
offset?: number;
|
|
51
|
+
size?: boolean | OverlaySizeOptions;
|
|
37
52
|
arrow?: ReactElement;
|
|
38
53
|
arrowClassName?: string;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
) => void;
|
|
54
|
+
onSizeChange?: (size: OverlaySize) => void;
|
|
55
|
+
onBlanketClick?: (ev: React.MouseEvent<HTMLDivElement>) => void;
|
|
42
56
|
}
|
|
43
57
|
|
|
44
58
|
export type Ref = HTMLDivElement | null;
|
|
@@ -52,27 +66,51 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
52
66
|
flip = true,
|
|
53
67
|
shift = true,
|
|
54
68
|
offset = 0,
|
|
69
|
+
size = false,
|
|
55
70
|
arrow,
|
|
56
71
|
className,
|
|
72
|
+
style: inlineStyle,
|
|
57
73
|
arrowClassName,
|
|
74
|
+
onSizeChange,
|
|
58
75
|
onBlanketClick,
|
|
59
76
|
children,
|
|
60
77
|
...props
|
|
61
78
|
}: OverlayProps,
|
|
62
79
|
forwardedRef
|
|
63
80
|
) => {
|
|
64
|
-
const ref = useRef(null);
|
|
81
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
65
82
|
const arrowRef = useRef<HTMLElement>(null);
|
|
66
83
|
|
|
67
|
-
useImperativeHandle<Ref, Ref>(
|
|
68
|
-
forwardedRef,
|
|
69
|
-
() => ref.current
|
|
70
|
-
);
|
|
84
|
+
useImperativeHandle<Ref, Ref>(forwardedRef, () => ref.current);
|
|
71
85
|
|
|
72
86
|
const fuiMiddleware = [];
|
|
73
|
-
if (flip) fuiMiddleware.push(fuiFlip());
|
|
74
|
-
if (shift)
|
|
87
|
+
if (flip) fuiMiddleware.push(fuiFlip(typeof flip === 'object' ? flip : {}));
|
|
88
|
+
if (shift)
|
|
89
|
+
fuiMiddleware.push(fuiShift(typeof shift === 'object' ? shift : {}));
|
|
75
90
|
if (offset) fuiMiddleware.push(fuiOffset(offset));
|
|
91
|
+
if (size) {
|
|
92
|
+
const sizeOptions = typeof size === 'object' ? size : {};
|
|
93
|
+
const { padding = 0, matchReferenceWidth = false } = sizeOptions;
|
|
94
|
+
|
|
95
|
+
fuiMiddleware.push(
|
|
96
|
+
fuiSize({
|
|
97
|
+
padding,
|
|
98
|
+
apply({ availableWidth, availableHeight, elements, rects }) {
|
|
99
|
+
const { style } = elements.floating;
|
|
100
|
+
|
|
101
|
+
onSizeChange?.({
|
|
102
|
+
referenceWidth: rects.reference.width,
|
|
103
|
+
availableWidth: Math.max(0, availableWidth),
|
|
104
|
+
availableHeight: Math.max(0, availableHeight),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (matchReferenceWidth) {
|
|
108
|
+
style.minWidth = `${rects.reference.width}px`;
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
);
|
|
113
|
+
}
|
|
76
114
|
|
|
77
115
|
const {
|
|
78
116
|
x,
|
|
@@ -88,9 +126,11 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
88
126
|
whileElementsMounted: autoUpdate,
|
|
89
127
|
});
|
|
90
128
|
|
|
91
|
-
const overlayPlacement = currentPlacement.split(
|
|
92
|
-
'
|
|
93
|
-
|
|
129
|
+
const overlayPlacement = currentPlacement.split('-')[0] as
|
|
130
|
+
| 'left'
|
|
131
|
+
| 'right'
|
|
132
|
+
| 'top'
|
|
133
|
+
| 'bottom';
|
|
94
134
|
|
|
95
135
|
useLayoutEffect(() => {
|
|
96
136
|
refs.setReference(reference.current);
|
|
@@ -135,9 +175,7 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
135
175
|
let ArrowComp = null;
|
|
136
176
|
|
|
137
177
|
if (arrow) {
|
|
138
|
-
const arrowElement = arrow as ReactElement<
|
|
139
|
-
HTMLAttributes<HTMLElement>
|
|
140
|
-
>;
|
|
178
|
+
const arrowElement = arrow as ReactElement<HTMLAttributes<HTMLElement>>;
|
|
141
179
|
|
|
142
180
|
arrowStyle = cx(
|
|
143
181
|
arrowBaseStyle,
|
|
@@ -177,12 +215,20 @@ const Overlay = forwardRef<Ref, OverlayProps>(
|
|
|
177
215
|
<>
|
|
178
216
|
{blanket && <Blanket onClick={onBlanketClick} />}
|
|
179
217
|
<div
|
|
180
|
-
ref={
|
|
218
|
+
ref={(node) => {
|
|
219
|
+
ref.current = node;
|
|
220
|
+
refs.setFloating(node);
|
|
221
|
+
}}
|
|
181
222
|
className={style}
|
|
182
223
|
style={{
|
|
224
|
+
...(inlineStyle as CSSProperties),
|
|
183
225
|
position: strategy,
|
|
184
226
|
left: x ?? 0,
|
|
185
227
|
top: y ?? 0,
|
|
228
|
+
visibility:
|
|
229
|
+
x === null || y === null
|
|
230
|
+
? 'hidden'
|
|
231
|
+
: (inlineStyle as CSSProperties | undefined)?.visibility,
|
|
186
232
|
}}
|
|
187
233
|
{...props}
|
|
188
234
|
>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { render, waitFor } from '@testing-library/react';
|
|
4
|
+
import Overlay from '../Overlay';
|
|
5
|
+
|
|
6
|
+
describe('Overlay', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('reports viewport sizing when size is enabled', async () => {
|
|
12
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(
|
|
13
|
+
function (this: HTMLElement) {
|
|
14
|
+
if (this.classList.contains('ucl-overlay')) {
|
|
15
|
+
return {
|
|
16
|
+
x: 0,
|
|
17
|
+
y: 48,
|
|
18
|
+
top: 48,
|
|
19
|
+
left: 0,
|
|
20
|
+
right: 120,
|
|
21
|
+
bottom: 88,
|
|
22
|
+
width: 120,
|
|
23
|
+
height: 40,
|
|
24
|
+
toJSON: () => {},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
x: 0,
|
|
30
|
+
y: 0,
|
|
31
|
+
top: 0,
|
|
32
|
+
left: 0,
|
|
33
|
+
right: 240,
|
|
34
|
+
bottom: 48,
|
|
35
|
+
width: 240,
|
|
36
|
+
height: 48,
|
|
37
|
+
toJSON: () => {},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
);
|
|
41
|
+
const handleSizeChange = vi.fn();
|
|
42
|
+
|
|
43
|
+
const TestOverlay = () => {
|
|
44
|
+
const reference = useRef<HTMLButtonElement>(null);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<button
|
|
49
|
+
ref={reference}
|
|
50
|
+
type='button'
|
|
51
|
+
>
|
|
52
|
+
Anchor
|
|
53
|
+
</button>
|
|
54
|
+
<Overlay
|
|
55
|
+
reference={reference}
|
|
56
|
+
size={{ matchReferenceWidth: true }}
|
|
57
|
+
onSizeChange={handleSizeChange}
|
|
58
|
+
>
|
|
59
|
+
Content
|
|
60
|
+
</Overlay>
|
|
61
|
+
</>
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const result = render(<TestOverlay />);
|
|
66
|
+
const overlay = result.container.querySelector('.ucl-overlay');
|
|
67
|
+
|
|
68
|
+
expect(overlay).toBeInTheDocument();
|
|
69
|
+
|
|
70
|
+
await waitFor(() => {
|
|
71
|
+
expect(overlay).toHaveStyle({
|
|
72
|
+
minWidth: '240px',
|
|
73
|
+
});
|
|
74
|
+
expect(handleSizeChange).toHaveBeenCalledWith({
|
|
75
|
+
referenceWidth: 240,
|
|
76
|
+
availableWidth: expect.any(Number),
|
|
77
|
+
availableHeight: expect.any(Number),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { default } from './Overlay';
|
|
2
|
-
export type { OverlayProps } from './Overlay';
|
|
2
|
+
export type { OverlayProps, OverlaySize } from './Overlay';
|
|
@@ -100,6 +100,13 @@ const meta = {
|
|
|
100
100
|
control: { type: 'boolean' },
|
|
101
101
|
table: { type: { summary: 'boolean' } },
|
|
102
102
|
},
|
|
103
|
+
dropdownWidth: {
|
|
104
|
+
description:
|
|
105
|
+
'Control whether the options panel grows to content or matches the Select width',
|
|
106
|
+
control: { type: 'radio' },
|
|
107
|
+
options: ['content', 'match-select'],
|
|
108
|
+
table: { type: { summary: "'content' | 'match-select'" } },
|
|
109
|
+
},
|
|
103
110
|
panelClassName: {
|
|
104
111
|
description: 'Custom className for the options panel',
|
|
105
112
|
control: { type: 'text' },
|
|
@@ -14,6 +14,7 @@ const Select = (<T extends string | number = string>(
|
|
|
14
14
|
filterable,
|
|
15
15
|
clearable,
|
|
16
16
|
onValueChange,
|
|
17
|
+
dropdownWidth,
|
|
17
18
|
ref,
|
|
18
19
|
...rest
|
|
19
20
|
} = props;
|
|
@@ -25,6 +26,11 @@ const Select = (<T extends string | number = string>(
|
|
|
25
26
|
if (clearable) {
|
|
26
27
|
console.warn('clearable is not supported on native Select; ignoring.');
|
|
27
28
|
}
|
|
29
|
+
if (dropdownWidth) {
|
|
30
|
+
console.warn(
|
|
31
|
+
'dropdownWidth is not supported on native Select; ignoring.'
|
|
32
|
+
);
|
|
33
|
+
}
|
|
28
34
|
const { value, ...nativeRest } = rest;
|
|
29
35
|
const {
|
|
30
36
|
value: nativeAttrValue,
|
|
@@ -51,6 +57,7 @@ const Select = (<T extends string | number = string>(
|
|
|
51
57
|
onValueChange={onValueChange}
|
|
52
58
|
filterable={filterable}
|
|
53
59
|
clearable={clearable}
|
|
60
|
+
dropdownWidth={dropdownWidth}
|
|
54
61
|
ref={ref as React.Ref<HTMLDivElement>}
|
|
55
62
|
{...rest}
|
|
56
63
|
/>
|
|
@@ -37,6 +37,8 @@ export type FilterInputProps = Omit<
|
|
|
37
37
|
| 'aria-label'
|
|
38
38
|
>;
|
|
39
39
|
|
|
40
|
+
export type SelectDropdownWidth = 'content' | 'match-select';
|
|
41
|
+
|
|
40
42
|
type SelectBaseProps<T = string | number> = Omit<
|
|
41
43
|
React.HTMLAttributes<HTMLElement>,
|
|
42
44
|
'onChange'
|
|
@@ -91,6 +93,12 @@ type SelectBaseProps<T = string | number> = Omit<
|
|
|
91
93
|
* Allow long option labels to wrap instead of truncating
|
|
92
94
|
*/
|
|
93
95
|
lineBreak?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Controls the width of the custom options panel.
|
|
98
|
+
* - `content` (default): panel is at least the Select width and can grow to fit option labels.
|
|
99
|
+
* - `match-select`: panel width is capped to the Select width.
|
|
100
|
+
*/
|
|
101
|
+
dropdownWidth?: SelectDropdownWidth;
|
|
94
102
|
/**
|
|
95
103
|
* Custom className for the root element
|
|
96
104
|
*/
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test, vi, beforeAll } from 'vitest';
|
|
2
2
|
import { useState } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createEvent,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
waitFor,
|
|
8
|
+
} from '@testing-library/react';
|
|
4
9
|
import userEvent from '@testing-library/user-event';
|
|
5
10
|
import Select from '../Select';
|
|
6
11
|
import { ThemeContextProvider } from '../../../theme/useTheme';
|
|
@@ -91,6 +96,181 @@ describe('Select', () => {
|
|
|
91
96
|
expect(options[2].textContent).toBe(defaultOptions[2].label);
|
|
92
97
|
});
|
|
93
98
|
|
|
99
|
+
test('renders the open panel inside an overlay', async () => {
|
|
100
|
+
const user = userEvent.setup();
|
|
101
|
+
const renderResult = render(
|
|
102
|
+
<ThemeContextProvider>
|
|
103
|
+
<Select
|
|
104
|
+
options={defaultOptions}
|
|
105
|
+
value=''
|
|
106
|
+
onValueChange={() => {}}
|
|
107
|
+
/>
|
|
108
|
+
</ThemeContextProvider>
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
await user.click(renderResult.getByRole('combobox'));
|
|
112
|
+
|
|
113
|
+
const panel = renderResult.getByTestId('ucl-uikit-select__panel');
|
|
114
|
+
const overlay = panel.parentElement;
|
|
115
|
+
|
|
116
|
+
expect(overlay).toHaveClass('ucl-overlay');
|
|
117
|
+
await waitFor(() => {
|
|
118
|
+
expect(overlay).toHaveStyle({
|
|
119
|
+
position: 'absolute',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('dropdownWidth defaults to content width', async () => {
|
|
125
|
+
const user = userEvent.setup();
|
|
126
|
+
const renderResult = render(
|
|
127
|
+
<ThemeContextProvider>
|
|
128
|
+
<Select
|
|
129
|
+
options={defaultOptions}
|
|
130
|
+
value=''
|
|
131
|
+
onValueChange={() => {}}
|
|
132
|
+
/>
|
|
133
|
+
</ThemeContextProvider>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
await user.click(renderResult.getByRole('combobox'));
|
|
137
|
+
|
|
138
|
+
expect(renderResult.getByRole('listbox')).toHaveStyle({
|
|
139
|
+
width: 'fit-content',
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('dropdownWidth=match-select caps panel width to the select width', async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
const getBoundingClientRectSpy = vi.spyOn(
|
|
146
|
+
Element.prototype,
|
|
147
|
+
'getBoundingClientRect'
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
getBoundingClientRectSpy.mockImplementation(() => ({
|
|
151
|
+
x: 0,
|
|
152
|
+
y: 0,
|
|
153
|
+
top: 0,
|
|
154
|
+
left: 0,
|
|
155
|
+
right: 240,
|
|
156
|
+
bottom: 48,
|
|
157
|
+
width: 240,
|
|
158
|
+
height: 48,
|
|
159
|
+
toJSON: () => {},
|
|
160
|
+
}));
|
|
161
|
+
|
|
162
|
+
const renderResult = render(
|
|
163
|
+
<ThemeContextProvider>
|
|
164
|
+
<Select
|
|
165
|
+
dropdownWidth='match-select'
|
|
166
|
+
options={defaultOptions}
|
|
167
|
+
value=''
|
|
168
|
+
onValueChange={() => {}}
|
|
169
|
+
/>
|
|
170
|
+
</ThemeContextProvider>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
await user.click(renderResult.getByRole('combobox'));
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(renderResult.getByRole('listbox')).toHaveStyle({
|
|
177
|
+
width: '240px',
|
|
178
|
+
maxWidth: '240px',
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
getBoundingClientRectSpy.mockRestore();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('open dropdown closes when clicking outside', async () => {
|
|
186
|
+
const user = userEvent.setup();
|
|
187
|
+
const renderResult = render(
|
|
188
|
+
<ThemeContextProvider>
|
|
189
|
+
<div>
|
|
190
|
+
<Select
|
|
191
|
+
options={defaultOptions}
|
|
192
|
+
value=''
|
|
193
|
+
onValueChange={() => {}}
|
|
194
|
+
/>
|
|
195
|
+
<button type='button'>Outside</button>
|
|
196
|
+
</div>
|
|
197
|
+
</ThemeContextProvider>
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
await user.click(renderResult.getByRole('combobox'));
|
|
201
|
+
expect(renderResult.getByRole('listbox')).toBeInTheDocument();
|
|
202
|
+
|
|
203
|
+
await user.click(renderResult.getByRole('button', { name: 'Outside' }));
|
|
204
|
+
|
|
205
|
+
expect(renderResult.queryByRole('listbox')).not.toBeInTheDocument();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('panel width is capped to dialog body width inside a dialog', async () => {
|
|
209
|
+
const user = userEvent.setup();
|
|
210
|
+
const getBoundingClientRectSpy = vi.spyOn(
|
|
211
|
+
Element.prototype,
|
|
212
|
+
'getBoundingClientRect'
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
getBoundingClientRectSpy.mockImplementation(function (this: Element) {
|
|
216
|
+
if (this.getAttribute('role') === 'document') {
|
|
217
|
+
return {
|
|
218
|
+
x: 0,
|
|
219
|
+
y: 0,
|
|
220
|
+
top: 0,
|
|
221
|
+
left: 0,
|
|
222
|
+
right: 431,
|
|
223
|
+
bottom: 200,
|
|
224
|
+
width: 431,
|
|
225
|
+
height: 200,
|
|
226
|
+
toJSON: () => {},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
x: 0,
|
|
232
|
+
y: 0,
|
|
233
|
+
top: 0,
|
|
234
|
+
left: 0,
|
|
235
|
+
right: 400,
|
|
236
|
+
bottom: 48,
|
|
237
|
+
width: 400,
|
|
238
|
+
height: 48,
|
|
239
|
+
toJSON: () => {},
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const result = render(
|
|
244
|
+
<ThemeContextProvider>
|
|
245
|
+
<dialog open>
|
|
246
|
+
<div role='document'>
|
|
247
|
+
<Select
|
|
248
|
+
options={[
|
|
249
|
+
{
|
|
250
|
+
label:
|
|
251
|
+
'A very long option label that should be truncated in the dialog',
|
|
252
|
+
value: 'long',
|
|
253
|
+
},
|
|
254
|
+
]}
|
|
255
|
+
value=''
|
|
256
|
+
onValueChange={() => {}}
|
|
257
|
+
/>
|
|
258
|
+
</div>
|
|
259
|
+
</dialog>
|
|
260
|
+
</ThemeContextProvider>
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
await user.click(result.getByRole('combobox'));
|
|
264
|
+
|
|
265
|
+
await waitFor(() => {
|
|
266
|
+
expect(result.getByRole('listbox')).toHaveStyle({
|
|
267
|
+
maxWidth: 'min(431px, calc(100vw - (32px * 2)))',
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
getBoundingClientRectSpy.mockRestore();
|
|
272
|
+
});
|
|
273
|
+
|
|
94
274
|
test('Cannot be used when disabled', async () => {
|
|
95
275
|
const user = userEvent.setup();
|
|
96
276
|
const renderResult = render(
|
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
useState,
|
|
3
|
+
useRef,
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useId,
|
|
8
|
+
} from 'react';
|
|
2
9
|
import { css, cx } from '@emotion/css';
|
|
3
10
|
import { VisibleField, Panel, CustomOption, FilterInput } from '.';
|
|
4
11
|
import { useTheme } from '../../../theme';
|
|
12
|
+
import Overlay from '../../Overlay';
|
|
13
|
+
import type { OverlaySize } from '../../Overlay';
|
|
5
14
|
import type { InternalSelectProps } from '../Select.types';
|
|
6
15
|
|
|
7
16
|
const NAME = 'ucl-uikit-select';
|
|
@@ -32,6 +41,7 @@ const CustomSelect = <T extends string | number>({
|
|
|
32
41
|
clearable = false,
|
|
33
42
|
placeholder,
|
|
34
43
|
lineBreak = false,
|
|
44
|
+
dropdownWidth = 'content',
|
|
35
45
|
filterInputProps,
|
|
36
46
|
width,
|
|
37
47
|
testId = NAME,
|
|
@@ -107,11 +117,17 @@ const CustomSelect = <T extends string | number>({
|
|
|
107
117
|
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number | null>(
|
|
108
118
|
null
|
|
109
119
|
);
|
|
120
|
+
const [panelMaxWidth, setPanelMaxWidth] = useState<number | null>(null);
|
|
121
|
+
const [panelReferenceWidth, setPanelReferenceWidth] = useState<number | null>(
|
|
122
|
+
null
|
|
123
|
+
);
|
|
124
|
+
const [overlaySize, setOverlaySize] = useState<OverlaySize | null>(null);
|
|
110
125
|
const filterInputRef = useRef<HTMLInputElement | null>(null);
|
|
111
126
|
const clearButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
112
127
|
const reactId = useId();
|
|
113
128
|
const idBase = props.id ?? `${testId}-${reactId.replace(/[:]/g, '')}`;
|
|
114
129
|
const listboxId = `${idBase}-listbox`;
|
|
130
|
+
const overlayViewportPadding = parseFloat(String(theme.margin.m32));
|
|
115
131
|
|
|
116
132
|
// Returns a list of indexes of options that are currently visible based on the filter text
|
|
117
133
|
const visibleOptionIndexes = useMemo(() => {
|
|
@@ -259,6 +275,35 @@ const CustomSelect = <T extends string | number>({
|
|
|
259
275
|
}
|
|
260
276
|
}, [filterable, isOpen]);
|
|
261
277
|
|
|
278
|
+
useLayoutEffect(() => {
|
|
279
|
+
if (!isOpen) {
|
|
280
|
+
setPanelMaxWidth(null);
|
|
281
|
+
setPanelReferenceWidth(null);
|
|
282
|
+
setOverlaySize(null);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const updatePanelMaxWidth = () => {
|
|
287
|
+
const referenceElement = effectiveRef.current;
|
|
288
|
+
const dialogElement = referenceElement?.closest('dialog');
|
|
289
|
+
const dialogBodyElement =
|
|
290
|
+
dialogElement?.querySelector<HTMLElement>('[role="document"]');
|
|
291
|
+
const dialogBodyRect = dialogBodyElement?.getBoundingClientRect();
|
|
292
|
+
const referenceRect = referenceElement?.getBoundingClientRect();
|
|
293
|
+
|
|
294
|
+
setPanelReferenceWidth(referenceRect?.width ?? null);
|
|
295
|
+
setPanelMaxWidth(
|
|
296
|
+
dialogBodyRect && referenceRect
|
|
297
|
+
? Math.max(0, dialogBodyRect.right - referenceRect.left)
|
|
298
|
+
: null
|
|
299
|
+
);
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
updatePanelMaxWidth();
|
|
303
|
+
window.addEventListener('resize', updatePanelMaxWidth);
|
|
304
|
+
return () => window.removeEventListener('resize', updatePanelMaxWidth);
|
|
305
|
+
}, [effectiveRef, isOpen]);
|
|
306
|
+
|
|
262
307
|
const togglePanel = () => {
|
|
263
308
|
if (!disabled) setIsOpen((prev) => !prev);
|
|
264
309
|
};
|
|
@@ -274,6 +319,21 @@ const CustomSelect = <T extends string | number>({
|
|
|
274
319
|
}
|
|
275
320
|
};
|
|
276
321
|
|
|
322
|
+
const handleOverlaySizeChange = (nextSize: OverlaySize) => {
|
|
323
|
+
setOverlaySize((prevSize) => {
|
|
324
|
+
if (
|
|
325
|
+
prevSize &&
|
|
326
|
+
prevSize.referenceWidth === nextSize.referenceWidth &&
|
|
327
|
+
prevSize.availableWidth === nextSize.availableWidth &&
|
|
328
|
+
prevSize.availableHeight === nextSize.availableHeight
|
|
329
|
+
) {
|
|
330
|
+
return prevSize;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return nextSize;
|
|
334
|
+
});
|
|
335
|
+
};
|
|
336
|
+
|
|
277
337
|
const handleClick = (event: React.MouseEvent) => {
|
|
278
338
|
if (disabled) return;
|
|
279
339
|
if (openedViaFocusRef.current) {
|
|
@@ -567,33 +627,55 @@ const CustomSelect = <T extends string | number>({
|
|
|
567
627
|
)}
|
|
568
628
|
</VisibleField>
|
|
569
629
|
{isOpen && (
|
|
570
|
-
<
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
630
|
+
<Overlay
|
|
631
|
+
reference={effectiveRef}
|
|
632
|
+
placement='bottom-start'
|
|
633
|
+
flip={{ padding: overlayViewportPadding }}
|
|
634
|
+
shift={{
|
|
635
|
+
padding: overlayViewportPadding,
|
|
636
|
+
mainAxis: false,
|
|
637
|
+
crossAxis: true,
|
|
638
|
+
}}
|
|
639
|
+
size={{
|
|
640
|
+
matchReferenceWidth: true,
|
|
641
|
+
padding: overlayViewportPadding,
|
|
642
|
+
}}
|
|
643
|
+
onSizeChange={handleOverlaySizeChange}
|
|
574
644
|
>
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
645
|
+
<Panel
|
|
646
|
+
className={panelClassName}
|
|
647
|
+
dropdownWidth={dropdownWidth}
|
|
648
|
+
referenceWidth={
|
|
649
|
+
overlaySize?.referenceWidth ?? panelReferenceWidth ?? undefined
|
|
650
|
+
}
|
|
651
|
+
availableHeight={overlaySize?.availableHeight}
|
|
652
|
+
maxWidth={panelMaxWidth ?? undefined}
|
|
653
|
+
id={listboxId}
|
|
654
|
+
role='listbox'
|
|
655
|
+
>
|
|
656
|
+
{visibleOptions.map((option, index) => (
|
|
657
|
+
<CustomOption<T>
|
|
658
|
+
key={`${String(option.value)}-${visibleOptionIndexes[index]}`}
|
|
659
|
+
id={`${idBase}-option-${visibleOptionIndexes[index]}`}
|
|
660
|
+
value={option.value}
|
|
661
|
+
optionIndex={visibleOptionIndexes[index]}
|
|
662
|
+
isSelected={highlightedVisibleIndex === index}
|
|
663
|
+
onSelect={handleSelect}
|
|
664
|
+
lineBreak={lineBreak}
|
|
665
|
+
role='option'
|
|
666
|
+
aria-selected={highlightedVisibleIndex === index}
|
|
667
|
+
aria-posinset={index + 1}
|
|
668
|
+
aria-setsize={visibleOptions.length}
|
|
669
|
+
{...option.optionProps}
|
|
670
|
+
>
|
|
671
|
+
{option.label}
|
|
672
|
+
</CustomOption>
|
|
673
|
+
))}
|
|
674
|
+
{visibleOptions.length === 0 && (
|
|
675
|
+
<div className={noOptionsStyle}>No options</div>
|
|
676
|
+
)}
|
|
677
|
+
</Panel>
|
|
678
|
+
</Overlay>
|
|
597
679
|
)}
|
|
598
680
|
</div>
|
|
599
681
|
);
|