Select.jsx 3.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
  1. import { h, Fragment } from 'preact';
  2. import ArrowDropdown from '../icons/ArrowDropdown';
  3. import ArrowDropup from '../icons/ArrowDropup';
  4. import Menu, { MenuItem } from './Menu';
  5. import TextField from './TextField';
  6. import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
  7. export default function Select({ label, onChange, options: inputOptions = [], selected: propSelected }) {
  8. const options = useMemo(
  9. () =>
  10. typeof inputOptions[0] === 'string' ? inputOptions.map((opt) => ({ value: opt, label: opt })) : inputOptions,
  11. [inputOptions]
  12. );
  13. const [showMenu, setShowMenu] = useState(false);
  14. const [selected, setSelected] = useState(
  15. Math.max(
  16. options.findIndex(({ value }) => value === propSelected),
  17. 0
  18. )
  19. );
  20. const [focused, setFocused] = useState(null);
  21. const ref = useRef(null);
  22. const handleSelect = useCallback(
  23. (value, label) => {
  24. setSelected(options.findIndex((opt) => opt.value === value));
  25. onChange && onChange(value, label);
  26. setShowMenu(false);
  27. },
  28. [onChange, options]
  29. );
  30. const handleClick = useCallback(() => {
  31. setShowMenu(true);
  32. }, [setShowMenu]);
  33. const handleKeydown = useCallback(
  34. (event) => {
  35. switch (event.key) {
  36. case 'Enter': {
  37. if (!showMenu) {
  38. setShowMenu(true);
  39. setFocused(selected);
  40. } else {
  41. setSelected(focused);
  42. onChange && onChange(options[focused].value, options[focused].label);
  43. setShowMenu(false);
  44. }
  45. break;
  46. }
  47. case 'ArrowDown': {
  48. const newIndex = focused + 1;
  49. newIndex < options.length && setFocused(newIndex);
  50. break;
  51. }
  52. case 'ArrowUp': {
  53. const newIndex = focused - 1;
  54. newIndex > -1 && setFocused(newIndex);
  55. break;
  56. }
  57. // no default
  58. }
  59. },
  60. [onChange, options, showMenu, setShowMenu, setFocused, focused, selected]
  61. );
  62. const handleDismiss = useCallback(() => {
  63. setShowMenu(false);
  64. }, [setShowMenu]);
  65. // Reset the state if the prop value changes
  66. useEffect(() => {
  67. const selectedIndex = Math.max(
  68. options.findIndex(({ value }) => value === propSelected),
  69. 0
  70. );
  71. if (propSelected && selectedIndex !== selected) {
  72. setSelected(selectedIndex);
  73. setFocused(selectedIndex);
  74. }
  75. // DO NOT include `selected`
  76. }, [options, propSelected]); // eslint-disable-line react-hooks/exhaustive-deps
  77. return (
  78. <Fragment>
  79. <TextField
  80. inputRef={ref}
  81. label={label}
  82. onchange={onChange}
  83. onclick={handleClick}
  84. onkeydown={handleKeydown}
  85. readonly
  86. trailingIcon={showMenu ? ArrowDropup : ArrowDropdown}
  87. value={options[selected]?.label}
  88. />
  89. {showMenu ? (
  90. <Menu className="rounded-t-none" onDismiss={handleDismiss} relativeTo={ref} widthRelative>
  91. {options.map(({ value, label }, i) => (
  92. <MenuItem key={value} label={label} focus={focused === i} onSelect={handleSelect} value={value} />
  93. ))}
  94. </Menu>
  95. ) : null}
  96. </Fragment>
  97. );
  98. }