@pushwoosh/dumb-components 1.1.144 → 1.1.146
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/Calendar/CalendarDropdown/CalendarDropdown.js +124 -105
- package/Calendar/CalendarDropdown/styles.d.ts +1 -0
- package/Calendar/CalendarDropdown/styles.js +4 -2
- package/Calendar/CalendarSuperForm/components/CalendarTimezoneSelect.js +14 -21
- package/Combobox/Combobox.d.ts +21 -0
- package/Combobox/types.d.ts +42 -0
- package/ComboboxMulti/ComboboxMulti.d.ts +13 -0
- package/ComboboxMulti/ComboboxMulti.js +13 -0
- package/ComboboxMulti/types.d.ts +26 -0
- package/hooks/__tests__/useLayer.test.d.ts +1 -0
- package/hooks/__tests__/useLayer.test.js +71 -0
- package/hooks/useLayer.js +25 -5
- package/package.json +1 -1
- package/shared/dropdownField/SuggestionsDropdown.js +2 -0
- package/shared/dropdownField/types.d.ts +29 -0
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { autoUpdate, flip, offset, shift, useFloating } from '@floating-ui/react-dom';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
3
5
|
import { Color } from '@pushwoosh/kit-constants';
|
|
4
|
-
import { Horizontal,
|
|
6
|
+
import { Horizontal, Vertical } from '@pushwoosh/kit-helpers';
|
|
5
7
|
import { CalendarIcon } from '@pushwoosh/kit-icons';
|
|
6
8
|
import { stringifyValue } from './helpers';
|
|
7
9
|
import { PopoverContent, PresetsContainer } from './styles';
|
|
8
10
|
import { Button } from '../../Button';
|
|
9
11
|
import { Chip } from '../../Chip';
|
|
10
|
-
import {
|
|
12
|
+
import { useLayer, useOnClickOutside } from '../../hooks';
|
|
11
13
|
import { CalendarSuperForm } from '../CalendarSuperForm';
|
|
12
14
|
import { Lang, useLang } from '../polyglot';
|
|
13
15
|
export const CalendarDropdown = props => {
|
|
@@ -22,8 +24,6 @@ export const CalendarDropdown = props => {
|
|
|
22
24
|
} = props;
|
|
23
25
|
const isRangePicker = mode === 'range-date' || mode === 'range-datetime';
|
|
24
26
|
const withTime = mode === 'single-datetime' || mode === 'range-datetime';
|
|
25
|
-
const buttonRef = useRef(null);
|
|
26
|
-
const popoverRef = useRef(null);
|
|
27
27
|
const [isShaking, setIsShaking] = useState(false);
|
|
28
28
|
const [isOpen, setIsOpen] = useState(false);
|
|
29
29
|
const lang = useLang();
|
|
@@ -41,10 +41,31 @@ export const CalendarDropdown = props => {
|
|
|
41
41
|
setValue(props.value ?? (isRangePicker ? [] : undefined));
|
|
42
42
|
setTimezone(initialTimezone);
|
|
43
43
|
};
|
|
44
|
-
useClickAway(isOpen, popoverRef, closePopover);
|
|
45
44
|
const openPopover = () => {
|
|
46
45
|
setIsOpen(true);
|
|
47
46
|
};
|
|
47
|
+
const {
|
|
48
|
+
refs,
|
|
49
|
+
floatingStyles
|
|
50
|
+
} = useFloating({
|
|
51
|
+
open: isOpen,
|
|
52
|
+
strategy: 'fixed',
|
|
53
|
+
placement: 'bottom-start',
|
|
54
|
+
transform: false,
|
|
55
|
+
middleware: [offset(4), flip(), shift({
|
|
56
|
+
padding: 8
|
|
57
|
+
})],
|
|
58
|
+
whileElementsMounted: autoUpdate
|
|
59
|
+
});
|
|
60
|
+
const {
|
|
61
|
+
zIndex
|
|
62
|
+
} = useLayer({
|
|
63
|
+
isOpen,
|
|
64
|
+
onClose: closePopover
|
|
65
|
+
});
|
|
66
|
+
useOnClickOutside([refs.reference, refs.floating], () => {
|
|
67
|
+
if (isOpen) closePopover();
|
|
68
|
+
});
|
|
48
69
|
const onChangeHandler = val => {
|
|
49
70
|
var _props$onChange;
|
|
50
71
|
setValue(val);
|
|
@@ -65,7 +86,7 @@ export const CalendarDropdown = props => {
|
|
|
65
86
|
return _jsxs(Horizontal, {
|
|
66
87
|
"$justifyContent": "start",
|
|
67
88
|
children: [_jsx(Button, {
|
|
68
|
-
boxRef:
|
|
89
|
+
boxRef: refs.setReference,
|
|
69
90
|
type: "button",
|
|
70
91
|
view: buttonView,
|
|
71
92
|
size: buttonSize,
|
|
@@ -77,108 +98,106 @@ export const CalendarDropdown = props => {
|
|
|
77
98
|
onClick: () => isOpen ? closePopover() : openPopover(),
|
|
78
99
|
isOpen: isOpen,
|
|
79
100
|
children: buttonTextCustom ?? buttonText
|
|
80
|
-
}), _jsx(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
children:
|
|
91
|
-
children:
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
}), isOpen && createPortal(_jsx(PopoverContent, {
|
|
102
|
+
ref: refs.setFloating,
|
|
103
|
+
style: {
|
|
104
|
+
...floatingStyles,
|
|
105
|
+
zIndex
|
|
106
|
+
},
|
|
107
|
+
"$isShaking": isShaking,
|
|
108
|
+
"$width": isRangePicker ? 500 : 260,
|
|
109
|
+
onAnimationEnd: () => setIsShaking(false),
|
|
110
|
+
children: _jsxs(Vertical, {
|
|
111
|
+
children: [presets && presets.length > 0 && _jsx(PresetsContainer, {
|
|
112
|
+
children: presets.map((preset, i) => _jsx(Chip, {
|
|
113
|
+
onClick: () => onChangeHandler(preset.value),
|
|
114
|
+
children: preset.label
|
|
115
|
+
}, i))
|
|
116
|
+
}), _jsxs(Horizontal, {
|
|
117
|
+
"$padding": 16,
|
|
118
|
+
"$border": {
|
|
119
|
+
bottom: `1px solid ${Color.DIVIDER}`
|
|
120
|
+
},
|
|
121
|
+
children: [mode === 'single-date' && _jsx(CalendarSuperForm, {
|
|
122
|
+
mode: "single-date",
|
|
123
|
+
...calendarProps,
|
|
124
|
+
value: value,
|
|
125
|
+
onChange: onChangeHandler
|
|
126
|
+
}), mode === 'single-datetime' && _jsx(CalendarSuperForm, {
|
|
127
|
+
mode: "single-datetime",
|
|
128
|
+
...calendarProps,
|
|
129
|
+
value: value,
|
|
130
|
+
onChange: onChangeHandler,
|
|
131
|
+
timezone: timezone,
|
|
132
|
+
onChangeTimezone: val => {
|
|
133
|
+
setTimezone(val);
|
|
134
|
+
if ('onChangeTimezone' in calendarProps) {
|
|
135
|
+
calendarProps.onChangeTimezone(val);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}), mode === 'range-date' && _jsx(CalendarSuperForm, {
|
|
139
|
+
mode: "range-date",
|
|
140
|
+
...calendarProps,
|
|
141
|
+
value: value,
|
|
142
|
+
onChange: onChangeHandler
|
|
143
|
+
}), mode === 'range-datetime' && _jsx(CalendarSuperForm, {
|
|
144
|
+
mode: "range-datetime",
|
|
145
|
+
...calendarProps,
|
|
146
|
+
value: value,
|
|
147
|
+
onChange: onChangeHandler,
|
|
148
|
+
timezone: timezone,
|
|
149
|
+
onChangeTimezone: val => {
|
|
150
|
+
setTimezone(val);
|
|
151
|
+
if ('onChangeTimezone' in calendarProps) {
|
|
152
|
+
calendarProps.onChangeTimezone(val);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
})]
|
|
156
|
+
}), _jsxs(Horizontal, {
|
|
157
|
+
"$padding": 16,
|
|
158
|
+
"$justifyContent": "end",
|
|
159
|
+
"$gap": 8,
|
|
160
|
+
children: [_jsx(Button, {
|
|
161
|
+
color: "secondary",
|
|
162
|
+
size: "compact",
|
|
163
|
+
onClick: () => {
|
|
164
|
+
if (noValue) {
|
|
165
|
+
closePopover();
|
|
166
|
+
} else {
|
|
167
|
+
setValue(isRangePicker ? [] : undefined);
|
|
168
|
+
setTimezone(initialTimezone);
|
|
169
|
+
}
|
|
100
170
|
},
|
|
101
|
-
children:
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
171
|
+
children: noValue ? _jsx(Lang, {
|
|
172
|
+
message: "DUMB.CALENDAR.CANCEL"
|
|
173
|
+
}) : _jsx(Lang, {
|
|
174
|
+
message: "DUMB.CALENDAR.RESET"
|
|
175
|
+
})
|
|
176
|
+
}), _jsx(Button, {
|
|
177
|
+
color: "primary",
|
|
178
|
+
size: "compact",
|
|
179
|
+
onClick: () => {
|
|
180
|
+
let isInvalid;
|
|
181
|
+
if (isRangePicker) {
|
|
182
|
+
const range = value;
|
|
183
|
+
const length = (range === null || range === void 0 ? void 0 : range.length) ?? 0;
|
|
184
|
+
isInvalid = allowEmpty ? length === 1 : length !== 2;
|
|
185
|
+
} else {
|
|
186
|
+
isInvalid = allowEmpty ? false : !value;
|
|
117
187
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}), mode === 'range-datetime' && _jsx(CalendarSuperForm, {
|
|
124
|
-
mode: "range-datetime",
|
|
125
|
-
...calendarProps,
|
|
126
|
-
value: value,
|
|
127
|
-
onChange: onChangeHandler,
|
|
128
|
-
timezone: timezone,
|
|
129
|
-
onChangeTimezone: val => {
|
|
130
|
-
setTimezone(val);
|
|
131
|
-
if ('onChangeTimezone' in calendarProps) {
|
|
132
|
-
calendarProps.onChangeTimezone(val);
|
|
133
|
-
}
|
|
188
|
+
if (isInvalid) {
|
|
189
|
+
setIsShaking(true);
|
|
190
|
+
} else {
|
|
191
|
+
props.onSubmit(value, timezone);
|
|
192
|
+
closePopover();
|
|
134
193
|
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
"$gap": 8,
|
|
140
|
-
children: [_jsx(Button, {
|
|
141
|
-
color: "secondary",
|
|
142
|
-
size: "compact",
|
|
143
|
-
onClick: () => {
|
|
144
|
-
if (noValue) {
|
|
145
|
-
closePopover();
|
|
146
|
-
} else {
|
|
147
|
-
setValue(isRangePicker ? [] : undefined);
|
|
148
|
-
setTimezone(initialTimezone);
|
|
149
|
-
}
|
|
150
|
-
},
|
|
151
|
-
children: noValue ? _jsx(Lang, {
|
|
152
|
-
message: "DUMB.CALENDAR.CANCEL"
|
|
153
|
-
}) : _jsx(Lang, {
|
|
154
|
-
message: "DUMB.CALENDAR.RESET"
|
|
155
|
-
})
|
|
156
|
-
}), _jsx(Button, {
|
|
157
|
-
color: "primary",
|
|
158
|
-
size: "compact",
|
|
159
|
-
onClick: () => {
|
|
160
|
-
let isInvalid;
|
|
161
|
-
if (isRangePicker) {
|
|
162
|
-
const range = value;
|
|
163
|
-
const length = (range === null || range === void 0 ? void 0 : range.length) ?? 0;
|
|
164
|
-
isInvalid = allowEmpty ? length === 1 : length !== 2;
|
|
165
|
-
} else {
|
|
166
|
-
isInvalid = allowEmpty ? false : !value;
|
|
167
|
-
}
|
|
168
|
-
if (isInvalid) {
|
|
169
|
-
setIsShaking(true);
|
|
170
|
-
} else {
|
|
171
|
-
props.onSubmit(value, timezone);
|
|
172
|
-
closePopover();
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
children: _jsx(Lang, {
|
|
176
|
-
message: "DUMB.CALENDAR.APPLY"
|
|
177
|
-
})
|
|
178
|
-
})]
|
|
194
|
+
},
|
|
195
|
+
children: _jsx(Lang, {
|
|
196
|
+
message: "DUMB.CALENDAR.APPLY"
|
|
197
|
+
})
|
|
179
198
|
})]
|
|
180
|
-
})
|
|
199
|
+
})]
|
|
181
200
|
})
|
|
182
|
-
})]
|
|
201
|
+
}), document.body)]
|
|
183
202
|
});
|
|
184
203
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import styled, { keyframes, css } from 'styled-components';
|
|
2
|
-
import { Shadow, Color } from '@pushwoosh/kit-constants';
|
|
2
|
+
import { Shadow, Color, ShapeRadius } from '@pushwoosh/kit-constants';
|
|
3
3
|
const shakeAnimation = keyframes(["0%,100%{transform:translateX(0);}10%,30%,50%,70%,90%{transform:translateX(-4px);}20%,40%,60%,80%{transform:translateX(4px);}"]);
|
|
4
4
|
export const PopoverContent = styled.div.withConfig({
|
|
5
5
|
displayName: "PopoverContent",
|
|
6
6
|
componentId: "sc-h6jz87-0"
|
|
7
|
-
})(["overflow:hidden;box-shadow:", ";background:", ";", ""],
|
|
7
|
+
})(["width:", "px;overflow:hidden;border:1px solid ", ";border-radius:", ";box-shadow:", ";background:", ";", ""], ({
|
|
8
|
+
$width
|
|
9
|
+
}) => $width, Color.FORM, ShapeRadius.DIALOG, Shadow.REGULAR, Color.CLEAR, ({
|
|
8
10
|
$isShaking
|
|
9
11
|
}) => $isShaking && css(["animation:", " 0.4s ease-in-out;"], shakeAnimation));
|
|
10
12
|
export const PresetsContainer = styled.div.withConfig({
|
|
@@ -1,31 +1,24 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
|
-
import {
|
|
3
|
+
import { Combobox } from '../../../Combobox';
|
|
4
4
|
import { getSortedTimezones } from '../../helpers-tz';
|
|
5
5
|
export const CalendarTimezoneSelect = ({
|
|
6
6
|
timezone,
|
|
7
7
|
onChange,
|
|
8
8
|
isDisabled
|
|
9
9
|
}) => {
|
|
10
|
-
const timezonesOptions = useMemo(() =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
zIndex: 10020
|
|
24
|
-
}),
|
|
25
|
-
menu: provided => ({
|
|
26
|
-
...provided,
|
|
27
|
-
width: '300px'
|
|
28
|
-
})
|
|
29
|
-
}
|
|
10
|
+
const timezonesOptions = useMemo(() => getSortedTimezones(), []);
|
|
11
|
+
const value = useMemo(() => timezonesOptions.find(op => op.value === timezone) ?? null, [timezonesOptions, timezone]);
|
|
12
|
+
return _jsx(Combobox, {
|
|
13
|
+
select: true,
|
|
14
|
+
items: timezonesOptions,
|
|
15
|
+
value: value,
|
|
16
|
+
onChange: option => {
|
|
17
|
+
if (option) onChange(option.value);
|
|
18
|
+
},
|
|
19
|
+
getLabel: option => option.label,
|
|
20
|
+
getKey: option => option.value,
|
|
21
|
+
filter: (option, query) => option.label.toLowerCase().includes(query.toLowerCase()),
|
|
22
|
+
disabled: isDisabled
|
|
30
23
|
});
|
|
31
24
|
};
|
package/Combobox/Combobox.d.ts
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
1
|
import { type ReactElement } from 'react';
|
|
2
2
|
import { type FreeComboboxProps, type SelectComboboxProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Free-text combobox: an input with optional sync or async suggestions. `value`
|
|
5
|
+
* is the string; `onChange` fires per keystroke and on pick (see `meta.source`).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <Combobox value={text} onChange={setText} items={fruits} placeholder="Fruit" clearable />
|
|
9
|
+
*/
|
|
3
10
|
export declare function Combobox(props: FreeComboboxProps): ReactElement;
|
|
11
|
+
/**
|
|
12
|
+
* Strict single-select combobox: pick one of `items` (or clear to `null`).
|
|
13
|
+
* Pass `select` to opt in; object items also need `getKey`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <Combobox<User>
|
|
17
|
+
* select
|
|
18
|
+
* items={users}
|
|
19
|
+
* value={user}
|
|
20
|
+
* onChange={setUser}
|
|
21
|
+
* getLabel={(u) => u.name}
|
|
22
|
+
* getKey={(u) => u.id}
|
|
23
|
+
* />
|
|
24
|
+
*/
|
|
4
25
|
export declare function Combobox<T>(props: SelectComboboxProps<T>): ReactElement;
|
package/Combobox/types.d.ts
CHANGED
|
@@ -1,47 +1,89 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { DropdownText, GetKeyProp, ItemState, LoadItems } from '../shared/dropdownField';
|
|
3
3
|
type ComboboxSource<T> = {
|
|
4
|
+
/** Static list of suggestions. */
|
|
4
5
|
items?: T[];
|
|
5
6
|
loadItems?: never;
|
|
6
7
|
debounceMs?: never;
|
|
7
8
|
minChars?: never;
|
|
8
9
|
} | {
|
|
9
10
|
items?: never;
|
|
11
|
+
/** Async loader; receives the query and a zero-based page index, returns one page. */
|
|
10
12
|
loadItems: LoadItems<T>;
|
|
13
|
+
/** Debounce (ms) before calling `loadItems` after the query changes. */
|
|
11
14
|
debounceMs?: number;
|
|
15
|
+
/** Don't call `loadItems` until the query has at least this many characters. */
|
|
12
16
|
minChars?: number;
|
|
13
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Origin of a free-mode `onChange`:
|
|
20
|
+
* - `'input'` — the user typed (or cleared the field);
|
|
21
|
+
* - `'select'` — the user picked a suggestion from the dropdown.
|
|
22
|
+
*/
|
|
14
23
|
export type ComboboxChangeSource = 'input' | 'select';
|
|
24
|
+
/** Second argument of free-mode `onChange`, describing what caused the change. */
|
|
15
25
|
export type ComboboxChangeMeta = {
|
|
16
26
|
source: ComboboxChangeSource;
|
|
17
27
|
};
|
|
18
28
|
type CommonProps<T> = {
|
|
29
|
+
/** Predicate for filtering `items` by the query (default: case-insensitive label match). Sync items only. */
|
|
19
30
|
filter?: (item: T, query: string) => boolean;
|
|
31
|
+
/** Render a custom option row; receives the item and its {@link ItemState}. */
|
|
20
32
|
renderItem?: (item: T, state: ItemState) => ReactNode;
|
|
33
|
+
/** Content shown when no options match. A node, or `(query) => node`. */
|
|
21
34
|
emptyText?: DropdownText;
|
|
35
|
+
/** Content shown while async items load. A node, or `(query) => node`. Defaults to "Loading…". */
|
|
22
36
|
loadingText?: DropdownText;
|
|
37
|
+
/** Called whenever the dropdown closes for any reason (Escape, click-outside, blur, selection). */
|
|
23
38
|
onClose?: () => void;
|
|
39
|
+
/** Focus the input and open the dropdown on mount. */
|
|
24
40
|
autoFocus?: boolean;
|
|
41
|
+
/** Placeholder shown when the field is empty. */
|
|
25
42
|
placeholder?: string;
|
|
43
|
+
/** Disable the field. */
|
|
26
44
|
disabled?: boolean;
|
|
45
|
+
/** Force the loading state (on top of async loading). */
|
|
27
46
|
loading?: boolean;
|
|
47
|
+
/** Show a clear (✕) button when there is a value. */
|
|
28
48
|
clearable?: boolean;
|
|
49
|
+
/** Size the field to its content instead of filling the parent. */
|
|
29
50
|
autosize?: boolean;
|
|
51
|
+
/** Render the error (red) state. */
|
|
30
52
|
hasError?: boolean;
|
|
53
|
+
/** Fixed CSS width (e.g. `"240px"`); overrides the default parent-driven / autosize width. */
|
|
31
54
|
width?: string;
|
|
55
|
+
/** Minimum CSS width, useful together with `autosize`. */
|
|
32
56
|
minWidth?: string;
|
|
33
57
|
};
|
|
58
|
+
/**
|
|
59
|
+
* Free-text Combobox: the value is the typed string and `items`/`loadItems`
|
|
60
|
+
* are optional suggestions. `onChange` fires on every keystroke and on picking
|
|
61
|
+
* a suggestion — tell them apart via `meta.source`.
|
|
62
|
+
*/
|
|
34
63
|
export type FreeComboboxProps = ComboboxSource<string> & CommonProps<string> & {
|
|
64
|
+
/** Free-text mode (the default). */
|
|
35
65
|
select?: false;
|
|
66
|
+
/** Current text value (controlled). */
|
|
36
67
|
value: string;
|
|
68
|
+
/** Fires on every change; `meta.source` is `'input'` (typed/cleared) or `'select'` (picked a suggestion). */
|
|
37
69
|
onChange: (value: string, meta: ComboboxChangeMeta) => void;
|
|
70
|
+
/** Map a suggestion to the text inserted on pick (default: identity). */
|
|
38
71
|
getLabel?: (item: string) => string;
|
|
39
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* Strict single-select Combobox: the value is one of `items` (or `null`).
|
|
75
|
+
* Typing only filters — arbitrary text cannot be committed.
|
|
76
|
+
*/
|
|
40
77
|
export type SelectComboboxProps<T> = ComboboxSource<T> & GetKeyProp<T> & CommonProps<T> & {
|
|
78
|
+
/** Strict single-select mode. */
|
|
41
79
|
select: true;
|
|
80
|
+
/** Currently selected item, or `null`. */
|
|
42
81
|
value: T | null;
|
|
82
|
+
/** Fires when the selection changes (an item, or `null` when cleared). */
|
|
43
83
|
onChange: (value: T | null) => void;
|
|
84
|
+
/** Map an item to its display label (required). */
|
|
44
85
|
getLabel: (item: T) => string;
|
|
45
86
|
};
|
|
87
|
+
/** Props of {@link Combobox} — free-text by default, strict single-select with `select`. */
|
|
46
88
|
export type ComboboxProps<T> = FreeComboboxProps | SelectComboboxProps<T>;
|
|
47
89
|
export {};
|
|
@@ -1,3 +1,16 @@
|
|
|
1
1
|
import { type ReactElement } from 'react';
|
|
2
2
|
import { type ComboboxMultiProps } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Multi-select combobox: pick several items shown as removable chips, with
|
|
5
|
+
* optional creation of new values. Sync `items` or async `loadItems`.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* <ComboboxMulti<string>
|
|
9
|
+
* items={fruits}
|
|
10
|
+
* value={selected}
|
|
11
|
+
* onChange={setSelected}
|
|
12
|
+
* getLabel={(s) => s}
|
|
13
|
+
* creatable
|
|
14
|
+
* />
|
|
15
|
+
*/
|
|
3
16
|
export declare function ComboboxMulti<T>({ items, loadItems, debounceMs, minChars, value, onChange, onClose, autoFocus, getLabel, getKey: getKeyProp, filter, renderItem, emptyText, loadingText, creatable, onCreate, canCreate, renderCreateLabel, maxValues, placeholder, disabled, loading, clearable, autosize, hasError, width, minWidth, }: ComboboxMultiProps<T>): ReactElement;
|
|
@@ -8,6 +8,19 @@ function defaultCanCreate(query, items, getLabel) {
|
|
|
8
8
|
const lower = trimmed.toLowerCase();
|
|
9
9
|
return !items.some(item => getLabel(item).toLowerCase() === lower);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Multi-select combobox: pick several items shown as removable chips, with
|
|
13
|
+
* optional creation of new values. Sync `items` or async `loadItems`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* <ComboboxMulti<string>
|
|
17
|
+
* items={fruits}
|
|
18
|
+
* value={selected}
|
|
19
|
+
* onChange={setSelected}
|
|
20
|
+
* getLabel={(s) => s}
|
|
21
|
+
* creatable
|
|
22
|
+
* />
|
|
23
|
+
*/
|
|
11
24
|
export function ComboboxMulti({
|
|
12
25
|
items,
|
|
13
26
|
loadItems,
|
package/ComboboxMulti/types.d.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { DropdownText, GetKeyProp, ItemState, ItemsSource } from '../shared/dropdownField';
|
|
3
|
+
/**
|
|
4
|
+
* Whether new values can be created from the typed query. For `string` items
|
|
5
|
+
* `onCreate` is optional (the trimmed query is used); for object items, enabling
|
|
6
|
+
* `creatable` requires `onCreate` to build the item.
|
|
7
|
+
*/
|
|
3
8
|
type CreatableProp<T> = [T] extends [string] ? {
|
|
4
9
|
creatable?: boolean;
|
|
5
10
|
onCreate?: (query: string) => T;
|
|
@@ -10,26 +15,47 @@ type CreatableProp<T> = [T] extends [string] ? {
|
|
|
10
15
|
creatable: true;
|
|
11
16
|
onCreate: (query: string) => T;
|
|
12
17
|
};
|
|
18
|
+
/** Props of {@link ComboboxMulti} — multi-select with chips and optional create. */
|
|
13
19
|
export type ComboboxMultiProps<T> = ItemsSource<T> & GetKeyProp<T> & CreatableProp<T> & {
|
|
20
|
+
/** Selected items (controlled), rendered as removable chips. */
|
|
14
21
|
value: T[];
|
|
22
|
+
/** Fires with the new selection whenever an item is added or removed. */
|
|
15
23
|
onChange: (value: T[]) => void;
|
|
24
|
+
/** Called whenever the dropdown closes for any reason (Escape, click-outside, blur). */
|
|
16
25
|
onClose?: () => void;
|
|
26
|
+
/** Focus the input and open the dropdown on mount. */
|
|
17
27
|
autoFocus?: boolean;
|
|
28
|
+
/** Map an item to its chip/label text (required). */
|
|
18
29
|
getLabel: (item: T) => string;
|
|
30
|
+
/** Predicate for filtering `items` by the query (default: case-insensitive label match). Sync items only. */
|
|
19
31
|
filter?: (item: T, query: string) => boolean;
|
|
32
|
+
/** Render a custom option row; receives the item and its {@link ItemState}. */
|
|
20
33
|
renderItem?: (item: T, state: ItemState) => ReactNode;
|
|
34
|
+
/** Content shown when no options match. A node, or `(query) => node`. */
|
|
21
35
|
emptyText?: DropdownText;
|
|
36
|
+
/** Content shown while async items load. A node, or `(query) => node`. Defaults to "Loading…". */
|
|
22
37
|
loadingText?: DropdownText;
|
|
38
|
+
/** Override whether the current query can be created (default: non-empty and not a duplicate label). */
|
|
23
39
|
canCreate?: (query: string, items: T[]) => boolean;
|
|
40
|
+
/** Render the label of the "create" row (default: `Create "query"`). */
|
|
24
41
|
renderCreateLabel?: (query: string) => ReactNode;
|
|
42
|
+
/** Maximum number of selected items. */
|
|
25
43
|
maxValues?: number;
|
|
44
|
+
/** Placeholder shown when there are no chips. */
|
|
26
45
|
placeholder?: string;
|
|
46
|
+
/** Disable the field. */
|
|
27
47
|
disabled?: boolean;
|
|
48
|
+
/** Force the loading state (on top of async loading). */
|
|
28
49
|
loading?: boolean;
|
|
50
|
+
/** Show a "clear all" (✕) button when there are chips. */
|
|
29
51
|
clearable?: boolean;
|
|
52
|
+
/** Size the field to its content instead of filling the parent. */
|
|
30
53
|
autosize?: boolean;
|
|
54
|
+
/** Render the error (red) state. */
|
|
31
55
|
hasError?: boolean;
|
|
56
|
+
/** Fixed CSS width (e.g. `"240px"`); overrides the default parent-driven / autosize width. */
|
|
32
57
|
width?: string;
|
|
58
|
+
/** Minimum CSS width, useful together with `autosize`. */
|
|
33
59
|
minWidth?: string;
|
|
34
60
|
};
|
|
35
61
|
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { createElement, Fragment } from 'react';
|
|
3
|
+
import { useLayer } from '../useLayer';
|
|
4
|
+
const GLOBAL_KEY = '__pushwoosh_dumb_components_layer_state__';
|
|
5
|
+
function resetLayerState() {
|
|
6
|
+
delete window[GLOBAL_KEY];
|
|
7
|
+
}
|
|
8
|
+
function Layer({
|
|
9
|
+
index,
|
|
10
|
+
zIndex
|
|
11
|
+
}) {
|
|
12
|
+
const resolved = useLayer({
|
|
13
|
+
isOpen: true,
|
|
14
|
+
zIndex
|
|
15
|
+
});
|
|
16
|
+
return createElement('div', {
|
|
17
|
+
'data-testid': `layer-${index}`,
|
|
18
|
+
'data-z': resolved.zIndex
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function layersTree(zIndexes) {
|
|
22
|
+
const children = zIndexes.map((zIndex, index) => createElement(Layer, {
|
|
23
|
+
// eslint-disable-next-line react/no-array-index-key
|
|
24
|
+
key: index,
|
|
25
|
+
index,
|
|
26
|
+
zIndex
|
|
27
|
+
}));
|
|
28
|
+
return createElement(Fragment, null, ...children);
|
|
29
|
+
}
|
|
30
|
+
function readZIndexes(getByTestId, count) {
|
|
31
|
+
return Array.from({
|
|
32
|
+
length: count
|
|
33
|
+
}, (_, index) => Number(getByTestId(`layer-${index}`).getAttribute('data-z')));
|
|
34
|
+
}
|
|
35
|
+
function renderZIndexes(zIndexes) {
|
|
36
|
+
const {
|
|
37
|
+
getByTestId
|
|
38
|
+
} = render(layersTree(zIndexes));
|
|
39
|
+
return readZIndexes(getByTestId, zIndexes.length);
|
|
40
|
+
}
|
|
41
|
+
describe('useLayer z-index resolution', () => {
|
|
42
|
+
beforeEach(resetLayerState);
|
|
43
|
+
afterEach(resetLayerState);
|
|
44
|
+
it('puts a lone layer at the base z-index', () => {
|
|
45
|
+
expect(renderZIndexes([undefined])).toEqual([10000]);
|
|
46
|
+
});
|
|
47
|
+
it('stacks plain layers in registration order', () => {
|
|
48
|
+
expect(renderZIndexes([undefined, undefined])).toEqual([10000, 10100]);
|
|
49
|
+
});
|
|
50
|
+
it('keeps a layer opened on top of an explicit base above it', () => {
|
|
51
|
+
expect(renderZIndexes([100000, undefined])).toEqual([100000, 100100]);
|
|
52
|
+
});
|
|
53
|
+
it('propagates the base to every layer stacked above it', () => {
|
|
54
|
+
expect(renderZIndexes([100000, undefined, undefined])).toEqual([100000, 100100, 100200]);
|
|
55
|
+
});
|
|
56
|
+
it('renders a lone explicit base exactly at its value', () => {
|
|
57
|
+
expect(renderZIndexes([100000])).toEqual([100000]);
|
|
58
|
+
});
|
|
59
|
+
it('honors a higher base set by a layer above', () => {
|
|
60
|
+
expect(renderZIndexes([undefined, 999999])).toEqual([10000, 999999]);
|
|
61
|
+
});
|
|
62
|
+
it('re-resolves stacked layers when an open layer changes its base', () => {
|
|
63
|
+
const {
|
|
64
|
+
rerender,
|
|
65
|
+
getByTestId
|
|
66
|
+
} = render(layersTree([100000, undefined]));
|
|
67
|
+
expect(readZIndexes(getByTestId, 2)).toEqual([100000, 100100]);
|
|
68
|
+
rerender(layersTree([200000, undefined]));
|
|
69
|
+
expect(readZIndexes(getByTestId, 2)).toEqual([200000, 200100]);
|
|
70
|
+
});
|
|
71
|
+
});
|
package/hooks/useLayer.js
CHANGED
|
@@ -67,8 +67,19 @@ function unregisterLayer(id) {
|
|
|
67
67
|
teardownKeydownListener();
|
|
68
68
|
notify();
|
|
69
69
|
}
|
|
70
|
-
function
|
|
71
|
-
|
|
70
|
+
function resolveZIndex(id, fallbackZIndex) {
|
|
71
|
+
const {
|
|
72
|
+
stack
|
|
73
|
+
} = getState();
|
|
74
|
+
let zIndexBelow = BASE_Z_INDEX - Z_INDEX_STEP;
|
|
75
|
+
for (const entry of stack) {
|
|
76
|
+
const layerZIndex = Math.max(entry.baseZIndex ?? 0, zIndexBelow + Z_INDEX_STEP);
|
|
77
|
+
if (entry.id === id) {
|
|
78
|
+
return layerZIndex;
|
|
79
|
+
}
|
|
80
|
+
zIndexBelow = layerZIndex;
|
|
81
|
+
}
|
|
82
|
+
return fallbackZIndex ?? BASE_Z_INDEX;
|
|
72
83
|
}
|
|
73
84
|
export function useLayer({
|
|
74
85
|
isOpen,
|
|
@@ -78,6 +89,8 @@ export function useLayer({
|
|
|
78
89
|
const id = useId();
|
|
79
90
|
const onCloseRef = useRef(onClose);
|
|
80
91
|
onCloseRef.current = onClose;
|
|
92
|
+
const zIndexRef = useRef(zIndex);
|
|
93
|
+
zIndexRef.current = zIndex;
|
|
81
94
|
const [, forceRender] = useState(0);
|
|
82
95
|
useEffect(() => {
|
|
83
96
|
const subscriber = () => forceRender(value => value + 1);
|
|
@@ -95,13 +108,20 @@ export function useLayer({
|
|
|
95
108
|
close: () => {
|
|
96
109
|
var _onCloseRef$current;
|
|
97
110
|
return (_onCloseRef$current = onCloseRef.current) === null || _onCloseRef$current === void 0 ? void 0 : _onCloseRef$current.call(onCloseRef);
|
|
98
|
-
}
|
|
111
|
+
},
|
|
112
|
+
baseZIndex: zIndexRef.current
|
|
99
113
|
});
|
|
100
114
|
forceRender(value => value + 1);
|
|
101
115
|
return () => unregisterLayer(id);
|
|
102
116
|
}, [isOpen, id]);
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
useLayoutEffect(() => {
|
|
118
|
+
const entry = getState().stack.find(item => item.id === id);
|
|
119
|
+
if (entry && entry.baseZIndex !== zIndex) {
|
|
120
|
+
entry.baseZIndex = zIndex;
|
|
121
|
+
notify();
|
|
122
|
+
}
|
|
123
|
+
}, [zIndex, id]);
|
|
124
|
+
const resolvedZIndex = resolveZIndex(id, zIndex);
|
|
105
125
|
return {
|
|
106
126
|
zIndex: resolvedZIndex
|
|
107
127
|
};
|
package/package.json
CHANGED
|
@@ -47,6 +47,8 @@ export function SuggestionsDropdown({
|
|
|
47
47
|
id: listboxId,
|
|
48
48
|
role: "listbox",
|
|
49
49
|
onScroll: onScroll,
|
|
50
|
+
onMouseDown: event => event.stopPropagation(),
|
|
51
|
+
onTouchStart: event => event.stopPropagation(),
|
|
50
52
|
children: [loading && _jsx(DropdownEmpty, {
|
|
51
53
|
children: loadingText
|
|
52
54
|
}), !loading && createLabel && _jsx(DropdownItem, {
|
|
@@ -1,28 +1,57 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Dropdown text slot (e.g. `emptyText`, `loadingText`): either a static node,
|
|
4
|
+
* or a function of the current query so the message can include the search term.
|
|
5
|
+
*/
|
|
2
6
|
export type DropdownText = ReactNode | ((query: string) => ReactNode);
|
|
7
|
+
/** State of an option row, passed to a custom `renderItem`. */
|
|
3
8
|
export type ItemState = {
|
|
9
|
+
/** The row is selected (its key matches the current value). */
|
|
4
10
|
selected: boolean;
|
|
11
|
+
/** The row is the active, keyboard-highlighted one. */
|
|
5
12
|
active: boolean;
|
|
6
13
|
};
|
|
14
|
+
/**
|
|
15
|
+
* How to derive a stable key for an item. Optional for `string`/`number` items
|
|
16
|
+
* (the item is its own key); required for object items.
|
|
17
|
+
*/
|
|
7
18
|
export type GetKeyProp<T> = [T] extends [string | number] ? {
|
|
8
19
|
getKey?: (item: T) => string | number;
|
|
9
20
|
} : {
|
|
10
21
|
getKey: (item: T) => string | number;
|
|
11
22
|
};
|
|
23
|
+
/** One page returned by an async {@link LoadItems} loader. */
|
|
12
24
|
export type LoadItemsResult<T> = {
|
|
25
|
+
/** Items for the requested page. */
|
|
13
26
|
items: T[];
|
|
27
|
+
/**
|
|
28
|
+
* Whether more pages are available. Defaults to `false` when omitted, so
|
|
29
|
+
* paging continues only while a page explicitly returns `true`.
|
|
30
|
+
*/
|
|
14
31
|
hasMore?: boolean;
|
|
15
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Async item loader. Called with the current query and a zero-based page index;
|
|
35
|
+
* the component owns paging and accumulates results across pages.
|
|
36
|
+
*/
|
|
16
37
|
export type LoadItems<T> = (query: string, page: number) => Promise<LoadItemsResult<T>>;
|
|
38
|
+
/**
|
|
39
|
+
* Where options come from: a static `items` array, or an async `loadItems`
|
|
40
|
+
* loader — mutually exclusive.
|
|
41
|
+
*/
|
|
17
42
|
export type ItemsSource<T> = {
|
|
43
|
+
/** Static list of options. */
|
|
18
44
|
items: T[];
|
|
19
45
|
loadItems?: never;
|
|
20
46
|
debounceMs?: never;
|
|
21
47
|
minChars?: never;
|
|
22
48
|
} | {
|
|
23
49
|
items?: never;
|
|
50
|
+
/** Async loader; receives the query and page index, returns one page. */
|
|
24
51
|
loadItems: LoadItems<T>;
|
|
52
|
+
/** Debounce (ms) before calling `loadItems` after the query changes. */
|
|
25
53
|
debounceMs?: number;
|
|
54
|
+
/** Don't call `loadItems` until the query has at least this many characters. */
|
|
26
55
|
minChars?: number;
|
|
27
56
|
};
|
|
28
57
|
export type DropdownItemDescriptor = {
|