diff --git a/panel/src/scss/components/_notifications.scss b/panel/src/scss/components/_notifications.scss index 186bb93f8..af8540483 100644 --- a/panel/src/scss/components/_notifications.scss +++ b/panel/src/scss/components/_notifications.scss @@ -68,3 +68,8 @@ background-color: var(--color-danger-500); color: var(--color-white); } + +.notification:focus-visible { + outline: 2px solid var(--color-white); + outline-offset: -4px; +} diff --git a/panel/src/scss/components/forms/_forms-tags.scss b/panel/src/scss/components/forms/_forms-tags.scss index 189c24b40..cfc27f427 100644 --- a/panel/src/scss/components/forms/_forms-tags.scss +++ b/panel/src/scss/components/forms/_forms-tags.scss @@ -92,9 +92,14 @@ .tag-remove { display: inline-block; padding: 0 0.125rem; + border: 0; margin-left: 0.25rem; + background: transparent; color: var(--color-base-100); + cursor: pointer; + font: inherit; font-style: normal; + line-height: inherit; transition: color var(--transition-time-sm); &:hover { diff --git a/panel/src/ts/components/dropdowns.ts b/panel/src/ts/components/dropdowns.ts index bc5f4a58c..0bc3ea173 100644 --- a/panel/src/ts/components/dropdowns.ts +++ b/panel/src/ts/components/dropdowns.ts @@ -2,42 +2,160 @@ import { getOuterHeight, getOuterWidth } from "../utils/dimensions"; import { throttle } from "../utils/events"; import { $, $$ } from "../utils/selectors"; +const itemSelector = ".dropdown-item:not([disabled]):not([aria-disabled=true])"; + export class Dropdowns { constructor() { if ($(".dropdown")) { + $$(".dropdown-button").forEach(initButton); + document.addEventListener("click", (event) => { - $$(".dropdown-menu").forEach((element) => (element.style.display = "")); + const button = (event.target as HTMLElement).closest(".dropdown-button"); + const dropdown = button ? getDropdown(button) : null; + const wasVisible = dropdown ? isVisible(dropdown) : false; - const button = (event.target as HTMLDivElement).closest(".dropdown-button") as HTMLButtonElement; + closeAll(); if (button) { - const dropdown = document.getElementById(button.dataset.dropdown as string) as HTMLElement; - const isVisible = getComputedStyle(dropdown).display !== "none"; event.preventDefault(); - const resizeHandler = throttle(() => setDropdownPosition(dropdown), 100); - - if (dropdown && !isVisible) { - dropdown.style.display = "block"; - setDropdownPosition(dropdown); - window.addEventListener("resize", resizeHandler); - } else { - window.removeEventListener("resize", resizeHandler); + if (dropdown && !wasVisible) { + open(button, dropdown); } } }); + document.addEventListener("keydown", (event) => { + const target = event.target as HTMLElement; + + const button = target.closest(".dropdown-button"); + + if (button) { + handleButtonKeydown(event, button); + return; + } + + const menu = target.closest(".dropdown-menu"); + + if (menu) { + handleMenuKeydown(event, menu); + } + }); + document.addEventListener("focusin", (event) => { const target = event.target as HTMLElement; if (!target.closest(".dropdown")) { - $$(".dropdown-menu").forEach((element) => (element.style.display = "")); + closeAll(); } }); } } } +function getDropdown(button: HTMLElement): HTMLElement | null { + return button.dataset.dropdown ? document.getElementById(button.dataset.dropdown) : null; +} + +function getButton(dropdown: HTMLElement): HTMLElement | null { + return dropdown.id ? $(`.dropdown-button[data-dropdown="${dropdown.id}"]`) : null; +} + +function isVisible(dropdown: HTMLElement): boolean { + return getComputedStyle(dropdown).display !== "none"; +} + +function initButton(button: HTMLElement) { + const dropdown = getDropdown(button); + + if (dropdown) { + button.setAttribute("aria-controls", dropdown.id); + button.setAttribute("aria-expanded", String(isVisible(dropdown))); + } +} + +function open(button: HTMLElement, dropdown: HTMLElement) { + // A tooltip of the button would cover the first items of the menu + $$(".tooltip").forEach((tooltip) => tooltip.remove()); + + const resizeHandler = throttle(() => setDropdownPosition(dropdown), 100); + + dropdown.style.display = "block"; + setDropdownPosition(dropdown); + window.addEventListener("resize", resizeHandler); + + initButton(button); + button.setAttribute("aria-expanded", "true"); +} + +function closeAll() { + $$(".dropdown-menu").forEach((element) => (element.style.display = "")); + $$(".dropdown-button[aria-expanded=true]").forEach((button) => button.setAttribute("aria-expanded", "false")); +} + +function getItems(dropdown: HTMLElement): HTMLElement[] { + return Array.from($$(itemSelector, dropdown)).filter((item) => item.getClientRects().length > 0); +} + +function handleButtonKeydown(event: KeyboardEvent, button: HTMLElement) { + const dropdown = getDropdown(button); + + if (!dropdown) { + return; + } + + if (event.key === "Escape" && isVisible(dropdown)) { + closeAll(); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + + if (!isVisible(dropdown)) { + closeAll(); + open(button, dropdown); + } + + const items = getItems(dropdown); + + (event.key === "ArrowDown" ? items[0] : items[items.length - 1])?.focus(); + } +} + +function handleMenuKeydown(event: KeyboardEvent, menu: HTMLElement) { + const button = getButton(menu); + + // Menus without a button (e.g. select inputs) manage their own keyboard interaction + if (!button || !isVisible(menu)) { + return; + } + + const items = getItems(menu); + const index = items.indexOf(document.activeElement as HTMLElement); + + switch (event.key) { + case "Escape": + event.preventDefault(); + closeAll(); + button.focus(); + break; + case "ArrowDown": + event.preventDefault(); + items[(index + 1) % items.length]?.focus(); + break; + case "ArrowUp": + event.preventDefault(); + items[(index <= 0 ? items.length : index) - 1]?.focus(); + break; + case "Home": + event.preventDefault(); + items[0]?.focus(); + break; + case "End": + event.preventDefault(); + items[items.length - 1]?.focus(); + break; + } +} + function setDropdownPosition(dropdown: HTMLElement) { dropdown.style.left = "0"; dropdown.style.right = ""; diff --git a/panel/src/ts/components/inputs/editor-input.ts b/panel/src/ts/components/inputs/editor-input.ts index 747cac47f..7d7d91ee6 100644 --- a/panel/src/ts/components/inputs/editor-input.ts +++ b/panel/src/ts/components/inputs/editor-input.ts @@ -1,6 +1,7 @@ import { app } from "../../app"; import { debounce } from "../../utils/events"; import { $ } from "../../utils/selectors"; +import { initToolbar } from "../../utils/toolbar"; import { escapeRegExp } from "../../utils/validation"; import { markdown } from "../icons"; @@ -129,9 +130,19 @@ export class EditorInput { }); }); - $(`label[for="${textarea.id}"]`)?.addEventListener("click", () => { - this.editorPromise.then(() => this.editor?.view.focus()); - }); + const label = $(`label[for="${textarea.id}"]`); + + const toolbar = $(".editor-toolbar", this.container); + + if (label) { + label.addEventListener("click", () => { + this.editorPromise.then(() => this.editor?.view.focus()); + }); + } + + if (toolbar) { + initToolbar(toolbar, label?.id); + } } async switchToMarkdown() { @@ -147,6 +158,7 @@ export class EditorInput { placeholder: this.element.placeholder, attributes: { spellcheck: this.options.spellcheck ? "true" : "false", + ...this.accessibilityAttributes(), }, }); this.element.style.display = "none"; @@ -171,6 +183,37 @@ export class EditorInput { this.editor.view.dom.style.height = `${this.options.height}px`; } + private accessibilityAttributes(): Record { + const attributes: Record = { + role: "textbox", + "aria-multiline": "true", + }; + + const labelId = $(`label[for="${this.id}"]`)?.id; + + if (labelId) { + attributes["aria-labelledby"] = labelId; + } else if (this.element.ariaLabel) { + attributes["aria-label"] = this.element.ariaLabel; + } + + const describedBy = this.element.getAttribute("aria-describedby"); + + if (describedBy) { + attributes["aria-describedby"] = describedBy; + } + + if (this.element.required) { + attributes["aria-required"] = "true"; + } + + if (this.element.readOnly) { + attributes["aria-readonly"] = "true"; + } + + return attributes; + } + get name() { return this.element.name; } diff --git a/panel/src/ts/components/inputs/select-input.ts b/panel/src/ts/components/inputs/select-input.ts index 6e1c8f194..eeeec5639 100644 --- a/panel/src/ts/components/inputs/select-input.ts +++ b/panel/src/ts/components/inputs/select-input.ts @@ -18,6 +18,8 @@ interface SelectInputOptions { }; } +let instanceCount = 0; + export class SelectInput { readonly options: SelectInputOptions; @@ -29,6 +31,10 @@ export class SelectInput { private emptyState: HTMLDivElement; + private readonly uid = ++instanceCount; + + private optionCount = 0; + constructor(element: HTMLSelectElement, options: Partial) { const defaults: SelectInputOptions = { labels: { @@ -96,6 +102,18 @@ export class SelectInput { this.labelInput.classList.add("form-select"); this.labelInput.id = selectId; + const describedBy = this.element.getAttribute("aria-describedby"); + + if (describedBy) { + this.labelInput.setAttribute("aria-describedby", describedBy); + } + + // The text input acts as the combobox, its dropdown is the listbox + this.labelInput.setAttribute("role", "combobox"); + this.labelInput.setAttribute("aria-autocomplete", "list"); + this.labelInput.setAttribute("aria-haspopup", "listbox"); + this.labelInput.setAttribute("aria-expanded", "false"); + this.element.removeAttribute("id"); this.element.ariaHidden = "true"; @@ -137,6 +155,12 @@ export class SelectInput { wrap.appendChild(this.element); this.createDropdown(list, wrap as HTMLElement); + + const labelId = this.labelInput.labels?.[0]?.id; + + if (labelId) { + this.dropdown.setAttribute("aria-labelledby", labelId); + } } addOption({ label, value, disabled, thumb, icon }: { label: string; value: string; disabled?: boolean; thumb?: string; icon?: string }) { @@ -192,6 +216,9 @@ export class SelectInput { private addDropdownItem(option: SelectInputListItem) { const item = document.createElement("div"); item.className = "dropdown-item"; + item.id = `${this.dropdown.id}-option-${++this.optionCount}`; + item.setAttribute("role", "option"); + item.setAttribute("aria-selected", String(option.selected)); item.innerText = option.label; item.dataset.value = option.value; @@ -202,6 +229,7 @@ export class SelectInput { if (option.disabled) { item.classList.add("disabled"); + item.setAttribute("aria-disabled", "true"); } if (option.dataset.thumb) { @@ -251,6 +279,9 @@ export class SelectInput { this.dropdown.className = "dropdown-list"; this.dropdown.dataset.for = this.labelInput.id; + this.dropdown.id = `${this.labelInput.id || `select-${this.uid}`}-listbox`; + this.dropdown.setAttribute("role", "listbox"); + this.labelInput.setAttribute("aria-controls", this.dropdown.id); this.emptyState.className = "dropdown-empty"; this.emptyState.style.display = "none"; @@ -281,7 +312,7 @@ export class SelectInput { if (!this.validateDropdownItem(this.labelInput.value)) { this.labelInput.value = this.getCurrentLabel(); } - this.dropdown.style.display = "none"; + this.setExpanded(false); }); this.labelInput.addEventListener("keydown", (event) => { @@ -349,7 +380,7 @@ export class SelectInput { case "Enter": return true; default: - this.dropdown.style.display = "block"; + this.setExpanded(true); this.filterDropdown(value); if (value.length > 0) { this.selectFirstDropdownItem(); @@ -365,8 +396,11 @@ export class SelectInput { visibleItems++; } element.classList.remove("selected"); + element.setAttribute("aria-selected", "false"); }); + this.labelInput.removeAttribute("aria-activedescendant"); + if (visibleItems > 0) { this.emptyState.style.display = "none"; } else { @@ -374,6 +408,15 @@ export class SelectInput { } } + private setExpanded(expanded: boolean) { + this.dropdown.style.display = expanded ? "block" : "none"; + this.labelInput.setAttribute("aria-expanded", String(expanded)); + + if (!expanded) { + this.labelInput.removeAttribute("aria-activedescendant"); + } + } + private filterDropdown(value: string) { const filter = (element: HTMLElement) => { if (value === "") { @@ -422,11 +465,14 @@ export class SelectInput { const selectedItem = $(".dropdown-item.selected", this.dropdown); if (selectedItem) { selectedItem.classList.remove("selected"); + selectedItem.setAttribute("aria-selected", "false"); } if (item) { const isDisabled = item.classList.contains("disabled"); if (!isDisabled) { item.classList.add("selected"); + item.setAttribute("aria-selected", "true"); + this.labelInput.setAttribute("aria-activedescendant", item.id); this.scrollToDropdownItem(item); } } @@ -505,7 +551,7 @@ export class SelectInput { this.filterDropdown(""); this.updateDropdown(); this.selectDropdownItem(this.getCurrent()); - this.dropdown.style.display = "block"; + this.setExpanded(true); this.scrollToDropdownItem(this.getCurrent()); } } diff --git a/panel/src/ts/components/inputs/tags-input.ts b/panel/src/ts/components/inputs/tags-input.ts index e763fff19..482e2a0ee 100644 --- a/panel/src/ts/components/inputs/tags-input.ts +++ b/panel/src/ts/components/inputs/tags-input.ts @@ -95,6 +95,12 @@ export class TagsInput { this.innerInput.id = this.element.id; this.innerInput.placeholder = this.element.placeholder; + const describedBy = this.element.getAttribute("aria-describedby"); + + if (describedBy) { + this.innerInput.setAttribute("aria-describedby", describedBy); + } + this.element.classList.remove("form-input"); this.element.removeAttribute("id"); this.element.removeAttribute("placeholder"); @@ -139,6 +145,8 @@ export class TagsInput { Sortable.create(this.list, { forceFallback: true, animation: 150, + filter: ".tag-remove", + preventOnFilter: false, onChoose: () => { if (this.dropdown) { @@ -419,15 +427,16 @@ export class TagsInput { private insertTag(value: string, parent: HTMLElement) { const tag = document.createElement("span"); - const tagRemove = document.createElement("i"); + const tagRemove = document.createElement("button"); tag.className = "tag"; tag.innerText = value; tag.style.marginRight = ".25rem"; parent.appendChild(tag); + tagRemove.type = "button"; tagRemove.className = "tag-remove"; tagRemove.title = this.options.labels.remove; - tagRemove.role = "button"; + tagRemove.ariaLabel = `${this.options.labels.remove}: ${value}`; let mousedownCaptured = false; @@ -442,6 +451,13 @@ export class TagsInput { if (mousedownCaptured || keyboardActivation) { this.removeTag(value); parent.removeChild(tag); + + $$(".tooltip").forEach((tooltip) => tooltip.remove()); + + if (keyboardActivation) { + // The focused button has been removed, keep keyboard focus in the field + this.innerInput.focus(); + } } mousedownCaptured = false; event.preventDefault(); diff --git a/panel/src/ts/components/inputs/upload-input.ts b/panel/src/ts/components/inputs/upload-input.ts index 35db48775..72deee073 100644 --- a/panel/src/ts/components/inputs/upload-input.ts +++ b/panel/src/ts/components/inputs/upload-input.ts @@ -218,12 +218,14 @@ export class UploadInput { if (info.type === "image") { const img = document.createElement("img"); img.className = "file-thumbnail"; + img.alt = ""; img.src = info.thumbnail; img.loading = "lazy"; $(".file-thumbnail", filesItem)?.replaceWith(img); } else if (info.type === "video") { const video = document.createElement("video"); video.classList.add("file-thumbnail"); + video.ariaHidden = "true"; video.src = info.thumbnail; video.preload = "metadata"; $(".file-thumbnail", filesItem)?.replaceWith(video); diff --git a/panel/src/ts/components/modal.ts b/panel/src/ts/components/modal.ts index 4ddd9ce9a..670f99979 100644 --- a/panel/src/ts/components/modal.ts +++ b/panel/src/ts/components/modal.ts @@ -14,7 +14,11 @@ type ModalCallback = (modal: Modal, triggerElement?: HTMLElement) => void; type ModalState = "open" | "closed"; +const focusableSelector = ["a[href]", "button:not([disabled])", "input:not([disabled]):not([type=hidden])", "select:not([disabled])", "textarea:not([disabled])", "[contenteditable=true]", "[tabindex]:not([tabindex='-1'])"].join(","); + export class Modal { + private static readonly openModals: Modal[] = []; + readonly element: HTMLElement; readonly form: Form | null; @@ -25,6 +29,8 @@ export class Modal { private state: ModalState = "closed"; + private returnFocusElement: HTMLElement | null = null; + constructor(element: HTMLElement) { this.element = element; @@ -71,6 +77,10 @@ export class Modal { } } + if (this.isClosed) { + this.returnFocusElement = this.resolveReturnFocusElement(options.triggerElement ?? (document.activeElement as HTMLElement | null)); + } + (document.activeElement as HTMLElement | null)?.blur(); // Don't retain focus on any element this.getFirstFocusableElement(this.element)?.focus(); @@ -79,6 +89,10 @@ export class Modal { this.createBackdrop(); + if (this.isClosed) { + Modal.openModals.push(this); + } + this.state = "open"; this.dispatchCallback("open", options.triggerElement); @@ -95,7 +109,15 @@ export class Modal { this.state = "closed"; + const index = Modal.openModals.indexOf(this); + + if (index !== -1) { + Modal.openModals.splice(index, 1); + } + this.dispatchCallback("close", options.triggerElement); + + this.restoreFocus(); } onBeforeOpen(callback: ModalCallback) { @@ -158,11 +180,23 @@ export class Modal { dismissButton?.addEventListener("click", () => this.close({ triggerElement: dismissButton })); document.addEventListener("keyup", (event) => { - if (event.key === "Escape") { + if (event.key === "Escape" && this.isOpen) { this.close(); } }); + document.addEventListener("keydown", (event) => { + if (event.key === "Tab" && this.isTopmost) { + this.trapFocus(event); + } + }); + + document.addEventListener("focusin", (event) => { + if (this.isTopmost && !this.element.contains(event.target as Node)) { + this.getFirstFocusableElement(this.element).focus(); + } + }); + let mousedownCaptured = false; this.element.addEventListener("mousedown", (event) => { @@ -183,6 +217,56 @@ export class Modal { }); } + private get isTopmost() { + return this.isOpen && Modal.openModals[Modal.openModals.length - 1] === this; + } + + private getFocusableElements(): HTMLElement[] { + return Array.from($$(focusableSelector, this.element)).filter((element) => element.getClientRects().length > 0); + } + + private trapFocus(event: KeyboardEvent) { + const focusable = this.getFocusableElements(); + + if (focusable.length === 0) { + event.preventDefault(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && (active === first || !this.element.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (active === last || !this.element.contains(active))) { + event.preventDefault(); + first.focus(); + } + } + + private resolveReturnFocusElement(element: HTMLElement | null): HTMLElement | null { + // When the trigger lives in a dropdown menu, return focus to the button that opens that menu + const menu = element?.closest(".dropdown-menu"); + + if (menu?.id) { + return $(`[data-dropdown="${menu.id}"]`) ?? element; + } + + return element; + } + + private restoreFocus() { + const element = this.returnFocusElement; + + this.returnFocusElement = null; + + if (element?.isConnected && element.getClientRects().length > 0) { + element.focus(); + } + } + private getFirstFocusableElement(parent: HTMLElement = document.body): HTMLElement { return parent.querySelector("[autofocus], button, .button, input:not([type=hidden]), select, textarea") || parent; } diff --git a/panel/src/ts/components/navigation.ts b/panel/src/ts/components/navigation.ts index f52fe0910..6f7f7e91e 100644 --- a/panel/src/ts/components/navigation.ts +++ b/panel/src/ts/components/navigation.ts @@ -2,9 +2,19 @@ import { $ } from "../utils/selectors"; export class Navigation { constructor() { - if ($(".sidebar-toggle")) { - $(".sidebar-toggle")?.addEventListener("click", () => { - if (($(".sidebar") as HTMLElement).classList.toggle("show")) { + const toggle = $(".sidebar-toggle"); + const sidebar = $(".sidebar"); + + if (toggle && sidebar) { + sidebar.id ||= "panel-sidebar"; + toggle.setAttribute("aria-controls", sidebar.id); + toggle.setAttribute("aria-expanded", "false"); + + const setOpen = (open: boolean) => { + sidebar.classList.toggle("show", open); + toggle.setAttribute("aria-expanded", String(open)); + + if (open) { if (!$(".sidebar-backdrop")) { const backdrop = document.createElement("div"); backdrop.className = "sidebar-backdrop hide-from-md"; @@ -16,6 +26,15 @@ export class Navigation { (backdrop.parentNode as ParentNode).removeChild(backdrop); } } + }; + + toggle.addEventListener("click", () => setOpen(!sidebar.classList.contains("show"))); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && sidebar.classList.contains("show")) { + setOpen(false); + toggle.focus(); + } }); } diff --git a/panel/src/ts/components/notification.ts b/panel/src/ts/components/notification.ts index ac786f293..740067468 100644 --- a/panel/src/ts/components/notification.ts +++ b/panel/src/ts/components/notification.ts @@ -14,6 +14,19 @@ type NotificationOptions = { }; export class Notification { + static getContainer(): HTMLElement { + let container = $(".notification-container"); + + if (!container) { + // Layouts render this container with its live region attributes, this is only a fallback + container = document.createElement("div"); + container.className = "notification-container"; + document.body.appendChild(container); + } + + return container; + } + text: string; type: NotificationType; options: NotificationOptions; @@ -46,19 +59,21 @@ export class Notification { this.options = Object.assign({}, defaults, options); - this.containerElement = $(".notification-container"); + this.containerElement = null; } show() { const create = (text: string, type: NotificationType, interval: number) => { - if (!this.containerElement) { - this.containerElement = document.createElement("div"); - this.containerElement.className = "notification-container"; - document.body.appendChild(this.containerElement); - } + this.containerElement = Notification.getContainer(); const notification = document.createElement("div"); notification.className = `notification notification-${this.options.typeClass[type]}`; + notification.tabIndex = 0; + + if (type === "error" || type === "warning") { + notification.setAttribute("role", "alert"); + } + notification.innerHTML = text; if (this.options.newestOnTop && this.containerElement.childNodes.length > 0) { @@ -69,11 +84,31 @@ export class Notification { let timer = window.setTimeout(() => this.remove(), interval); - notification.addEventListener("click", () => this.remove()); + const pause = () => window.clearTimeout(timer); - notification.addEventListener("mouseenter", () => clearTimeout(timer)); + const resume = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => this.remove(), this.options.mouseleaveDelay); + }; - notification.addEventListener("mouseleave", () => ((timer = window.setTimeout(() => this.remove())), this.options.mouseleaveDelay)); + notification.addEventListener("click", () => this.remove()); + + // Pause the countdown while the pointer or keyboard focus is on the notification + notification.addEventListener("mouseenter", pause); + notification.addEventListener("mouseleave", () => { + if (!notification.matches(":focus-within")) { + resume(); + } + }); + notification.addEventListener("focusin", pause); + notification.addEventListener("focusout", resume); + + notification.addEventListener("keydown", (event) => { + if (event.key === "Escape" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + this.remove(); + } + }); return notification; }; @@ -97,12 +132,6 @@ export class Notification { if (this.containerElement && this.notificationElement && this.notificationElement.parentNode) { this.containerElement.removeChild(this.notificationElement); } - if (this.containerElement && this.containerElement.childNodes.length < 1) { - if (this.containerElement.parentNode) { - document.body.removeChild(this.containerElement); - } - this.containerElement = null; - } }, this.options.fadeOutDelay); } } diff --git a/panel/src/ts/components/tabs.ts b/panel/src/ts/components/tabs.ts index b81ffca29..1a3b9f247 100644 --- a/panel/src/ts/components/tabs.ts +++ b/panel/src/ts/components/tabs.ts @@ -4,12 +4,13 @@ export class Tabs { constructor() { $$(".tabs").forEach((tabs) => { const formName = tabs.closest("form")?.dataset.form; - const tabButtons = $$(".tabs-tab[data-tab]", tabs); + const tabButtons = Array.from($$(".tabs-tab[data-tab]", tabs)); const selectTab = (name: string) => { tabButtons.forEach((button) => { button.classList.toggle("active", button.dataset.tab === name); button.ariaSelected = (button.dataset.tab === name).toString(); + button.tabIndex = button.dataset.tab === name ? 0 : -1; const tabPanel = $(`.tabs-panel[data-tab="${button.dataset.tab}"]`); tabPanel?.classList.toggle("visible", button.dataset.tab === name); }); @@ -29,6 +30,24 @@ export class Tabs { selectTab(tabButton.dataset.tab as string); window.localStorage.setItem(`formwork.tabStatus[${formName}]`, tabButton.dataset.tab as string); }); + + tabButton.addEventListener("keydown", (event) => { + const index = tabButtons.indexOf(tabButton); + const lastIndex = tabButtons.length - 1; + + const nextIndex = { + ArrowRight: index === lastIndex ? 0 : index + 1, + ArrowLeft: index === 0 ? lastIndex : index - 1, + Home: 0, + End: lastIndex, + }[event.key]; + + if (nextIndex !== undefined) { + event.preventDefault(); + tabButtons[nextIndex].focus(); + tabButtons[nextIndex].click(); + } + }); }); }); } diff --git a/panel/src/ts/components/tooltip.ts b/panel/src/ts/components/tooltip.ts index fcad95493..ecb19c543 100644 --- a/panel/src/ts/components/tooltip.ts +++ b/panel/src/ts/components/tooltip.ts @@ -56,6 +56,12 @@ export class Tooltip { const container = options.container; this.delayTimer = window.setTimeout(() => { + // Do not show a tooltip for an element that has been removed + // or cover the menu opened by the reference element + if (!options.referenceElement.isConnected || options.referenceElement.getAttribute("aria-expanded") === "true") { + return; + } + const getRelativePosition = (tooltip: HTMLElement) => { const offset = options.offset; diff --git a/panel/src/ts/components/tooltips.ts b/panel/src/ts/components/tooltips.ts index 89bdaefab..619517438 100644 --- a/panel/src/ts/components/tooltips.ts +++ b/panel/src/ts/components/tooltips.ts @@ -17,12 +17,23 @@ export class Tooltips { debounce((event: MouseEvent) => { const target = event.target as HTMLElement; + // The event is debounced, its target may have been removed in the meantime + if (!target.isConnected) { + return; + } + const element = target.closest("[data-tooltip]") as HTMLElement; const position = { x: event.pageX, y: event.pageY }; const offset = { x: 0, y: 16 }; if (element) { + // Do not cover an open menu with the tooltip of the button that opened it + if (element.getAttribute("aria-expanded") === "true") { + this.tooltip?.remove(); + return; + } + if (this.element === element && !this.tooltip?.removed) { return; } @@ -76,7 +87,7 @@ export class Tooltips { const element = target.closest("[data-tooltip]") as HTMLElement; if (element && (element.tagName.toLowerCase() === "button" || element.classList.contains("button"))) { - if (this.element === element) { + if (this.element === element || element.getAttribute("aria-expanded") === "true") { return; } diff --git a/panel/src/ts/utils/toolbar.ts b/panel/src/ts/utils/toolbar.ts new file mode 100644 index 000000000..ebb50f62a --- /dev/null +++ b/panel/src/ts/utils/toolbar.ts @@ -0,0 +1,71 @@ +/** + * Turn an element containing buttons into an ARIA toolbar with a roving tabindex: + * the toolbar is a single tab stop and its buttons are reached with the arrow keys. + */ +export function initToolbar(toolbar: HTMLElement, labelledBy?: string) { + toolbar.setAttribute("role", "toolbar"); + toolbar.setAttribute("aria-orientation", "horizontal"); + + if (labelledBy) { + toolbar.setAttribute("aria-labelledby", labelledBy); + } + + let current: HTMLElement | null = null; + + // Until a button has been focused, the first enabled button is the tab stop (buttons are added after the toolbar is created) + let chosen = false; + + const getButtons = () => Array.from(toolbar.querySelectorAll("button")).filter((button) => !button.closest(".dropdown-menu")); + + const getEnabledButtons = () => getButtons().filter((button) => !button.disabled); + + const sync = () => { + const enabled = getEnabledButtons(); + + if (!chosen || !current || !enabled.includes(current as HTMLButtonElement)) { + current = enabled[0] ?? null; + } + + getButtons().forEach((button) => { + button.tabIndex = button === current ? 0 : -1; + }); + }; + + toolbar.addEventListener("focusin", (event) => { + const button = (event.target as HTMLElement).closest("button"); + + if (button && getEnabledButtons().includes(button)) { + current = button; + chosen = true; + sync(); + } + }); + + toolbar.addEventListener("keydown", (event) => { + const buttons = getEnabledButtons(); + const index = buttons.indexOf((event.target as HTMLElement).closest("button") as HTMLButtonElement); + + if (index === -1) { + return; + } + + const lastIndex = buttons.length - 1; + + const nextIndex = { + ArrowRight: index === lastIndex ? 0 : index + 1, + ArrowLeft: index === 0 ? lastIndex : index - 1, + Home: 0, + End: lastIndex, + }[event.key]; + + if (nextIndex !== undefined) { + event.preventDefault(); + buttons[nextIndex].focus(); + } + }); + + // Buttons are added and enabled or disabled while the editor is running + new MutationObserver(sync).observe(toolbar, { subtree: true, childList: true, attributes: true, attributeFilter: ["disabled"] }); + + sync(); +} diff --git a/panel/translations/de.yaml b/panel/translations/de.yaml index 7f28108b3..92a09115e 100644 --- a/panel/translations/de.yaml +++ b/panel/translations/de.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Einzigartige Besucher panel.dashboard.statistics.visits: Besuche panel.dashboard.welcome: Willkommen panel.dragToReorder: Ziehen zum Umsortieren +panel.dropdown.moreOptions: Weitere Optionen panel.editor.bold: Fett panel.editor.bulletList: Aufzählungsliste panel.editor.code: Code diff --git a/panel/translations/el.yaml b/panel/translations/el.yaml index 5ec29df07..640a31c18 100644 --- a/panel/translations/el.yaml +++ b/panel/translations/el.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Μοναδικοί επισκέπτε panel.dashboard.statistics.visits: Επισκέψεις panel.dashboard.welcome: Καλώς ήρθες panel.dragToReorder: Σύρε για αλλαγή σειράς +panel.dropdown.moreOptions: Περισσότερες επιλογές panel.editor.bold: Έντονα panel.editor.bulletList: Λίστα κουκκίδων panel.editor.code: Κώδικας diff --git a/panel/translations/en.yaml b/panel/translations/en.yaml index 827b9c73e..4533cda1e 100644 --- a/panel/translations/en.yaml +++ b/panel/translations/en.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Unique visitors panel.dashboard.statistics.visits: Visits panel.dashboard.welcome: Welcome panel.dragToReorder: Drag to reorder +panel.dropdown.moreOptions: More options panel.editor.bold: Bold panel.editor.bulletList: Bullet list panel.editor.code: Code diff --git a/panel/translations/es.yaml b/panel/translations/es.yaml index 30661db35..062f420c5 100644 --- a/panel/translations/es.yaml +++ b/panel/translations/es.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Visitantes únicos panel.dashboard.statistics.visits: Visitas panel.dashboard.welcome: Bienvenido panel.dragToReorder: Arrastra para reordenar +panel.dropdown.moreOptions: Más opciones panel.editor.bold: Negrita panel.editor.bulletList: Lista con viñetas panel.editor.code: Código diff --git a/panel/translations/fr.yaml b/panel/translations/fr.yaml index 42edf4ae3..ca0459fb4 100644 --- a/panel/translations/fr.yaml +++ b/panel/translations/fr.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Visiteurs uniques panel.dashboard.statistics.visits: Visites panel.dashboard.welcome: Bienvenue panel.dragToReorder: Glisser pour réorganiser +panel.dropdown.moreOptions: Plus d'options panel.editor.bold: Gras panel.editor.bulletList: Liste à puce panel.editor.code: Code diff --git a/panel/translations/hu.yaml b/panel/translations/hu.yaml index 020440aca..9cb38f3ce 100644 --- a/panel/translations/hu.yaml +++ b/panel/translations/hu.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Egyedi látogatók panel.dashboard.statistics.visits: Látogatások panel.dashboard.welcome: Üdvözöljük panel.dragToReorder: Húzza az átrendezéshez +panel.dropdown.moreOptions: További lehetőségek panel.editor.bold: Félkövér panel.editor.bulletList: Felsorolás panel.editor.code: Kód diff --git a/panel/translations/it.yaml b/panel/translations/it.yaml index 04fb63371..82a3c3c0f 100644 --- a/panel/translations/it.yaml +++ b/panel/translations/it.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Visitatori unici panel.dashboard.statistics.visits: Visite panel.dashboard.welcome: Benvenuto/a panel.dragToReorder: Trascina per riordinare +panel.dropdown.moreOptions: Altre opzioni panel.editor.bold: Grassetto panel.editor.bulletList: Elenco puntato panel.editor.code: Codice diff --git a/panel/translations/nl.yaml b/panel/translations/nl.yaml index ea61a8022..68e4f2642 100644 --- a/panel/translations/nl.yaml +++ b/panel/translations/nl.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Unieke bezoekers panel.dashboard.statistics.visits: Bezoeken panel.dashboard.welcome: Welkom panel.dragToReorder: Sleep om te herschikken +panel.dropdown.moreOptions: Meer opties panel.editor.bold: Vet panel.editor.bulletList: Opsommingstekens panel.editor.code: Code diff --git a/panel/translations/pl.yaml b/panel/translations/pl.yaml index d782cda30..976da5219 100644 --- a/panel/translations/pl.yaml +++ b/panel/translations/pl.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Unikalni odwiedzający panel.dashboard.statistics.visits: Wizyty panel.dashboard.welcome: Witaj panel.dragToReorder: Przeciągnij, aby zmienić kolejność +panel.dropdown.moreOptions: Więcej opcji panel.editor.bold: Pogrubienie panel.editor.bulletList: Lista punktowana panel.editor.code: Kod diff --git a/panel/translations/pt.yaml b/panel/translations/pt.yaml index ff64b38d0..2cf68d410 100644 --- a/panel/translations/pt.yaml +++ b/panel/translations/pt.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Visitantes únicos panel.dashboard.statistics.visits: Visitas panel.dashboard.welcome: BemVindo panel.dragToReorder: Arraste para reordenar +panel.dropdown.moreOptions: Mais opções panel.editor.bold: Negrito panel.editor.bulletList: Lista de marcadores panel.editor.code: Código diff --git a/panel/translations/ro.yaml b/panel/translations/ro.yaml index b971ad879..deea2ca9f 100644 --- a/panel/translations/ro.yaml +++ b/panel/translations/ro.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Vizitatori unici panel.dashboard.statistics.visits: Vizite panel.dashboard.welcome: Bine ai venit panel.dragToReorder: Trage pentru a reordona +panel.dropdown.moreOptions: Mai multe opțiuni panel.editor.bold: Aldin panel.editor.bulletList: Listă cu marcatori panel.editor.code: Cod diff --git a/panel/translations/ru.yaml b/panel/translations/ru.yaml index d6ede619d..c12fbf890 100644 --- a/panel/translations/ru.yaml +++ b/panel/translations/ru.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Уникальные посетите panel.dashboard.statistics.visits: Просмотров panel.dashboard.welcome: Добро пожаловать panel.dragToReorder: Перетащите для изменения порядка +panel.dropdown.moreOptions: Другие параметры panel.editor.bold: Жирный panel.editor.bulletList: Маркированный список panel.editor.code: Код diff --git a/panel/translations/sv.yaml b/panel/translations/sv.yaml index 942fefc77..940f2efe6 100644 --- a/panel/translations/sv.yaml +++ b/panel/translations/sv.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Unika besökare panel.dashboard.statistics.visits: Besök panel.dashboard.welcome: Välkommen panel.dragToReorder: Dra för att ändra ordning +panel.dropdown.moreOptions: Fler alternativ panel.editor.bold: Fetstil panel.editor.bulletList: Punktlista panel.editor.code: Kod diff --git a/panel/translations/tr.yaml b/panel/translations/tr.yaml index 82bb3d1b8..a8e391e91 100644 --- a/panel/translations/tr.yaml +++ b/panel/translations/tr.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Tekil ziyaretçiler panel.dashboard.statistics.visits: Ziyaretler panel.dashboard.welcome: Hoş geldiniz panel.dragToReorder: Yeniden sıralamak için sürükleyin +panel.dropdown.moreOptions: Daha fazla seçenek panel.editor.bold: Kalın panel.editor.bulletList: Madde işaretli liste panel.editor.code: Kod diff --git a/panel/translations/uk.yaml b/panel/translations/uk.yaml index 75c5f10a9..062dc5501 100644 --- a/panel/translations/uk.yaml +++ b/panel/translations/uk.yaml @@ -25,6 +25,7 @@ panel.dashboard.statistics.uniqueVisitors: Унікальні відвідува panel.dashboard.statistics.visits: Візити panel.dashboard.welcome: Ласкаво просимо panel.dragToReorder: Перетягніть, щоб змінити порядок +panel.dropdown.moreOptions: Інші параметри panel.editor.bold: Жирний panel.editor.bulletList: Список з маркерами panel.editor.code: Код diff --git a/panel/views/dashboard/index.php b/panel/views/dashboard/index.php index 944fe6cf5..e5d412354 100644 --- a/panel/views/dashboard/index.php +++ b/panel/views/dashboard/index.php @@ -22,7 +22,7 @@