picker.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import DropdownIcon from '../assets/icons/dropdown.svg';
  2. let optionsCounter = 0;
  3. function toggleAriaAttribute(element, attribute) {
  4. element.setAttribute(
  5. attribute,
  6. !(element.getAttribute(attribute) === 'true'),
  7. );
  8. }
  9. class Picker {
  10. constructor(select) {
  11. this.select = select;
  12. this.container = document.createElement('span');
  13. this.buildPicker();
  14. this.select.style.display = 'none';
  15. this.select.parentNode.insertBefore(this.container, this.select);
  16. this.label.addEventListener('mousedown', () => {
  17. this.togglePicker();
  18. });
  19. this.label.addEventListener('keydown', event => {
  20. switch (event.key) {
  21. case 'Enter':
  22. this.togglePicker();
  23. break;
  24. case 'Escape':
  25. this.escape();
  26. event.preventDefault();
  27. break;
  28. default:
  29. }
  30. });
  31. this.select.addEventListener('change', this.update.bind(this));
  32. }
  33. togglePicker() {
  34. this.container.classList.toggle('ql-expanded');
  35. // Toggle aria-expanded and aria-hidden to make the picker accessible
  36. toggleAriaAttribute(this.label, 'aria-expanded');
  37. toggleAriaAttribute(this.options, 'aria-hidden');
  38. }
  39. buildItem(option) {
  40. const item = document.createElement('span');
  41. item.tabIndex = '0';
  42. item.setAttribute('role', 'button');
  43. item.classList.add('ql-picker-item');
  44. if (option.hasAttribute('value')) {
  45. item.setAttribute('data-value', option.getAttribute('value'));
  46. }
  47. if (option.textContent) {
  48. item.setAttribute('data-label', option.textContent);
  49. }
  50. item.addEventListener('click', () => {
  51. this.selectItem(item, true);
  52. });
  53. item.addEventListener('keydown', event => {
  54. switch (event.key) {
  55. case 'Enter':
  56. this.selectItem(item, true);
  57. event.preventDefault();
  58. break;
  59. case 'Escape':
  60. this.escape();
  61. event.preventDefault();
  62. break;
  63. default:
  64. }
  65. });
  66. return item;
  67. }
  68. buildLabel() {
  69. const label = document.createElement('span');
  70. label.classList.add('ql-picker-label');
  71. label.innerHTML = DropdownIcon;
  72. label.tabIndex = '0';
  73. label.setAttribute('role', 'button');
  74. label.setAttribute('aria-expanded', 'false');
  75. this.container.appendChild(label);
  76. return label;
  77. }
  78. buildOptions() {
  79. const options = document.createElement('span');
  80. options.classList.add('ql-picker-options');
  81. // Don't want screen readers to read this until options are visible
  82. options.setAttribute('aria-hidden', 'true');
  83. options.tabIndex = '-1';
  84. // Need a unique id for aria-controls
  85. options.id = `ql-picker-options-${optionsCounter}`;
  86. optionsCounter += 1;
  87. this.label.setAttribute('aria-controls', options.id);
  88. this.options = options;
  89. Array.from(this.select.options).forEach(option => {
  90. const item = this.buildItem(option);
  91. options.appendChild(item);
  92. if (option.selected === true) {
  93. this.selectItem(item);
  94. }
  95. });
  96. this.container.appendChild(options);
  97. }
  98. buildPicker() {
  99. Array.from(this.select.attributes).forEach(item => {
  100. this.container.setAttribute(item.name, item.value);
  101. });
  102. this.container.classList.add('ql-picker');
  103. this.label = this.buildLabel();
  104. this.buildOptions();
  105. }
  106. escape() {
  107. // Close menu and return focus to trigger label
  108. this.close();
  109. // Need setTimeout for accessibility to ensure that the browser executes
  110. // focus on the next process thread and after any DOM content changes
  111. setTimeout(() => this.label.focus(), 1);
  112. }
  113. close() {
  114. this.container.classList.remove('ql-expanded');
  115. this.label.setAttribute('aria-expanded', 'false');
  116. this.options.setAttribute('aria-hidden', 'true');
  117. }
  118. selectItem(item, trigger = false) {
  119. const selected = this.container.querySelector('.ql-selected');
  120. if (item === selected) return;
  121. if (selected != null) {
  122. selected.classList.remove('ql-selected');
  123. }
  124. if (item == null) return;
  125. item.classList.add('ql-selected');
  126. this.select.selectedIndex = Array.from(item.parentNode.children).indexOf(
  127. item,
  128. );
  129. if (item.hasAttribute('data-value')) {
  130. this.label.setAttribute('data-value', item.getAttribute('data-value'));
  131. } else {
  132. this.label.removeAttribute('data-value');
  133. }
  134. if (item.hasAttribute('data-label')) {
  135. this.label.setAttribute('data-label', item.getAttribute('data-label'));
  136. } else {
  137. this.label.removeAttribute('data-label');
  138. }
  139. if (trigger) {
  140. this.select.dispatchEvent(new Event('change'));
  141. this.close();
  142. }
  143. }
  144. update() {
  145. let option;
  146. if (this.select.selectedIndex > -1) {
  147. const item = this.container.querySelector('.ql-picker-options').children[
  148. this.select.selectedIndex
  149. ];
  150. option = this.select.options[this.select.selectedIndex];
  151. this.selectItem(item);
  152. } else {
  153. this.selectItem(null);
  154. }
  155. const isActive =
  156. option != null &&
  157. option !== this.select.querySelector('option[selected]');
  158. this.label.classList.toggle('ql-active', isActive);
  159. }
  160. }
  161. export default Picker;