Javascript-форум (https://javascript.ru/forum/)
-   Общие вопросы Javascript (https://javascript.ru/forum/misc/)
-   -   contendeditable и tabIndex (https://javascript.ru/forum/misc/85519-contendeditable-i-tabindex.html)

ruslan_mart 02.10.2023 19:10

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 03.10.2023 14:38

Цитата:

Сообщение от ruslan_mart
Свойство tabIndex по умолчанию -1, хотя сам элемент фокусируется табом в потоке элементов с tabIndex 0

Но если засетить tabIndex вручную, то он станет работать правильно (фокуса по табу в элемент не будет)

Такое вот нашел...
Цитата:

Переход к элементам, у которых не задан атрибут tabindex или его значение равно 0, происходит после всех «нумерованных» элементов в том порядке, как они указаны в коде.
https://htmlbook.ru/html/attr/tabindex

ruslan_mart 04.10.2023 19:00

@ksa

Это да, потому у элементов форм он и так по умолчанию 0:


document.createElement('input').tabIndex // 0



Но если явно указать -1, хоть инпуту, хоть диву, то таб работать не будет.
и свойство будет возвращать -1 (как и должно быть).

Но вот когда div с contenteditable, то он ведет себя как tabindex="0", что, в принципе. логично. Но вот почему-то само свойство возвращает -1. Причем если это свойство явно переопределяешь на тот же самый -1, то начинает работать правильно.

В общем, похоже, что какой-то массовый баг всех браузеров, который унаследовали из-за какой-либо обратной совместимости. Это как с typeof null === 'object'

voraa 04.10.2023 21:10

Цитата:

Сообщение от ruslan_mart
В общем, похоже, что какой-то массовый баг всех браузеров,

Смотрим спеку на HTML
https://html.spec.whatwg.org/multipa...#attr-tabindex
Особенно зеленые примечания.
Я так понял, что если не задано значение атрибута, то "пользовательский агент" (браузер) сам решает быть ли элементу фокусируемым и участвовать ли в последовательной навигации (с помощью Tab).
Во втором замечании говорится, что даже если tabIndex = -1 то браузер вправе игнорировать требование, что элемент не участвует в последовательной навигации, т.к. это единственный способ добраться до элемента управления, если у пользователя нет мыши.

Короче полная хрень и неопределенность с этим tabindex.

Aetae 05.10.2023 00:06

Ну, почему там -1 понятно: у div по умолачнию именно -1 т.к. div не табается.
Потом появилась надстройка в виде contentEditable и оноую сделали табаемой, но значения по умолчанию видать менять не стали. Подозреваю там пришлось бы много переделывать чисто в плане кода, чтоб оно зависело не только от тега но и от атрибута.:)

ruslan_mart 05.10.2023 16:56

Если кому-то нужно, то вот мой костыль:

function getRealTabIndex(element: HTMLElement) {
  if (element.isContentEditable && element.tabIndex === -1 && !element.hasAttribute('tabindex')) {
    return 0;
  }

  return element.tabIndex;
}

ruslan_mart 08.10.2023 16:40

С ссылками (якорями) похожая история, кстати.

Ссылка по умолчанию имеет 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;
}

Aetae 09.10.2023 00:20

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 09.10.2023 00:27

В jquery ui есть псевдоселектор :tabbable, т.к. либа древняя там должны были бы учесть все нюансы, можно посмотреть на их код и адапртировакт к себе: tabbable < focusable < visible. :)

ruslan_mart 11.10.2023 11:59

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]);
}


Это все работает, но пока все-равно подумываю над улучшениями.

voraa 11.10.2023 13:42

Я в свое время где то надыбал такой селектор для поиска фокусируемых элементов
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(',');

ruslan_mart 11.10.2023 14:04

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

voraa 11.10.2023 14:18

Тут еще одна затыка может быть. Я не уверен, что алгоритм сортировки оставит элементы с одинаковым tabIndex в той же последовательности, какие они имели до сортировки.

ruslan_mart 11.10.2023 17:17

У меня все правильно работает:

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);


Или ты что-то другое имеешь ввиду?

Aetae 11.10.2023 18:21

ruslan_mart, на это полагаться никак нельзя. Алгоритм может произвольно меняться от реализации к реализации и от браузера к браузеру. Он гарантирует только сортировку и ничего более.

voraa 11.10.2023 18:30

Цитата:

Сообщение от ruslan_mart
У меня все правильно работает:

Ну хорошо если так всегда будет.

ruslan_mart 12.10.2023 18:15

Aetae, ох, ладно, свою сортировку значит напишу.

Спасибо всем!

Aetae 12.10.2023 21:20

Ну прям свою сортировку это перебор, достаточно сделать как-то так :) :
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);

ruslan_mart 12.10.2023 23:45

Спасибо, поэкспериментирую.

Меня напрягает, что алгоритмическая сложность начинает расти. Все-таки элементов может быть достаточно много. Сам по себе sort работает не быстро O(n log n), и если еще дополнительные 2 мэпа и внутри каждого создается новый массив через slice. Как-то сложно получается. :)

В общем, нужно подумать, может одним reduce попробую решить.

Aetae 13.10.2023 00:20

Ну по сути он так и останется O(n log n), лишние n же не считаются.=)
А так тебе просто надо изначально формировать свой массив elements с индексами и всего делов.


Часовой пояс GMT +3, время: 05:05.