refactor(ui): extract shared SettingsSelect; replace raw <select> (native-cinny audit 6/N)
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a shared components/settings-select/SettingsSelect.tsx, and used it to replace raw native <select> elements (which render OS-styled and broke under non-default themes via colorScheme:'dark'): - Profile "auto-clear after" select - PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange) The form-tied timezone <select> in Profile is left for a follow-up (it's wired to native form submission + a disabled state and needs more care). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
export type SettingsSelectOption<T extends string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
|
||||
* pattern — used instead of a raw `<select>`, which renders OS-styled and
|
||||
* breaks under non-default themes.
|
||||
*/
|
||||
export function SettingsSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
'aria-label': ariaLabel,
|
||||
}: {
|
||||
value: T;
|
||||
options: SettingsSelectOption<T>[];
|
||||
onChange: (v: T) => void;
|
||||
'aria-label'?: string;
|
||||
}) {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (v: T) => {
|
||||
onChange(v);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
aria-label={ariaLabel}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!menuCords}
|
||||
>
|
||||
<Text size="T300">{selectedLabel}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{options.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.value}
|
||||
size="300"
|
||||
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
disabled={opt.disabled}
|
||||
onClick={() => !opt.disabled && handleSelect(opt.value)}
|
||||
>
|
||||
<Text size="T300">{opt.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user