Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions panel/src/scss/components/_notifications.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 5 additions & 0 deletions panel/src/scss/components/forms/_forms-tags.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
144 changes: 131 additions & 13 deletions panel/src/ts/components/dropdowns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>(".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<HTMLElement>(".dropdown-button");

if (button) {
handleButtonKeydown(event, button);
return;
}

const menu = target.closest<HTMLElement>(".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($$<HTMLElement>(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 = "";
Expand Down
49 changes: 46 additions & 3 deletions panel/src/ts/components/inputs/editor-input.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 = $<HTMLElement>(".editor-toolbar", this.container);

if (label) {
label.addEventListener("click", () => {
this.editorPromise.then(() => this.editor?.view.focus());
});
}

if (toolbar) {
initToolbar(toolbar, label?.id);
}
}

async switchToMarkdown() {
Expand All @@ -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";
Expand All @@ -171,6 +183,37 @@ export class EditorInput {
this.editor.view.dom.style.height = `${this.options.height}px`;
}

private accessibilityAttributes(): Record<string, string> {
const attributes: Record<string, string> = {
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;
}
Expand Down
Loading
Loading