uikit-react-public 0.29.3 → 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.
@@ -88,7 +88,12 @@ const Label = forwardRef<Ref, LabelProps>(
88
88
  {...props}
89
89
  >
90
90
  {children}
91
- {optional && <span className={optionalStyle}> (optional)</span>}
91
+ {optional && (
92
+ <>
93
+ {' '}
94
+ <span className={optionalStyle}>(optional)</span>
95
+ </>
96
+ )}
92
97
  </label>
93
98
  );
94
99
  }
@@ -67,10 +67,11 @@ exports[`Label > snapshot: with optional prop 1`] = `
67
67
  for=""
68
68
  >
69
69
  Name
70
+
70
71
  <span
71
72
  class="css-1fd72bb"
72
73
  >
73
- (optional)
74
+ (optional)
74
75
  </span>
75
76
  </label>
76
77
  `;
@@ -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
- export interface OverlayProps
30
- extends HTMLAttributes<HTMLDivElement> {
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
- onBlanketClick?: (
40
- ev: React.MouseEvent<HTMLDivElement>
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) fuiMiddleware.push(fuiShift());
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
- )[0] as 'left' | 'right' | 'top' | 'bottom';
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={refs.setFloating}
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 { createEvent, fireEvent, render } from '@testing-library/react';
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(