Skip to content

Commit d709169

Browse files
authored
Merge pull request #17 from mononen/UX-improvements
Ux improvements
2 parents e1fb5bf + 4e6b990 commit d709169

1 file changed

Lines changed: 189 additions & 82 deletions

File tree

Lines changed: 189 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,23 @@
1-
import { useState, useMemo } from 'react';
2-
import MuiSelect from '@mui/material/Select';
3-
import MenuItem from '@mui/material/MenuItem';
4-
import FormControl from '@mui/material/FormControl';
5-
import InputLabel from '@mui/material/InputLabel';
6-
import FormHelperText from '@mui/material/FormHelperText';
7-
import ListItemIcon from '@mui/material/ListItemIcon';
8-
import ListItemText from '@mui/material/ListItemText';
9-
import CircularProgress from '@mui/material/CircularProgress';
10-
import OutlinedInput from '@mui/material/OutlinedInput';
11-
import InputBase from '@mui/material/InputBase';
12-
import Chip from '@mui/material/Chip';
13-
import Box from '@mui/material/Box';
14-
import SearchIcon from '@mui/icons-material/Search';
15-
import ClearIcon from '@mui/icons-material/Clear';
16-
import type { SxProps, Theme } from '@mui/material/styles';
17-
import type { SelectChangeEvent } from '@mui/material/Select';
1+
import { useState, useMemo } from "react";
2+
import MuiSelect from "@mui/material/Select";
3+
import MenuItem from "@mui/material/MenuItem";
4+
import FormControl from "@mui/material/FormControl";
5+
import InputLabel from "@mui/material/InputLabel";
6+
import FormHelperText from "@mui/material/FormHelperText";
7+
import ListItemIcon from "@mui/material/ListItemIcon";
8+
import ListItemText from "@mui/material/ListItemText";
9+
import CircularProgress from "@mui/material/CircularProgress";
10+
import OutlinedInput from "@mui/material/OutlinedInput";
11+
import InputBase from "@mui/material/InputBase";
12+
import Chip from "@mui/material/Chip";
13+
import Box from "@mui/material/Box";
14+
import SearchIcon from "@mui/icons-material/Search";
15+
import ClearIcon from "@mui/icons-material/Clear";
16+
import type { SxProps, Theme } from "@mui/material/styles";
17+
import type { SelectChangeEvent } from "@mui/material/Select";
1818

19-
import type { SemanticColor, ComponentSize } from '../types';
20-
import { toMuiInputSize, toMuiColor, INPUT_HEIGHTS } from '../types';
19+
import type { SemanticColor, ComponentSize } from "../types";
20+
import { toMuiInputSize, toMuiColor, INPUT_HEIGHTS } from "../types";
2121

2222
export interface SelectOption {
2323
value: string;
@@ -50,8 +50,8 @@ export default function Select({
5050
options,
5151
value,
5252
onChange,
53-
size = 'md',
54-
color = 'neutral',
53+
size = "md",
54+
color = "neutral",
5555
label,
5656
helperText,
5757
placeholder,
@@ -64,11 +64,20 @@ export default function Select({
6464
clearable = false,
6565
sx,
6666
}: SelectProps) {
67-
const [search, setSearch] = useState('');
67+
const [search, setSearch] = useState("");
68+
const [isOpen, setIsOpen] = useState(false);
69+
const [focusedIndex, setFocusedIndex] = useState(-1);
70+
6871
const muiSize = toMuiInputSize(size);
6972
const muiColor = toMuiColor(color) as any;
70-
const hasError = typeof error === 'string' ? !!error : error;
71-
const errorText = typeof error === 'string' ? error : undefined;
73+
const hasError = typeof error === "string" ? !!error : error;
74+
const errorText = typeof error === "string" ? error : undefined;
75+
76+
const closeAndReset = () => {
77+
setIsOpen(false);
78+
setFocusedIndex(-1);
79+
if (searchable) setSearch("");
80+
};
7281

7382
const filteredOptions = useMemo(() => {
7483
if (!searchable || !search) return options;
@@ -80,18 +89,73 @@ export default function Select({
8089
onChange(e.target.value);
8190
};
8291

92+
const handleSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
93+
if (e.key === "Escape") {
94+
closeAndReset();
95+
return;
96+
}
97+
98+
if (e.key === "ArrowDown") {
99+
e.preventDefault();
100+
e.stopPropagation();
101+
if (filteredOptions.length === 0) return;
102+
const next = focusedIndex + 1;
103+
setFocusedIndex(next >= filteredOptions.length ? 0 : next);
104+
return;
105+
}
106+
107+
if (e.key === "ArrowUp") {
108+
e.preventDefault();
109+
e.stopPropagation();
110+
const prev = focusedIndex - 1;
111+
setFocusedIndex(prev < 0 ? filteredOptions.length - 1 : prev);
112+
return;
113+
}
114+
115+
if (
116+
e.key === "Enter" &&
117+
focusedIndex >= 0 &&
118+
focusedIndex < filteredOptions.length
119+
) {
120+
e.preventDefault();
121+
e.stopPropagation();
122+
const opt = filteredOptions[focusedIndex];
123+
if (!opt.disabled) {
124+
if (multiple) {
125+
const arr = Array.isArray(value) ? value : [];
126+
const newValue = arr.includes(opt.value)
127+
? arr.filter((v) => v !== opt.value)
128+
: [...arr, opt.value];
129+
onChange(newValue);
130+
} else {
131+
onChange(opt.value);
132+
closeAndReset();
133+
}
134+
}
135+
return;
136+
}
137+
138+
e.stopPropagation();
139+
};
140+
83141
return (
84142
<FormControl
85143
size={muiSize}
86144
fullWidth={fullWidth}
87145
error={hasError}
88146
disabled={disabled}
89-
style={{ '--ov-input-height': INPUT_HEIGHTS[size] } as React.CSSProperties}
147+
style={
148+
{ "--ov-input-height": INPUT_HEIGHTS[size] } as React.CSSProperties
149+
}
90150
sx={sx}
91151
>
92152
{label && (
93153
<InputLabel
94-
color={muiColor === 'default' || muiColor === 'inherit' ? undefined : muiColor}
154+
color={
155+
muiColor === "default" || muiColor === "inherit"
156+
? undefined
157+
: muiColor
158+
}
95159
shrink={!!placeholder || undefined}
96160
>
97161
{label}
@@ -103,124 +167,155 @@ export default function Select({
103167
multiple={multiple}
104168
displayEmpty={!!placeholder}
105169
label={label}
106-
notched={label ? (!!placeholder || undefined) : undefined}
170+
notched={label ? !!placeholder || undefined : undefined}
107171
input={multiple ? <OutlinedInput label={label} /> : undefined}
108-
onClose={() => { if (searchable) setSearch(''); }}
172+
open={isOpen}
173+
onOpen={() => {
174+
setIsOpen(true);
175+
setFocusedIndex(-1);
176+
}}
177+
onClose={closeAndReset}
109178
renderValue={
110179
multiple
111180
? (selected) => {
112181
const arr = selected as string[];
113182
if (arr.length === 0 && placeholder) {
114-
return <Box sx={{ color: 'var(--ov-fg-faint)' }}>{placeholder}</Box>;
183+
return (
184+
<Box sx={{ color: "var(--ov-fg-faint)" }}>
185+
{placeholder}
186+
</Box>
187+
);
115188
}
116189
return (
117-
<Box sx={{ display: 'flex', flexWrap: 'nowrap', gap: 0.5, overflow: 'hidden', alignItems: 'center' }}>
190+
<Box
191+
sx={{
192+
display: "flex",
193+
flexWrap: "nowrap",
194+
gap: 0.5,
195+
overflow: "hidden",
196+
alignItems: "center",
197+
}}
198+
>
118199
{arr.map((val) => {
119200
const opt = options.find((o) => o.value === val);
120201
return (
121202
<Chip
122203
key={val}
123204
label={opt?.label ?? val}
124205
size="small"
125-
{...(clearable ? {
126-
onDelete: (e: React.SyntheticEvent) => {
127-
e.stopPropagation();
128-
onChange(arr.filter(v => v !== val));
129-
},
130-
onMouseDown: (e: React.MouseEvent) => {
131-
e.stopPropagation();
132-
},
133-
} : {})}
206+
{...(clearable
207+
? {
208+
onDelete: (e: React.SyntheticEvent) => {
209+
e.stopPropagation();
210+
onChange(arr.filter((v) => v !== val));
211+
},
212+
onMouseDown: (e: React.MouseEvent) => {
213+
e.stopPropagation();
214+
},
215+
}
216+
: {})}
134217
/>
135218
);
136219
})}
137220
{clearable && arr.length > 1 && (
138-
<span
139-
role="button"
221+
<button
222+
type="button"
140223
aria-label="Clear all"
141-
onMouseDown={(e) => {
224+
onClick={(e) => {
142225
e.stopPropagation();
143-
e.preventDefault();
144226
onChange([]);
145227
}}
146228
style={{
147-
display: 'inline-flex',
148-
alignItems: 'center',
229+
display: "inline-flex",
230+
alignItems: "center",
149231
flexShrink: 0,
150232
marginLeft: 2,
151-
cursor: 'pointer',
152-
color: 'var(--ov-fg-faint)',
233+
cursor: "pointer",
234+
color: "var(--ov-fg-faint)",
153235
lineHeight: 0,
236+
background: "none",
237+
border: "none",
238+
padding: 0,
154239
}}
155240
>
156241
<ClearIcon sx={{ fontSize: 16 }} />
157-
</span>
242+
</button>
158243
)}
159244
</Box>
160245
);
161246
}
162247
: placeholder && !value
163248
? () => (
164-
<Box sx={{ color: 'var(--ov-fg-faint)' }}>{placeholder}</Box>
249+
<Box sx={{ color: "var(--ov-fg-faint)" }}>{placeholder}</Box>
165250
)
166251
: undefined
167252
}
168253
MenuProps={{
169254
PaperProps: {
170255
sx: {
171256
maxHeight: 300,
172-
...(size === 'xs' || size === 'sm' ? {
173-
'& .MuiMenuItem-root': {
174-
fontSize: '0.8rem',
175-
minHeight: 28,
176-
padding: '3px 8px',
177-
},
178-
'& .MuiListItemText-root': {
179-
margin: 0,
180-
},
181-
'& .MuiListItemText-primary': {
182-
fontSize: '0.8rem',
183-
},
184-
'& .MuiListItemIcon-root': {
185-
minWidth: 22,
186-
},
187-
} : {}),
257+
...(size === "xs" || size === "sm"
258+
? {
259+
"& .MuiMenuItem-root": {
260+
fontSize: "0.8rem",
261+
minHeight: 28,
262+
padding: "3px 8px",
263+
},
264+
"& .MuiListItemText-root": {
265+
margin: 0,
266+
},
267+
"& .MuiListItemText-primary": {
268+
fontSize: "0.8rem",
269+
},
270+
"& .MuiListItemIcon-root": {
271+
minWidth: 22,
272+
},
273+
}
274+
: {}),
188275
},
189276
},
190277
}}
191278
>
192279
{searchable && (
193280
<Box
194281
sx={{ px: 1, pb: 0.5, pt: 0.5 }}
195-
onKeyDown={(e) => e.stopPropagation()}
196282
onClickCapture={(e) => e.stopPropagation()}
197283
onMouseDown={(e) => e.stopPropagation()}
198284
>
199285
<Box
200286
sx={{
201-
display: 'flex',
202-
alignItems: 'center',
287+
display: "flex",
288+
alignItems: "center",
203289
height: 28,
204-
border: '1px solid var(--ov-border-default)',
205-
borderRadius: '4px',
206-
bgcolor: 'var(--ov-bg-base)',
290+
border: "1px solid var(--ov-border-default)",
291+
borderRadius: "4px",
292+
bgcolor: "var(--ov-bg-base)",
207293
px: 0.75,
208-
'&:focus-within': { borderColor: 'var(--ov-accent)' },
294+
"&:focus-within": { borderColor: "var(--ov-accent)" },
209295
}}
210296
>
211-
<SearchIcon sx={{ fontSize: 14, color: 'var(--ov-fg-faint)', mr: 0.5 }} />
297+
<SearchIcon
298+
sx={{ fontSize: 14, color: "var(--ov-fg-faint)", mr: 0.5 }}
299+
/>
212300
<InputBase
213301
autoFocus
214302
fullWidth
215303
placeholder="Search..."
216304
value={search}
217-
onChange={(e) => setSearch(e.target.value)}
305+
onChange={(e) => {
306+
setSearch(e.target.value);
307+
setFocusedIndex(-1);
308+
}}
309+
onKeyDown={handleSearchKeyDown}
218310
sx={{
219311
flex: 1,
220-
fontSize: '0.75rem',
221-
color: 'var(--ov-fg-default)',
222-
'& input': { py: 0, px: 0 },
223-
'& input::placeholder': { color: 'var(--ov-fg-faint)', opacity: 1 },
312+
fontSize: "0.75rem",
313+
color: "var(--ov-fg-default)",
314+
"& input": { py: 0, px: 0 },
315+
"& input::placeholder": {
316+
color: "var(--ov-fg-faint)",
317+
opacity: 1,
318+
},
224319
}}
225320
/>
226321
</Box>
@@ -231,9 +326,21 @@ export default function Select({
231326
<CircularProgress size={16} sx={{ mr: 1 }} /> Loading...
232327
</MenuItem>
233328
)}
234-
{filteredOptions.map((opt) => (
235-
<MenuItem key={opt.value} value={opt.value} disabled={opt.disabled}>
236-
{opt.icon && <ListItemIcon sx={{ minWidth: 28 }}>{opt.icon}</ListItemIcon>}
329+
{filteredOptions.map((opt, idx) => (
330+
<MenuItem
331+
key={opt.value}
332+
value={opt.value}
333+
disabled={opt.disabled}
334+
{...(searchable
335+
? {
336+
onMouseEnter: () => setFocusedIndex(idx),
337+
sx: idx === focusedIndex ? { bgcolor: "action.focus" } : undefined,
338+
}
339+
: {})}
340+
>
341+
{opt.icon && (
342+
<ListItemIcon sx={{ minWidth: 28 }}>{opt.icon}</ListItemIcon>
343+
)}
237344
<ListItemText>{opt.label}</ListItemText>
238345
</MenuItem>
239346
))}
@@ -248,4 +355,4 @@ export default function Select({
248355
);
249356
}
250357

251-
Select.displayName = 'Select';
358+
Select.displayName = "Select";

0 commit comments

Comments
 (0)