IconPicker.tsx 5.4 KB

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