如何设置 HTML 元素的 tabindex,使其对应于 Tab 键顺序中的下一个元素?

How can I set the tabindex of an HTML element so that it corresponds to the next element in the tab order?

提问人:Oscar R 提问时间:11/18/2023 最后编辑:Oscar R 更新时间:11/18/2023 访问量:76

问:

在下面的代码中,您可以看到一个简化的表工作示例,其中每行都有一个选项按钮,该按钮会触发包含更多按钮的菜单。但是,当您尝试按 Tab 键浏览可聚焦元素时,您会看到尽管打开了菜单,但在进入菜单之前,您仍然需要按 Tab 键浏览所有行。

我想要的结果是,例如,如果您将“选项”按钮集中在第 1 行,然后继续打开它,则下一次按下选项卡应该会将您带入菜单的第一个按钮。然后,当您按 Tab 键浏览菜单的所有按钮时,您应该会回到第 2 行的“选项”按钮。

如何实现此目的?

(此外,我无法删除溢出:从表中隐藏,这就是为什么我首先在表元素之外使用绝对定位的菜单)

const menu = document.getElementById("menu");
const table = document.getElementById("table");
const option_buttons = Array.from(table.querySelectorAll("button"));

table.addEventListener("click", (e) => {
  if (option_buttons.includes(e.target)) {
    e.stopPropagation();

    menu.style.top = e.target.getBoundingClientRect().top + "px";
    menu.style.display = "flex";

    document.documentElement.addEventListener("click", (e) => {
      if (e.target !== menu && !menu.contains(e.target)) {
        menu.style.display = "none";
      }
    });
  }
});
.table {
  overflow: hidden;
}

.row {
  display: flex;
  gap: 2rem;
  padding: 0.5rem 0;
  border-bottom: 0.0625rem solid #ccc;
}

.menu {
  display: none;
  position: absolute;
  left: 11rem;
  padding: 0.5rem;
  background: #ccc;
  flex-direction: column;
  gap: 0.5rem;
}

.menu::before {
  content: '';
  position: absolute;
  top: 0;
  left: -0.75rem;
  width: 0;
  height: 0;
  border-top: 0.75rem solid transparent;
  border-bottom: 0.75rem solid transparent;
  border-right: 0.75rem solid #ccc;
}

button:focus {
  background: skyblue;
}
<div class="table" id="table">
  <div class="row">
    <span>Item 1</span>
    <button class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 2</span>
    <button class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 3</span>
    <button class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 4</span>
    <button class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 5</span>
    <button class="btn_option" type="button">Options</button>
  </div>
</div>

<div class="menu" id="menu">
  <button type="button">View</button>
  <button type="button">Edit</button>
  <button type="button">Delete</button>
</div>

JavaScript 的HTML

评论

1赞 Heretic Monkey 11/18/2023
当菜单打开时,调用第一个菜单项?我建议不要惹;那条路就是疯狂。.focus()tabindex
0赞 Oscar R 11/18/2023
@HereticMonkey 使用此解决方案,如果您按 Tab 键浏览菜单,不幸的是,您不会进入下一行。同样的问题,如果你尝试向后跳动,你所在的行会丢失。
1赞 Heretic Monkey 11/18/2023
您确实需要像在模式对话框中一样将焦点捕获到菜单上(如如何在 Javascript 中将焦点捕获到弹出窗口?)
0赞 Oscar R 11/18/2023
比我发布的答案更简单,也许更好的方法是使用此技巧来允许菜单溢出表格,尽管表格使用 然后,您可以在当前聚焦行的末尾插入菜单的 HTML(定位它将与以前相同)。唯一需要注意的是,您的行不能用于此方法的工作。overflow: hidden;position: relative;

答:

0赞 Oscar R 11/18/2023 #1

我终于在这个解决方案中实现了我在帖子中概述的一切。我在每行的开头/结尾 + 菜单的开头/结尾使用“焦点管理器”来手动传递焦点。

唯一需要注意的是,它要求您手动将数据属性添加到:

  • 表格前后的第一个可聚焦元素
  • 所有行中的第一个和最后一个可聚焦元素
  • 菜单中的第一个和最后一个可聚焦元素

除此之外,我唯一担心的是使用不可见按钮可能是 SEO/可访问性问题(我不知道)

PS:此解决方案还假设您不需要行本身是可聚焦的。

class Table {
  constructor(elm_table, elm_menu, elm_table_prev_focusable, elm_table_next_focusable) {
    if (!elm_table || !elm_menu || !elm_table_prev_focusable || !elm_table_next_focusable) return;
    this.elm_table = elm_table;
    this.elm_menu = elm_menu;
    this.elm_table_prev_focusable = elm_table_prev_focusable;
    this.elm_table_next_focusable = elm_table_next_focusable;
    
    this.table_option_buttons = Array.from(this.elm_table.querySelectorAll(".btn_option"));
    
    this.row_prev_focus_manager = Object.assign(document.createElement('button'), { type: 'button', id: 'row_prev_focus_manager', className: 'focus_manager' });
    this.row_next_focus_manager = Object.assign(document.createElement('button'), { type: 'button', id: 'row_next_focus_manager', className: 'focus_manager' });
    
    this.menu_prev_focus_manager = this.elm_menu.querySelector("#menu_prev_focus_manager");
    this.menu_next_focus_manager = this.elm_menu.querySelector("#menu_next_focus_manager");
    
    this.menu_first_focusable_elm = this.elm_menu.querySelector("[data-menu-first-focusable]");
    this.menu_last_focusable_elm = this.elm_menu.querySelector("[data-menu-last-focusable]");
    
    this.elm_table.addEventListener("focusin", (e) => {
      const parent_row = this.get_parent_row(e.target);
      if (parent_row) {
        this.set_row_as_active(parent_row);
      }
    });
    
    this.elm_table.addEventListener("focusout", (e) => {
      const old_parent_row = this.get_parent_row(e.target);
      const cur_parent_row = this.get_parent_row(e.relatedTarget);
      if (old_parent_row && old_parent_row !== cur_parent_row) {
        this.set_row_as_inactive(old_parent_row);
      }
    });
    
    this.row_prev_focus_manager.addEventListener("focus", (e) => {
      const target_row = this.get_parent_row(e.target);
      const prev_row = target_row.previousElementSibling;
      if (this.elm_menu.is_open && this.elm_menu.target_row === prev_row) {
        this.menu_last_focusable_elm.focus();
      } else {
        if (prev_row) {
          this.get_row_last_focusable(prev_row).focus();
        } else {
          this.elm_table_prev_focusable.focus();
        }
      }
    });
    
    this.row_next_focus_manager.addEventListener("focus", (e) => {
      const target_row = this.get_parent_row(e.target);
      if (this.elm_menu.is_open && this.elm_menu.target_row === target_row) {
        this.menu_first_focusable_elm.focus();
      } else {
        const next_row = target_row.nextElementSibling;
        if (next_row) {
          this.get_row_first_focusable(next_row).focus();
        } else {
          this.elm_table_next_focusable.focus();
        }
      }
    });
    
    this.menu_prev_focus_manager.addEventListener("focus", (e) => {
      this.get_row_last_focusable(this.elm_menu.target_row).focus();
    });
    
    this.menu_next_focus_manager.addEventListener("focus", (e) => {
      if (e.relatedTarget === this.elm_table_next_focusable) {
        this.menu_last_focusable_elm.focus();
        return;
      }
      const next_row = this.elm_menu.target_row.nextElementSibling;
      if (next_row) {
        this.get_row_first_focusable(next_row).focus();
      } else {
        this.elm_table_next_focusable.focus();
      }
    });
    
    this.elm_table.addEventListener("click", (e) => {
      if (this.table_option_buttons.includes(e.target)) {
        e.stopPropagation();

        this.elm_menu.style.top = e.target.getBoundingClientRect().top + "px";
        this.elm_menu.style.display = "flex";
        this.elm_menu.is_open = true;
        this.elm_menu.target_row = this.get_parent_row(e.target);

        document.documentElement.addEventListener("click", (e) => {
          if (e.target !== this.elm_menu && !this.elm_menu.contains(e.target)) {
            this.elm_menu.style.display = "none";
            this.elm_menu.is_open = false;
            this.elm_menu.target_row = null;
          }
        });
      }
    });
    
  }
  
  get_parent_row(elm) {
    return !elm ? null : elm.closest(".row");
  }
    
  get_row_first_focusable(row) {
    return row.querySelector("[data-row-first-focusable]");
  }
  
  get_row_last_focusable(row) {
    return row.querySelector("[data-row-last-focusable]");
  }
  
  set_row_as_active(row) {
    if (!row.is_active) {
      row.is_active = true;
      row.insertBefore(this.row_prev_focus_manager, row.firstChild);
      row.appendChild(this.row_next_focus_manager);
    }
  }
  
  set_row_as_inactive(row) {
    row.is_active = false;
    if (row.contains(this.row_prev_focus_manager) && row.contains(this.row_next_focus_manager)) {
      row.removeChild(this.row_prev_focus_manager);
      row.removeChild(this.row_next_focus_manager);
    }
  }
}

const my_table = new Table(
  document.getElementById("table"),
  document.getElementById("menu"),
  document.querySelector("[data-table-prev-focusable]"),
  document.querySelector("[data-table-next-focusable]"),
);
.table {
  overflow: hidden;
}
.table > .row:first-child {
  border-top: 0.0625rem solid #ccc;
}
.table > .row {
  display: flex;
  gap: 2rem;
  padding: 0.5rem 0;
  border-bottom: 0.0625rem solid #ccc;
}


.menu {
  display: none;
  position: absolute;
  left: 11rem;
  padding: 0.5rem;
  background: #ccc;
  flex-direction: column;
  gap: 0.5rem;
}
.menu::before {
  content: '';
  position: absolute;
  top: 0;
  left: -0.75rem;
  width: 0;
  height: 0;
  border-top: 0.75rem solid transparent;
  border-bottom: 0.75rem solid transparent;
  border-right: 0.75rem solid #ccc;
}


.focus_manager {
  position: absolute;
  opacity: 0;
}


button:focus {
  background: skyblue;
}
<button type="button" data-table-prev-focusable>Prev Focusable Element</button>

<br><br><br>

<div class="table" id="table">
  <div class="row">
    <span>Item 1</span>
    <button data-row-first-focusable data-row-last-focusable class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 2</span>
    <button data-row-first-focusable data-row-last-focusable class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 3</span>
    <button data-row-first-focusable data-row-last-focusable class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 4</span>
    <button data-row-first-focusable data-row-last-focusable class="btn_option" type="button">Options</button>
  </div>
  <div class="row">
    <span>Item 5</span>
    <button data-row-first-focusable data-row-last-focusable class="btn_option" type="button">Options</button>
  </div>
</div>

<div class="menu" id="menu">
  <button type="button" id="menu_prev_focus_manager" class="focus_manager"></button>
  <button type="button" data-menu-first-focusable>View</button>
  <button type="button">Edit</button>
  <button type="button" data-menu-last-focusable>Delete</button>
  <button type="button" id="menu_next_focus_manager" class="focus_manager"></button>
</div>

<br><br><br>

<button type="button" data-table-next-focusable>Next Focusable Element</button>