@ews-admin/global-design-system 1.1.6 → 1.1.8
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/Input/Input.d.ts +4 -0
- package/dist/components/Input/Input.d.ts.map +1 -1
- package/dist/components/Logo/Logo.d.ts +1 -1
- package/dist/components/Logo/Logo.d.ts.map +1 -1
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts +1 -1
- package/dist/components/MultiSearchAutocomplete/MultiSearchAutocomplete.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +91 -0
- package/dist/components/Select/Select.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +3 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/ThemeDebugger/ThemeDebugger.d.ts +6 -0
- package/dist/components/ThemeDebugger/ThemeDebugger.d.ts.map +1 -0
- package/dist/components/ThemeDebugger/index.d.ts +3 -0
- package/dist/components/ThemeDebugger/index.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useSelectField.d.ts +16 -0
- package/dist/hooks/useSelectField.d.ts.map +1 -0
- package/dist/icons/ArrowRightIcon.d.ts +4 -0
- package/dist/icons/ArrowRightIcon.d.ts.map +1 -0
- package/dist/icons/CheckIcon.d.ts +4 -0
- package/dist/icons/CheckIcon.d.ts.map +1 -0
- package/dist/icons/EyeIcon.d.ts +4 -0
- package/dist/icons/EyeIcon.d.ts.map +1 -0
- package/dist/icons/EyeOffIcon.d.ts +4 -0
- package/dist/icons/EyeOffIcon.d.ts.map +1 -0
- package/dist/icons/SearchIcon.d.ts +4 -0
- package/dist/icons/SearchIcon.d.ts.map +1 -0
- package/dist/index.css +2 -2
- package/dist/index.d.ts +118 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.esm.css +2 -2
- package/dist/index.esm.js +763 -10
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +764 -8
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/components/Input/Input.tsx +10 -4
- package/src/components/Logo/Logo.tsx +1 -1
- package/src/components/MultiSearchAutocomplete/MultiSearchAutocomplete.tsx +1 -1
- package/src/components/Select/Select.tsx +553 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/ThemeDebugger/ThemeDebugger.tsx +101 -0
- package/src/components/ThemeDebugger/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useSelectField.ts +53 -0
- package/src/index.ts +8 -1
- package/src/styles/index.css +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ews-admin/global-design-system",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "EWS Global Design System - Reusable components for EWS applications",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.esm.js",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@babel/preset-env": "^7.23.0",
|
|
29
29
|
"@babel/preset-react": "^7.22.0",
|
|
30
30
|
"@babel/preset-typescript": "^7.23.0",
|
|
31
|
+
"@eslint/js": "^9.36.0",
|
|
31
32
|
"@rollup/plugin-babel": "^6.0.4",
|
|
32
33
|
"@rollup/plugin-commonjs": "^25.0.7",
|
|
33
34
|
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
@@ -41,13 +42,13 @@
|
|
|
41
42
|
"@storybook/testing-library": "^0.2.2",
|
|
42
43
|
"@types/react": "^18.2.37",
|
|
43
44
|
"@types/react-dom": "^18.2.15",
|
|
44
|
-
"@typescript-eslint/eslint-plugin": "^
|
|
45
|
-
"@typescript-eslint/parser": "^
|
|
45
|
+
"@typescript-eslint/eslint-plugin": "^8.44.0",
|
|
46
|
+
"@typescript-eslint/parser": "^8.44.0",
|
|
46
47
|
"@vitejs/plugin-react": "^4.2.1",
|
|
47
48
|
"autoprefixer": "^10.4.21",
|
|
48
|
-
"eslint": "^
|
|
49
|
-
"eslint-plugin-react": "^7.
|
|
50
|
-
"eslint-plugin-react-hooks": "^
|
|
49
|
+
"eslint": "^9.36.0",
|
|
50
|
+
"eslint-plugin-react": "^7.37.5",
|
|
51
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
51
52
|
"postcss": "^8.5.6",
|
|
52
53
|
"rollup": "^4.6.1",
|
|
53
54
|
"rollup-plugin-dts": "^6.1.0",
|
|
@@ -40,6 +40,10 @@ export interface InputProps
|
|
|
40
40
|
* Whether to show password toggle for password inputs
|
|
41
41
|
*/
|
|
42
42
|
showPasswordToggle?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Whether the input is required (shows red asterisk)
|
|
45
|
+
*/
|
|
46
|
+
required?: boolean;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
@@ -55,6 +59,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
55
59
|
rightIcon,
|
|
56
60
|
fullWidth = false,
|
|
57
61
|
showPasswordToggle = false,
|
|
62
|
+
required = false,
|
|
58
63
|
id,
|
|
59
64
|
type = "text",
|
|
60
65
|
...props
|
|
@@ -102,11 +107,12 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
102
107
|
className="block text-sm font-medium text-ews-gray-700"
|
|
103
108
|
>
|
|
104
109
|
{label}
|
|
110
|
+
{required && <span className="ml-1 text-ews-error">*</span>}
|
|
105
111
|
</label>
|
|
106
112
|
)}
|
|
107
113
|
<div className="relative">
|
|
108
114
|
{leftIcon && (
|
|
109
|
-
<div className="absolute inset-y-0 left-0 pl-3
|
|
115
|
+
<div className="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
|
110
116
|
<span className={cn("text-ews-gray-400", iconSizes[size])}>
|
|
111
117
|
{leftIcon}
|
|
112
118
|
</span>
|
|
@@ -127,7 +133,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
127
133
|
{...props}
|
|
128
134
|
/>
|
|
129
135
|
{rightIcon && !shouldShowPasswordToggle && (
|
|
130
|
-
<div className="absolute inset-y-0 right-0 pr-3
|
|
136
|
+
<div className="flex absolute inset-y-0 right-0 items-center pr-3 pointer-events-none">
|
|
131
137
|
<span className={cn("text-ews-gray-400", iconSizes[size])}>
|
|
132
138
|
{rightIcon}
|
|
133
139
|
</span>
|
|
@@ -136,13 +142,13 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
|
136
142
|
{shouldShowPasswordToggle && (
|
|
137
143
|
<button
|
|
138
144
|
type="button"
|
|
139
|
-
className="absolute inset-y-0 right-0 pr-3
|
|
145
|
+
className="flex absolute inset-y-0 right-0 items-center pr-3"
|
|
140
146
|
onClick={() => setShowPassword(!showPassword)}
|
|
141
147
|
tabIndex={-1}
|
|
142
148
|
>
|
|
143
149
|
<span
|
|
144
150
|
className={cn(
|
|
145
|
-
"text-ews-gray-400 hover:text-ews-gray-600
|
|
151
|
+
"transition-colors text-ews-gray-400 hover:text-ews-gray-600",
|
|
146
152
|
iconSizes[size]
|
|
147
153
|
)}
|
|
148
154
|
>
|
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { ChevronDown, Search, X } from "lucide-react";
|
|
2
|
+
import React, { forwardRef, useEffect, useId, useRef, useState } from "react";
|
|
3
|
+
import { cn } from "../../utils";
|
|
4
|
+
|
|
5
|
+
export interface SelectOption<T = any> {
|
|
6
|
+
value: T;
|
|
7
|
+
label: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SelectProps<T = any> {
|
|
12
|
+
/**
|
|
13
|
+
* Array of options to display
|
|
14
|
+
*/
|
|
15
|
+
options: SelectOption<T>[];
|
|
16
|
+
/**
|
|
17
|
+
* Current selected value
|
|
18
|
+
*/
|
|
19
|
+
value?: T;
|
|
20
|
+
/**
|
|
21
|
+
* Callback when selection changes
|
|
22
|
+
*/
|
|
23
|
+
onChange?: (value: T, option: SelectOption<T>) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Placeholder text when no option is selected
|
|
26
|
+
*/
|
|
27
|
+
placeholder?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Label for the select
|
|
30
|
+
*/
|
|
31
|
+
label?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Helper text to display below the select
|
|
34
|
+
*/
|
|
35
|
+
helperText?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Error message to display
|
|
38
|
+
*/
|
|
39
|
+
error?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Whether the select is in an error state
|
|
42
|
+
*/
|
|
43
|
+
isError?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Size variant
|
|
46
|
+
*/
|
|
47
|
+
size?: "sm" | "md" | "lg";
|
|
48
|
+
/**
|
|
49
|
+
* Whether the select is disabled
|
|
50
|
+
*/
|
|
51
|
+
disabled?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Whether the select is required
|
|
54
|
+
*/
|
|
55
|
+
required?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Whether the select is searchable
|
|
58
|
+
*/
|
|
59
|
+
searchable?: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Whether the select allows multiple selections
|
|
62
|
+
*/
|
|
63
|
+
multiple?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Custom class name for the select element
|
|
66
|
+
*/
|
|
67
|
+
selectClassName?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Custom class name for the container
|
|
70
|
+
*/
|
|
71
|
+
containerClassName?: string;
|
|
72
|
+
/**
|
|
73
|
+
* Custom class name for the dropdown
|
|
74
|
+
*/
|
|
75
|
+
dropdownClassName?: string;
|
|
76
|
+
/**
|
|
77
|
+
* Maximum height of the dropdown
|
|
78
|
+
*/
|
|
79
|
+
maxHeight?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Whether to show clear button
|
|
82
|
+
*/
|
|
83
|
+
clearable?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Custom render function for options
|
|
86
|
+
*/
|
|
87
|
+
renderOption?: (
|
|
88
|
+
option: SelectOption<T>,
|
|
89
|
+
isSelected: boolean
|
|
90
|
+
) => React.ReactNode;
|
|
91
|
+
/**
|
|
92
|
+
* Custom render function for selected value
|
|
93
|
+
*/
|
|
94
|
+
renderValue?: (option: SelectOption<T> | null) => React.ReactNode;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const Select = forwardRef<HTMLDivElement, SelectProps>(
|
|
98
|
+
(
|
|
99
|
+
{
|
|
100
|
+
options = [],
|
|
101
|
+
value,
|
|
102
|
+
onChange,
|
|
103
|
+
placeholder = "Select an option...",
|
|
104
|
+
label,
|
|
105
|
+
helperText,
|
|
106
|
+
error,
|
|
107
|
+
isError = false,
|
|
108
|
+
size = "md",
|
|
109
|
+
disabled = false,
|
|
110
|
+
required = false,
|
|
111
|
+
searchable = false,
|
|
112
|
+
multiple: _multiple = false,
|
|
113
|
+
selectClassName,
|
|
114
|
+
containerClassName,
|
|
115
|
+
dropdownClassName,
|
|
116
|
+
maxHeight = 200,
|
|
117
|
+
clearable = false,
|
|
118
|
+
renderOption,
|
|
119
|
+
renderValue,
|
|
120
|
+
...props
|
|
121
|
+
},
|
|
122
|
+
ref
|
|
123
|
+
) => {
|
|
124
|
+
const generatedId = useId();
|
|
125
|
+
const selectId = `select-${generatedId}`;
|
|
126
|
+
const hasError = isError || !!error;
|
|
127
|
+
|
|
128
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
129
|
+
const [searchTerm, setSearchTerm] = useState("");
|
|
130
|
+
const [focusedIndex, setFocusedIndex] = useState(-1);
|
|
131
|
+
const [dropdownPosition, setDropdownPosition] = useState<"bottom" | "top">(
|
|
132
|
+
"bottom"
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
136
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
137
|
+
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
138
|
+
const optionRefs = useRef<(HTMLDivElement | null)[]>([]);
|
|
139
|
+
|
|
140
|
+
// Find selected option
|
|
141
|
+
const selectedOption = options.find((option) => option.value === value);
|
|
142
|
+
|
|
143
|
+
// Filter options based on search term
|
|
144
|
+
const filteredOptions = searchable
|
|
145
|
+
? options.filter((option) =>
|
|
146
|
+
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
|
147
|
+
)
|
|
148
|
+
: options;
|
|
149
|
+
|
|
150
|
+
// Calculate dropdown position based on available space
|
|
151
|
+
const calculateDropdownPosition = () => {
|
|
152
|
+
if (!containerRef.current) return;
|
|
153
|
+
|
|
154
|
+
const containerRect = containerRef.current.getBoundingClientRect();
|
|
155
|
+
const viewportHeight = window.innerHeight;
|
|
156
|
+
|
|
157
|
+
// More accurate height calculation
|
|
158
|
+
const optionHeight = 40; // Approximate height per option
|
|
159
|
+
const searchHeight = searchable ? 60 : 0;
|
|
160
|
+
const padding = 16; // py-1 = 8px top + 8px bottom
|
|
161
|
+
const dropdownHeight = Math.min(
|
|
162
|
+
maxHeight,
|
|
163
|
+
filteredOptions.length * optionHeight + searchHeight + padding
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const spaceBelow = viewportHeight - containerRect.bottom;
|
|
167
|
+
const spaceAbove = containerRect.top;
|
|
168
|
+
|
|
169
|
+
// Add some buffer (20px) to prevent edge cases
|
|
170
|
+
const buffer = 20;
|
|
171
|
+
|
|
172
|
+
console.log("Position calculation:", {
|
|
173
|
+
spaceBelow,
|
|
174
|
+
spaceAbove,
|
|
175
|
+
dropdownHeight,
|
|
176
|
+
viewportHeight,
|
|
177
|
+
containerBottom: containerRect.bottom,
|
|
178
|
+
shouldOpenTop:
|
|
179
|
+
spaceBelow < dropdownHeight + buffer &&
|
|
180
|
+
spaceAbove > dropdownHeight + buffer,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// If there's not enough space below but enough space above, position on top
|
|
184
|
+
if (
|
|
185
|
+
spaceBelow < dropdownHeight + buffer &&
|
|
186
|
+
spaceAbove > dropdownHeight + buffer
|
|
187
|
+
) {
|
|
188
|
+
setDropdownPosition("top");
|
|
189
|
+
} else {
|
|
190
|
+
setDropdownPosition("bottom");
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Alternative calculation using actual dropdown element when available
|
|
195
|
+
const calculateDropdownPositionWithElement = () => {
|
|
196
|
+
if (!containerRef.current || !dropdownRef.current) return;
|
|
197
|
+
|
|
198
|
+
const containerRect = containerRef.current.getBoundingClientRect();
|
|
199
|
+
const dropdownRect = dropdownRef.current.getBoundingClientRect();
|
|
200
|
+
const viewportHeight = window.innerHeight;
|
|
201
|
+
|
|
202
|
+
const spaceBelow = viewportHeight - containerRect.bottom;
|
|
203
|
+
const spaceAbove = containerRect.top;
|
|
204
|
+
const actualDropdownHeight = dropdownRect.height;
|
|
205
|
+
|
|
206
|
+
console.log("Position calculation with element:", {
|
|
207
|
+
spaceBelow,
|
|
208
|
+
spaceAbove,
|
|
209
|
+
actualDropdownHeight,
|
|
210
|
+
viewportHeight,
|
|
211
|
+
containerBottom: containerRect.bottom,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// If there's not enough space below but enough space above, position on top
|
|
215
|
+
if (
|
|
216
|
+
spaceBelow < actualDropdownHeight &&
|
|
217
|
+
spaceAbove > actualDropdownHeight
|
|
218
|
+
) {
|
|
219
|
+
setDropdownPosition("top");
|
|
220
|
+
} else {
|
|
221
|
+
setDropdownPosition("bottom");
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Handle click outside
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
228
|
+
if (
|
|
229
|
+
containerRef.current &&
|
|
230
|
+
!containerRef.current.contains(event.target as Node)
|
|
231
|
+
) {
|
|
232
|
+
setIsOpen(false);
|
|
233
|
+
setSearchTerm("");
|
|
234
|
+
setFocusedIndex(-1);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
239
|
+
return () =>
|
|
240
|
+
document.removeEventListener("mousedown", handleClickOutside);
|
|
241
|
+
}, []);
|
|
242
|
+
|
|
243
|
+
// Calculate position when dropdown opens
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (isOpen) {
|
|
246
|
+
// First calculation based on estimated height
|
|
247
|
+
calculateDropdownPosition();
|
|
248
|
+
|
|
249
|
+
// Second calculation after dropdown is rendered with actual height
|
|
250
|
+
requestAnimationFrame(() => {
|
|
251
|
+
calculateDropdownPositionWithElement();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}, [isOpen, filteredOptions.length, searchable, maxHeight]);
|
|
255
|
+
|
|
256
|
+
// Recalculate position on window resize
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const handleResize = () => {
|
|
259
|
+
if (isOpen) {
|
|
260
|
+
calculateDropdownPosition();
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
window.addEventListener("resize", handleResize);
|
|
265
|
+
window.addEventListener("scroll", handleResize);
|
|
266
|
+
|
|
267
|
+
return () => {
|
|
268
|
+
window.removeEventListener("resize", handleResize);
|
|
269
|
+
window.removeEventListener("scroll", handleResize);
|
|
270
|
+
};
|
|
271
|
+
}, [isOpen]);
|
|
272
|
+
|
|
273
|
+
// Handle keyboard navigation
|
|
274
|
+
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
275
|
+
if (disabled) return;
|
|
276
|
+
|
|
277
|
+
switch (event.key) {
|
|
278
|
+
case "ArrowDown":
|
|
279
|
+
event.preventDefault();
|
|
280
|
+
if (!isOpen) {
|
|
281
|
+
setIsOpen(true);
|
|
282
|
+
} else {
|
|
283
|
+
setFocusedIndex((prev) =>
|
|
284
|
+
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
case "ArrowUp":
|
|
289
|
+
event.preventDefault();
|
|
290
|
+
if (!isOpen) {
|
|
291
|
+
setIsOpen(true);
|
|
292
|
+
} else {
|
|
293
|
+
setFocusedIndex((prev) =>
|
|
294
|
+
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
break;
|
|
298
|
+
case "Enter":
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
if (
|
|
301
|
+
isOpen &&
|
|
302
|
+
focusedIndex >= 0 &&
|
|
303
|
+
focusedIndex < filteredOptions.length
|
|
304
|
+
) {
|
|
305
|
+
handleSelect(filteredOptions[focusedIndex]);
|
|
306
|
+
} else if (!isOpen) {
|
|
307
|
+
setIsOpen(true);
|
|
308
|
+
}
|
|
309
|
+
break;
|
|
310
|
+
case "Escape":
|
|
311
|
+
event.preventDefault();
|
|
312
|
+
setIsOpen(false);
|
|
313
|
+
setSearchTerm("");
|
|
314
|
+
setFocusedIndex(-1);
|
|
315
|
+
break;
|
|
316
|
+
case "Tab":
|
|
317
|
+
setIsOpen(false);
|
|
318
|
+
setSearchTerm("");
|
|
319
|
+
setFocusedIndex(-1);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// Handle option selection
|
|
325
|
+
const handleSelect = (option: SelectOption) => {
|
|
326
|
+
if (option.disabled) return;
|
|
327
|
+
|
|
328
|
+
onChange?.(option.value, option);
|
|
329
|
+
setIsOpen(false);
|
|
330
|
+
setSearchTerm("");
|
|
331
|
+
setFocusedIndex(-1);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// Handle clear
|
|
335
|
+
const handleClear = (event: React.MouseEvent) => {
|
|
336
|
+
event.stopPropagation();
|
|
337
|
+
onChange?.(undefined as any, {} as SelectOption);
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Handle toggle
|
|
341
|
+
const handleToggle = () => {
|
|
342
|
+
if (disabled) return;
|
|
343
|
+
setIsOpen(!isOpen);
|
|
344
|
+
if (!isOpen && searchable) {
|
|
345
|
+
setTimeout(() => inputRef.current?.focus(), 0);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
// Scroll focused option into view
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
if (focusedIndex >= 0 && optionRefs.current[focusedIndex]) {
|
|
352
|
+
optionRefs.current[focusedIndex]?.scrollIntoView({
|
|
353
|
+
block: "nearest",
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}, [focusedIndex]);
|
|
357
|
+
|
|
358
|
+
const sizeClasses = {
|
|
359
|
+
sm: "text-sm px-3 py-1.5",
|
|
360
|
+
md: "text-sm px-3 py-2",
|
|
361
|
+
lg: "text-base px-4 py-2.5",
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const iconSizeClasses = {
|
|
365
|
+
sm: "w-4 h-4",
|
|
366
|
+
md: "w-4 h-4",
|
|
367
|
+
lg: "w-5 h-5",
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
return (
|
|
371
|
+
<div className={cn("w-full", containerClassName)} ref={containerRef}>
|
|
372
|
+
{label && (
|
|
373
|
+
<label
|
|
374
|
+
htmlFor={selectId}
|
|
375
|
+
className={cn(
|
|
376
|
+
"block text-sm font-medium mb-1",
|
|
377
|
+
hasError ? "text-ews-error" : "text-ews-gray-700",
|
|
378
|
+
disabled && "text-ews-gray-400"
|
|
379
|
+
)}
|
|
380
|
+
>
|
|
381
|
+
{label}
|
|
382
|
+
{required && <span className="ml-1 text-ews-error">*</span>}
|
|
383
|
+
</label>
|
|
384
|
+
)}
|
|
385
|
+
|
|
386
|
+
<div className="relative">
|
|
387
|
+
{/* Custom select trigger */}
|
|
388
|
+
<div
|
|
389
|
+
ref={ref as React.RefObject<HTMLDivElement>}
|
|
390
|
+
role="combobox"
|
|
391
|
+
aria-expanded={isOpen}
|
|
392
|
+
aria-haspopup="listbox"
|
|
393
|
+
aria-labelledby={label ? selectId : undefined}
|
|
394
|
+
tabIndex={disabled ? -1 : 0}
|
|
395
|
+
onKeyDown={handleKeyDown}
|
|
396
|
+
onClick={handleToggle}
|
|
397
|
+
className={cn(
|
|
398
|
+
// Base styles
|
|
399
|
+
"ews-select-trigger w-full bg-white border rounded-md shadow-sm transition-colors cursor-pointer",
|
|
400
|
+
"focus:outline-none focus:ring-2 focus:ring-offset-0",
|
|
401
|
+
"disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-ews-gray-50",
|
|
402
|
+
|
|
403
|
+
// Size
|
|
404
|
+
sizeClasses[size],
|
|
405
|
+
|
|
406
|
+
// Border and focus states
|
|
407
|
+
hasError
|
|
408
|
+
? "border-ews-error focus:border-ews-error focus:ring-ews-error/20"
|
|
409
|
+
: "border-ews-gray-300 focus:border-ews-primary focus:ring-ews-primary/20",
|
|
410
|
+
|
|
411
|
+
// Hover state
|
|
412
|
+
!disabled && !hasError && "hover:border-ews-gray-400",
|
|
413
|
+
|
|
414
|
+
// Text color
|
|
415
|
+
"text-ews-gray-900",
|
|
416
|
+
|
|
417
|
+
// Padding for icons
|
|
418
|
+
"pr-10",
|
|
419
|
+
|
|
420
|
+
selectClassName
|
|
421
|
+
)}
|
|
422
|
+
{...props}
|
|
423
|
+
>
|
|
424
|
+
<div className="flex justify-between items-center">
|
|
425
|
+
<div className="flex-1 min-w-0">
|
|
426
|
+
{selectedOption ? (
|
|
427
|
+
renderValue ? (
|
|
428
|
+
renderValue(selectedOption)
|
|
429
|
+
) : (
|
|
430
|
+
<span className="truncate">{selectedOption.label}</span>
|
|
431
|
+
)
|
|
432
|
+
) : (
|
|
433
|
+
<span className="text-ews-gray-500">{placeholder}</span>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<div className="flex items-center ml-2 space-x-1">
|
|
438
|
+
{clearable && selectedOption && !disabled && (
|
|
439
|
+
<button
|
|
440
|
+
type="button"
|
|
441
|
+
onClick={handleClear}
|
|
442
|
+
className="p-1 rounded hover:bg-ews-gray-100"
|
|
443
|
+
>
|
|
444
|
+
<X
|
|
445
|
+
className={cn(iconSizeClasses[size], "text-ews-gray-400")}
|
|
446
|
+
/>
|
|
447
|
+
</button>
|
|
448
|
+
)}
|
|
449
|
+
<ChevronDown
|
|
450
|
+
className={cn(
|
|
451
|
+
iconSizeClasses[size],
|
|
452
|
+
hasError ? "text-ews-error" : "text-ews-gray-400",
|
|
453
|
+
disabled && "text-ews-gray-300",
|
|
454
|
+
isOpen && "rotate-180 transition-transform"
|
|
455
|
+
)}
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* Custom dropdown */}
|
|
462
|
+
{isOpen && (
|
|
463
|
+
<div
|
|
464
|
+
ref={dropdownRef}
|
|
465
|
+
role="listbox"
|
|
466
|
+
className={cn(
|
|
467
|
+
"absolute z-50 w-full bg-white rounded-md border shadow-lg border-ews-gray-300",
|
|
468
|
+
"focus:outline-none",
|
|
469
|
+
dropdownPosition === "top"
|
|
470
|
+
? "bottom-full mb-1"
|
|
471
|
+
: "top-full mt-1",
|
|
472
|
+
dropdownClassName
|
|
473
|
+
)}
|
|
474
|
+
style={{ maxHeight: `${maxHeight}px` }}
|
|
475
|
+
>
|
|
476
|
+
{/* Search input */}
|
|
477
|
+
{searchable && (
|
|
478
|
+
<div className="p-2 border-b border-ews-gray-200">
|
|
479
|
+
<div className="relative">
|
|
480
|
+
<Search className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-ews-gray-400" />
|
|
481
|
+
<input
|
|
482
|
+
ref={inputRef}
|
|
483
|
+
type="text"
|
|
484
|
+
value={searchTerm}
|
|
485
|
+
onChange={(e) => setSearchTerm(e.target.value)}
|
|
486
|
+
placeholder="Search options..."
|
|
487
|
+
className="py-2 pr-3 pl-9 w-full text-sm rounded-md border border-ews-gray-300 focus:outline-none focus:ring-2 focus:ring-offset-0 focus:ring-ews-primary/20 focus:border-ews-primary"
|
|
488
|
+
/>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
|
|
493
|
+
{/* Options list */}
|
|
494
|
+
<div
|
|
495
|
+
className="overflow-auto py-1"
|
|
496
|
+
style={{ maxHeight: `${maxHeight - (searchable ? 60 : 0)}px` }}
|
|
497
|
+
>
|
|
498
|
+
{filteredOptions.length === 0 ? (
|
|
499
|
+
<div className="px-3 py-2 text-sm text-ews-gray-500">
|
|
500
|
+
{searchTerm ? "No options found" : "No options available"}
|
|
501
|
+
</div>
|
|
502
|
+
) : (
|
|
503
|
+
filteredOptions.map((option, index) => {
|
|
504
|
+
const isSelected = option.value === value;
|
|
505
|
+
const isFocused = index === focusedIndex;
|
|
506
|
+
|
|
507
|
+
return (
|
|
508
|
+
<div
|
|
509
|
+
key={`${String(option.value)}-${index}`}
|
|
510
|
+
ref={(el) => (optionRefs.current[index] = el)}
|
|
511
|
+
role="option"
|
|
512
|
+
aria-selected={isSelected}
|
|
513
|
+
onClick={() => handleSelect(option)}
|
|
514
|
+
className={cn(
|
|
515
|
+
"px-3 py-2 text-sm cursor-pointer transition-colors",
|
|
516
|
+
isSelected && "bg-ews-primary text-white",
|
|
517
|
+
!isSelected && isFocused && "bg-ews-gray-100",
|
|
518
|
+
!isSelected && !isFocused && "hover:bg-ews-gray-50",
|
|
519
|
+
option.disabled && "opacity-50 cursor-not-allowed"
|
|
520
|
+
)}
|
|
521
|
+
>
|
|
522
|
+
{renderOption ? (
|
|
523
|
+
renderOption(option, isSelected)
|
|
524
|
+
) : (
|
|
525
|
+
<span className="truncate">{option.label}</span>
|
|
526
|
+
)}
|
|
527
|
+
</div>
|
|
528
|
+
);
|
|
529
|
+
})
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
{/* Helper text or error message */}
|
|
537
|
+
{(helperText || error) && (
|
|
538
|
+
<div className="mt-1">
|
|
539
|
+
{error ? (
|
|
540
|
+
<p className="text-sm text-ews-error">{error}</p>
|
|
541
|
+
) : (
|
|
542
|
+
<p className="text-sm text-ews-gray-500">{helperText}</p>
|
|
543
|
+
)}
|
|
544
|
+
</div>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
Select.displayName = "Select";
|
|
552
|
+
|
|
553
|
+
export { Select };
|