contendeditable и tabIndex
Всем привет, давно не виделись :)
Обнаружил странное поведение во всех браузерах (проверял в хроме, фф и сафари) <div id="foo" contenteditable="true">123</div> <script> console.log(foo.tabIndex); // -1 </script> Свойство tabIndex по умолчанию -1, хотя сам элемент фокусируется табом в потоке элементов с tabIndex 0 Но если засетить tabIndex вручную, то он станет работать правильно (фокуса по табу в элемент не будет) console.log(foo.tabIndex); // -1 // работает как будто бы он 0 foo.tabIndex = -1; // или foo.tabIndex = foo.tabIndex; console.log(foo.tabIndex); // -1 // теперь работает как -1 Кто-нибудь может объяснить в чём тут прикол? В гугле ничего не нашел по этому поводу |
Цитата:
Цитата:
|
@ksa
Это да, потому у элементов форм он и так по умолчанию 0: document.createElement('input').tabIndex // 0 Но если явно указать -1, хоть инпуту, хоть диву, то таб работать не будет. и свойство будет возвращать -1 (как и должно быть). Но вот когда div с contenteditable, то он ведет себя как tabindex="0", что, в принципе. логично. Но вот почему-то само свойство возвращает -1. Причем если это свойство явно переопределяешь на тот же самый -1, то начинает работать правильно. В общем, похоже, что какой-то массовый баг всех браузеров, который унаследовали из-за какой-либо обратной совместимости. Это как с typeof null === 'object' |
Цитата:
https://html.spec.whatwg.org/multipa...#attr-tabindex Особенно зеленые примечания. Я так понял, что если не задано значение атрибута, то "пользовательский агент" (браузер) сам решает быть ли элементу фокусируемым и участвовать ли в последовательной навигации (с помощью Tab). Во втором замечании говорится, что даже если tabIndex = -1 то браузер вправе игнорировать требование, что элемент не участвует в последовательной навигации, т.к. это единственный способ добраться до элемента управления, если у пользователя нет мыши. Короче полная хрень и неопределенность с этим tabindex. |
Ну, почему там -1 понятно: у div по умолачнию именно -1 т.к. div не табается.
Потом появилась надстройка в виде contentEditable и оноую сделали табаемой, но значения по умолчанию видать менять не стали. Подозреваю там пришлось бы много переделывать чисто в плане кода, чтоб оно зависело не только от тега но и от атрибута.:) |
Если кому-то нужно, то вот мой костыль:
function getRealTabIndex(element: HTMLElement) { if (element.isContentEditable && element.tabIndex === -1 && !element.hasAttribute('tabindex')) { return 0; } return element.tabIndex; } |
С ссылками (якорями) похожая история, кстати.
Ссылка по умолчанию имеет tabindex 0, но при этом, если отсутствует `href`, то она не табается, не смотря на то, что tabindex все еще 0. Но если явно его переопределить, то все начинает работать правильно. <a id="link" href="">Табается</a> <script> console.log(link.tabIndex); // 0 </script> <a id="link">Не табается</a> <script> console.log(link.tabIndex); // 0 </script> <a id="link">табается</a> <script> console.log(link.tabIndex); // 0 link.tabIndex = link.tabIndex; </script> ------ Угадайте какой tabIndex будет здесь :) <a contenteditable="true">123</a> ------ Финальный костыль: function getRealTabIndex(element: HTMLElement) { // Anchors without the "href" attribute have a default tab index of 0, although they behave like -1. // If this is the case, then treat the tabindex as -1 if ( element instanceof HTMLAnchorElement && !element.hasAttribute('href') && !element.hasAttribute('tabindex') ) { return -1; } // ContentEditable elements have a default tabindex of -1, although they behave like 0. // If this is the case, then treat the tabindex as 0 if (element.isContentEditable && element.tabIndex === -1 && !element.hasAttribute('tabindex')) { return 0; } return element.tabIndex; } |
ruslan_mart, твой костыль не учитывает тобой же приведённый вариант <a contentEditable>.:)
Как-то так оно должно выглядеть: function getRealTabIndex(element: HTMLElement) { if(!element.hasAttribute('tabindex')) { // ContentEditable elements have a default tabindex of -1, although they behave like 0. // If this is the case, then treat the tabindex as 0 if (element.isContentEditable) { return 0; } // Anchors without the "href" attribute have a default tab index of 0, although they behave like -1. // If this is the case, then treat the tabindex as -1 if (element instanceof HTMLAnchorElement && !element.hasAttribute('href')) { return -1; } } return element.tabIndex; } Но чё-то мне кажется что как-то проще оно должно быть... |
|
Aetae, да, упустил, спасибо)
Насчет jquery, глянул, у них там подход немного другой. В jQuery пытаются получить может ли быть элемент фокусируемый, но суть примерно та же самая, да. Вроде как там аналогично проверяют href через hasAttribute. Я тут просто делаю Popover, который можно вешать на любые элементы и настраивать триггер, по которому он будет вызван (click, focus, hover, contextmenu). Сам Popover вставляю порталом в конец body, хотя популярные UI либы вставляют Popover в место вызова, но мне кажется, что это не очень классно, так как может нарушиться семантика и валидность элемента. Поэтому я решил, что Popover будет рисоваться в конце body. Но возник нюанс с фокусом, что когда мы фокусируемся на элемент, к которому привязан Popover, то при табуляции мы не проваливаемся в сам Popover, а идем дальше по дереву фокусируемых элементов DOM, так как наш Popover находится в конце. Что пришло в голову, так это знать о всех фокусируемых элементах на странице, знать текущий фокусируемый элемент, знать следующий фокусируемый элемент, первый и последний фокусируемый элемент в Popover. И тут уже совершать магию :) При потере фокуса табом на текущем элементе, принудительно фокусироваться на первом элементе в Popover, ждать пока мы сфокусируемся на последнем элементе Popover и далее продолжать уже фокусировку по элементу, который был после целевого элемента. Но столкнулся с множеством нюансов. Это то, что у нас могут быть разные tabIndex, contentEditable элементы, элементы форм с disabled состоянием (причем это состояние может быть вызвано родительским fieldset, поэтому это тоже нужно учитывать). Вот такое решение у меня в итоге получилось. const FOCUSABLE_ELEMENTS_SELECTOR = 'a, button, input, select, textarea, details summary, [contenteditable], [tabindex]'; function filterFocusableElements(element: HTMLElement) { return ( getRealTabIndex(element) !== -1 && (!( element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement ) || !element.matches(':disabled')) ); } function getRealTabIndex(element: HTMLElement) { if (!element.hasAttribute('tabindex')) { // ContentEditable elements have a default tabindex of -1, although they behave like 0. // If this is the case, then treat the tabindex as 0 if (element.isContentEditable) { return 0; } // Anchors without the "href" attribute have a default tab index of 0, although they behave like -1. // If this is the case, then treat the tabindex as -1 if (element instanceof HTMLAnchorElement && !element.hasAttribute('href')) { return -1; } } return element.tabIndex; } function sortElementsCallback(elementA: HTMLElement, elementB: HTMLElement) { return getRealTabIndex(elementA) - getRealTabIndex(elementB); } export function getAllFocusableElements(parentElement: Element) { const allElements = parentElement.querySelectorAll( FOCUSABLE_ELEMENTS_SELECTOR ) as NodeListOf<HTMLElement>; return Array.from(allElements).filter(filterFocusableElements).sort(sortElementsCallback); } export function isDisabledElement(element: Element) { if ( element instanceof HTMLButtonElement || element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement ) { // The element may be in a disabled fieldset, so we check through the selector return element.matches(':disabled'); } return false; } import { useContext, useEffect, useRef } from 'react'; import { PrivateContext } from '../context'; import { getAllFocusableElements } from '../utils/getAllFocusableElements'; interface ContentFocusOptions { enabled: boolean; } export function useContentFocus(options: ContentFocusOptions) { const { enabled } = options; const { contentWrapperRef, targetContainerRef } = useContext(PrivateContext)!; const contentFocusingRef = useRef(false); useEffect(() => { if (!enabled) { return undefined; } const handleWindowKeyDown = (event: KeyboardEvent) => { if (event.key === 'Tab') { const contentWrapperElement = contentWrapperRef.current; const targetContainerElement = targetContainerRef.current; if (contentWrapperElement !== null && targetContainerElement !== null) { if (!contentFocusingRef.current) { const targetFocusableElements = getAllFocusableElements(targetContainerElement); if ( targetFocusableElements.length === 0 || targetFocusableElements.pop() === document.activeElement ) { const contentFocusableElements = getAllFocusableElements(contentWrapperElement); if (contentFocusableElements.length > 0) { contentFocusableElements[0]!.focus(); contentFocusingRef.current = true; event.preventDefault(); } } } else { const contentFocusableElements = getAllFocusableElements(contentWrapperElement); if (contentFocusableElements.pop() === document.activeElement) { const targetFocusableElements = getAllFocusableElements(targetContainerElement); if (targetFocusableElements.length > 0) { const documentFocusableElements = getAllFocusableElements(document.body); const targetLastFocusableElement = targetFocusableElements.pop()!; const targetLastFocusableIndex = documentFocusableElements.indexOf( targetLastFocusableElement ); const nextFocusableIndex = (targetLastFocusableIndex + 1) % documentFocusableElements.length; const nextFocusableElement = documentFocusableElements[nextFocusableIndex]; nextFocusableElement!.focus(); event.preventDefault(); } } } } } }; window.addEventListener('keydown', handleWindowKeyDown); return () => { contentFocusingRef.current = false; window.removeEventListener('keydown', handleWindowKeyDown); }; }, [enabled]); } Это все работает, но пока все-равно подумываю над улучшениями. |
Я в свое время где то надыбал такой селектор для поиска фокусируемых элементов
const sfocusable = [ 'a[href]:not([tabindex^="-"]):not([inert])', 'area[href]:not([tabindex^="-"]):not([inert])', 'input:not([disabled]):not([inert])', 'select:not([disabled]):not([inert])', 'textarea:not([disabled]):not([inert])', 'button:not([disabled]):not([inert])', 'iframe:not([tabindex^="-"]):not([inert])', 'audio:not([tabindex^="-"]):not([inert])', 'video:not([tabindex^="-"]):not([inert])', '[contenteditable]:not([tabindex^="-"]):not([inert])', '[tabindex]:not([tabindex^="-"]):not([inert])', ].join(','); |
voraa, спасибо
Про inert не знал Но кажется, что это не совсем правильный селектор. Здесь как минимум некорректно проверять наличие атрибута disabled у элементов форм, потому что он может отсутствовать, но при этом состояние наследоваться от fieldset. Про inert прочитал, он также может быть выставлен у родителя, чего здесь не учитывается :) Вот такой селектор сделал: const sfocusable = ':where(:any-link, :read-write, audio, button, input, select, video, details summary, [tabindex]):not(:disabled):not([tabindex^="-"])'; Но кажется, что селектором не получится исключить родительский inert без извращений. Нужно по скорости проверить, что будет быстрее, селектор попроще + проверки на JS, или просто один сложный селектор. P.S.: селектор :read-write покрывает сразу кейс с contenteditable и textarea |
Тут еще одна затыка может быть. Я не уверен, что алгоритм сортировки оставит элементы с одинаковым tabIndex в той же последовательности, какие они имели до сортировки.
|
У меня все правильно работает:
const elements = [ [-1, 'A'], [0, 'C'], [-1, 'B'], [0, 'D'], [0, 'E'], [1, 'F'], [2, 'G'], ]; const sorted = elements.toSorted((elementA, elementB) => { return elementA[0] - elementB[0]; }); console.log(sorted); Или ты что-то другое имеешь ввиду? |
ruslan_mart, на это полагаться никак нельзя. Алгоритм может произвольно меняться от реализации к реализации и от браузера к браузеру. Он гарантирует только сортировку и ничего более.
|
Цитата:
|
Aetae, ох, ладно, свою сортировку значит напишу.
Спасибо всем! |
Ну прям свою сортировку это перебор, достаточно сделать как-то так :) :
const elements = [ [-1, 'A'], [0, 'C'], [-1, 'B'], [0, 'D'], [0, 'E'], [1, 'F'], [2, 'G'], ]; const sorted = elements .map((arr, index) => [index, ...arr]) .sort((elementA, elementB) => { return elementA[1] - elementB[1] || elementA[0] - elementB[0]; }) .map((arr) => arr.slice(1)); console.log(sorted); |
Спасибо, поэкспериментирую.
Меня напрягает, что алгоритмическая сложность начинает расти. Все-таки элементов может быть достаточно много. Сам по себе sort работает не быстро O(n log n), и если еще дополнительные 2 мэпа и внутри каждого создается новый массив через slice. Как-то сложно получается. :) В общем, нужно подумать, может одним reduce попробую решить. |
Ну по сути он так и останется O(n log n), лишние n же не считаются.=)
А так тебе просто надо изначально формировать свой массив elements с индексами и всего делов. |
Часовой пояс GMT +3, время: 05:05. |