IconPicker.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. import React from "react";
  2. import { Popover } from "./Popover";
  3. import "./IconPicker.scss";
  4. import { isArrowKey, KEYS } from "../keys";
  5. import { getLanguage } from "../i18n";
  6. function Picker<T>({
  7. options,
  8. value,
  9. label,
  10. onChange,
  11. onClose,
  12. }: {
  13. label: string;
  14. value: T;
  15. options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
  16. onChange: (value: T) => void;
  17. onClose: () => void;
  18. }) {
  19. const rFirstItem = React.useRef<HTMLButtonElement>();
  20. const rActiveItem = React.useRef<HTMLButtonElement>();
  21. const rGallery = React.useRef<HTMLDivElement>(null);
  22. React.useEffect(() => {
  23. // After the component is first mounted focus on first input
  24. if (rActiveItem.current) {
  25. rActiveItem.current.focus();
  26. } else if (rGallery.current) {
  27. rGallery.current.focus();
  28. }
  29. }, []);
  30. const handleKeyDown = (event: React.KeyboardEvent) => {
  31. const pressedOption = options.find(
  32. (option) => option.keyBinding === event.key.toLowerCase(),
  33. )!;
  34. if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
  35. // Keybinding navigation
  36. const index = options.indexOf(pressedOption);
  37. (rGallery!.current!.children![index] as any).focus();
  38. event.preventDefault();
  39. } else if (event.key === KEYS.TAB) {
  40. // Tab navigation cycle through options. If the user tabs
  41. // away from the picker, close the picker. We need to use
  42. // a timeout here to let the stack clear before checking.
  43. setTimeout(() => {
  44. const active = rActiveItem.current;
  45. const docActive = document.activeElement;
  46. if (active !== docActive) {
  47. onClose();
  48. }
  49. }, 0);
  50. } else if (isArrowKey(event.key)) {
  51. // Arrow navigation
  52. const { activeElement } = document;
  53. const isRTL = getLanguage().rtl;
  54. const index = Array.prototype.indexOf.call(
  55. rGallery!.current!.children,
  56. activeElement,
  57. );
  58. if (index !== -1) {
  59. const length = options.length;
  60. let nextIndex = index;
  61. switch (event.key) {
  62. // Select the next option
  63. case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
  64. case KEYS.ARROW_DOWN: {
  65. nextIndex = (index + 1) % length;
  66. break;
  67. }
  68. // Select the previous option
  69. case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
  70. case KEYS.ARROW_UP: {
  71. nextIndex = (length + index - 1) % length;
  72. break;
  73. }
  74. }
  75. (rGallery.current!.children![nextIndex] as any).focus();
  76. }
  77. event.preventDefault();
  78. } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
  79. // Close on escape or enter
  80. event.preventDefault();
  81. onClose();
  82. }
  83. event.nativeEvent.stopImmediatePropagation();
  84. event.stopPropagation();
  85. };
  86. return (
  87. <div
  88. className={`picker`}
  89. role="dialog"
  90. aria-modal="true"
  91. aria-label={label}
  92. onKeyDown={handleKeyDown}
  93. >
  94. <div className="picker-content" ref={rGallery}>
  95. {options.map((option, i) => (
  96. <button
  97. className="picker-option"
  98. onClick={(event) => {
  99. (event.currentTarget as HTMLButtonElement).focus();
  100. onChange(option.value);
  101. }}
  102. title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
  103. aria-label={option.text || "none"}
  104. aria-keyshortcuts={option.keyBinding}
  105. key={option.text}
  106. ref={(el) => {
  107. if (el && i === 0) {
  108. rFirstItem.current = el;
  109. }
  110. if (el && option.value === value) {
  111. rActiveItem.current = el;
  112. }
  113. }}
  114. onFocus={() => {
  115. onChange(option.value);
  116. }}
  117. >
  118. {option.icon}
  119. <span className="picker-keybinding">{option.keyBinding}</span>
  120. </button>
  121. ))}
  122. </div>
  123. </div>
  124. );
  125. }
  126. export function IconPicker<T>({
  127. value,
  128. label,
  129. options,
  130. onChange,
  131. group = "",
  132. }: {
  133. label: string;
  134. value: T;
  135. options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
  136. onChange: (value: T) => void;
  137. group?: string;
  138. }) {
  139. const [isActive, setActive] = React.useState(false);
  140. const rPickerButton = React.useRef<any>(null);
  141. const isRTL = getLanguage().rtl;
  142. return (
  143. <label className={"picker-container"}>
  144. <button
  145. name={group}
  146. className={isActive ? "active" : ""}
  147. aria-label={label}
  148. onClick={() => setActive(!isActive)}
  149. ref={rPickerButton}
  150. >
  151. {options.find((option) => option.value === value)?.icon}
  152. </button>
  153. <React.Suspense fallback="">
  154. {isActive ? (
  155. <>
  156. <Popover
  157. onCloseRequest={(event) =>
  158. event.target !== rPickerButton.current && setActive(false)
  159. }
  160. {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
  161. >
  162. <Picker
  163. options={options}
  164. value={value}
  165. label={label}
  166. onChange={onChange}
  167. onClose={() => {
  168. setActive(false);
  169. rPickerButton.current?.focus();
  170. }}
  171. />
  172. </Popover>
  173. <div className="picker-triangle" />
  174. </>
  175. ) : null}
  176. </React.Suspense>
  177. </label>
  178. );
  179. }