IconPicker.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. };
  85. return (
  86. <div
  87. className={`picker`}
  88. role="dialog"
  89. aria-modal="true"
  90. aria-label={label}
  91. onKeyDown={handleKeyDown}
  92. >
  93. <div className="picker-content" ref={rGallery}>
  94. {options.map((option, i) => (
  95. <button
  96. className="picker-option"
  97. onClick={(event) => {
  98. (event.currentTarget as HTMLButtonElement).focus();
  99. onChange(option.value);
  100. }}
  101. title={`${option.text} — ${option.keyBinding.toUpperCase()}`}
  102. aria-label={option.text || "none"}
  103. aria-keyshortcuts={option.keyBinding}
  104. key={option.text}
  105. ref={(el) => {
  106. if (el && i === 0) {
  107. rFirstItem.current = el;
  108. }
  109. if (el && option.value === value) {
  110. rActiveItem.current = el;
  111. }
  112. }}
  113. onFocus={() => {
  114. onChange(option.value);
  115. }}
  116. >
  117. {option.icon}
  118. <span className="picker-keybinding">{option.keyBinding}</span>
  119. </button>
  120. ))}
  121. </div>
  122. </div>
  123. );
  124. }
  125. export function IconPicker<T>({
  126. value,
  127. label,
  128. options,
  129. onChange,
  130. group = "",
  131. }: {
  132. label: string;
  133. value: T;
  134. options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[];
  135. onChange: (value: T) => void;
  136. group?: string;
  137. }) {
  138. const [isActive, setActive] = React.useState(false);
  139. const rPickerButton = React.useRef<any>(null);
  140. const isRTL = getLanguage().rtl;
  141. return (
  142. <label className={"picker-container"}>
  143. <button
  144. name={group}
  145. className={isActive ? "active" : ""}
  146. aria-label={label}
  147. onClick={() => setActive(!isActive)}
  148. ref={rPickerButton}
  149. >
  150. {options.find((option) => option.value === value)?.icon}
  151. </button>
  152. <React.Suspense fallback="">
  153. {isActive ? (
  154. <>
  155. <Popover
  156. onCloseRequest={(event) =>
  157. event.target !== rPickerButton.current && setActive(false)
  158. }
  159. {...(isRTL ? { right: 5.5 } : { left: -5.5 })}
  160. >
  161. <Picker
  162. options={options}
  163. value={value}
  164. label={label}
  165. onChange={onChange}
  166. onClose={() => {
  167. setActive(false);
  168. rPickerButton.current?.focus();
  169. }}
  170. />
  171. </Popover>
  172. <div className="picker-triangle" />
  173. </>
  174. ) : null}
  175. </React.Suspense>
  176. </label>
  177. );
  178. }