mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-11 15:59:03 +00:00
feat(menu, panel): improved keyboard navigation for selects + inputs
This commit is contained in:
parent
8745dc9313
commit
9f998b5320
@ -34,12 +34,12 @@ function Panel({
|
|||||||
|
|
||||||
const values = [
|
const values = [
|
||||||
{
|
{
|
||||||
icon: html`<i class="i-type h-[16px] w-[16px]" />`,
|
icon: "type",
|
||||||
value: "word counter",
|
value: "word counter",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
icon: html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
$icon: html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||||
<circle cx="5" cy="7" r="2.8"/>
|
<circle cx="5" cy="7" r="2.8"/>
|
||||||
<circle cx="5" cy="17" r="2.79"/>
|
<circle cx="5" cy="17" r="2.79"/>
|
||||||
<path d="M21,5.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,5.5,21.55,5.95,21,5.95z"/>
|
<path d="M21,5.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,5.5,21.55,5.95,21,5.95z"/>
|
||||||
|
@ -11,8 +11,8 @@ const updateHotkey = (event) => {
|
|||||||
const alias = modifier[0].toUpperCase() + modifier.slice(1, -3);
|
const alias = modifier[0].toUpperCase() + modifier.slice(1, -3);
|
||||||
keys.push(alias);
|
keys.push(alias);
|
||||||
}
|
}
|
||||||
// retain tab for keyboard navigation of menu
|
// retain keyboard navigation of menu
|
||||||
if (event.key === "Tab" && !keys.length) {
|
if (["Tab", "Escape"].includes(event.key) && !keys.length) {
|
||||||
return;
|
return;
|
||||||
} else event.preventDefault();
|
} else event.preventDefault();
|
||||||
if (!keys.length && ["Backspace", "Delete"].includes(event.key)) {
|
if (!keys.length && ["Backspace", "Delete"].includes(event.key)) {
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import { Popup } from "./Popup.mjs";
|
import { Popup } from "./Popup.mjs";
|
||||||
|
|
||||||
function Option({ icon = "", value = "", _get, _set }) {
|
function Option({ $icon = "", value = "", _get, _set }) {
|
||||||
const { html, useState } = globalThis.__enhancerApi,
|
const { html, useState } = globalThis.__enhancerApi,
|
||||||
$selected = html`<i class="ml-auto i-check w-[16px] h-[16px]"></i>`,
|
$selected = html`<i class="ml-auto i-check w-[16px] h-[16px]"></i>`,
|
||||||
$option = html`<div
|
$option = html`<div
|
||||||
@ -14,17 +14,18 @@ function Option({ icon = "", value = "", _get, _set }) {
|
|||||||
role="option"
|
role="option"
|
||||||
class="select-none cursor-pointer rounded-[3px]
|
class="select-none cursor-pointer rounded-[3px]
|
||||||
flex items-center w-full h-[28px] px-[12px] leading-[1.2]
|
flex items-center w-full h-[28px] px-[12px] leading-[1.2]
|
||||||
transition duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
|
transition duration-[20ms] focus:bg-[color:var(--theme--bg-hover)]"
|
||||||
|
onmouseover=${(event) => event.target.focus()}
|
||||||
onclick=${() => _set?.(value)}
|
onclick=${() => _set?.(value)}
|
||||||
onkeydown=${(event) => {
|
onkeydown=${(event) => {
|
||||||
if (event.key === "Enter") _set?.(value);
|
if (["Enter", " "].includes(event.key)) _set?.(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mr-[6px] inline-flex items-center gap-[6px]
|
class="mr-[6px] inline-flex items-center gap-[6px]
|
||||||
text-[14px] text-ellipsis overflow-hidden"
|
text-[14px] text-ellipsis overflow-hidden"
|
||||||
>
|
>
|
||||||
${icon}<span>${value}</span>
|
${$icon}<span>${value}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
useState(["rerender"], async () => {
|
useState(["rerender"], async () => {
|
||||||
@ -56,23 +57,52 @@ function Select({
|
|||||||
duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
|
duration-[20ms] hover:bg-[color:var(--theme--bg-hover)]"
|
||||||
></div>`;
|
></div>`;
|
||||||
|
|
||||||
|
const options = values.map((opt) => {
|
||||||
|
if (typeof opt === "string") opt = { value: opt };
|
||||||
|
if (!(opt?.$icon instanceof Element)) {
|
||||||
|
if (opt?.icon && typeof opt.icon === "string") {
|
||||||
|
opt.$icon = html`<i class="i-${opt.icon} h-[16px] w-[16px]" />`;
|
||||||
|
} else delete opt.$icon;
|
||||||
|
}
|
||||||
|
opt.$option = html`<${Option} ...${{ ...opt, _get, _set }} />`;
|
||||||
|
opt.$selection = html`<div class="inline-flex items-center gap-[6px]">
|
||||||
|
<!-- swap icon/value order for correct display when dir="rtl" -->
|
||||||
|
<span>${opt.value}</span>${opt.$icon?.cloneNode(true) ?? ""}
|
||||||
|
</div>`;
|
||||||
|
return opt;
|
||||||
|
}),
|
||||||
|
getSelected = async () => {
|
||||||
|
const value = (await _get?.()) ?? $select.innerText,
|
||||||
|
option = options.find((opt) => opt.value === value);
|
||||||
|
if (!option) _set(options[0].value);
|
||||||
|
return option || options[0];
|
||||||
|
},
|
||||||
|
onKeydown = (event) => {
|
||||||
|
const intercept = () => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
intercept(setState({ rerender: true }));
|
||||||
|
} else if (!options.length) return;
|
||||||
|
// prettier-ignore
|
||||||
|
const $next = options.find(({ $option }) => $option === event.target)
|
||||||
|
?.$option.nextElementSibling ?? options.at(0).$option,
|
||||||
|
$prev = options.find(({ $option }) => $option === event.target)
|
||||||
|
?.$option.previousElementSibling ?? options.at(-1).$option;
|
||||||
|
// overflow to opposite end of list from dir of travel
|
||||||
|
if (event.key === "ArrowUp") intercept($prev.focus());
|
||||||
|
if (event.key === "ArrowDown") intercept($next.focus());
|
||||||
|
// re-enable natural tab behaviour in notion interface
|
||||||
|
if (event.key === "Tab") event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
let _initialValue;
|
let _initialValue;
|
||||||
values = values.map((value) => {
|
|
||||||
value = typeof value === "string" ? { value } : value;
|
|
||||||
if (typeof value.icon === "string" && value.icon) {
|
|
||||||
value.icon = html`<i class="i-${value.icon} h-[16px] w-[16px]" />`;
|
|
||||||
} else value.icon ??= "";
|
|
||||||
value.value ??= "";
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
useState(["rerender"], async () => {
|
useState(["rerender"], async () => {
|
||||||
const value = (await _get?.()) ?? ($select.innerText || values[0].value),
|
if (!options.length) return;
|
||||||
icon = values.find((v) => v.value === value)?.icon;
|
const { value, $selection } = await getSelected();
|
||||||
$select.innerHTML = "";
|
$select.innerHTML = "";
|
||||||
// swap icon/value order for correct display when dir="rtl"
|
$select.append($selection);
|
||||||
$select.append(html`<div class="inline-flex items-center gap-[6px]">
|
|
||||||
<span>${value}</span>${icon?.cloneNode?.(true) || ""}
|
|
||||||
</div>`);
|
|
||||||
if (_requireReload) {
|
if (_requireReload) {
|
||||||
_initialValue ??= value;
|
_initialValue ??= value;
|
||||||
if (value !== _initialValue) setState({ databaseUpdated: true });
|
if (value !== _initialValue) setState({ databaseUpdated: true });
|
||||||
@ -81,11 +111,13 @@ function Select({
|
|||||||
|
|
||||||
extendProps(props, { class: "notion-enhancer--menu-select relative" });
|
extendProps(props, { class: "notion-enhancer--menu-select relative" });
|
||||||
return html`<div ...${props}>
|
return html`<div ...${props}>
|
||||||
${$select}
|
${$select}<${Popup}
|
||||||
<${Popup}
|
tabindex="0"
|
||||||
trigger=${$select}
|
trigger=${$select}
|
||||||
mode=${popupMode}
|
mode=${popupMode}
|
||||||
|
onopen=${() => document.addEventListener("keydown", onKeydown, true)}
|
||||||
onbeforeclose=${() => {
|
onbeforeclose=${() => {
|
||||||
|
document.removeEventListener("keydown", onKeydown, true);
|
||||||
$select.style.width = `${$select.offsetWidth}px`;
|
$select.style.width = `${$select.offsetWidth}px`;
|
||||||
$select.style.background = "transparent";
|
$select.style.background = "transparent";
|
||||||
}}
|
}}
|
||||||
@ -93,9 +125,7 @@ function Select({
|
|||||||
$select.style.width = "";
|
$select.style.width = "";
|
||||||
$select.style.background = "";
|
$select.style.background = "";
|
||||||
}}
|
}}
|
||||||
>${values.map((value) => {
|
>${options.map(({ $option }) => $option)}
|
||||||
return html`<${Option} ...${{ ...value, _get, _set }} />`;
|
|
||||||
})}
|
|
||||||
<//>
|
<//>
|
||||||
<i
|
<i
|
||||||
class="i-chevron-down pointer-events-none
|
class="i-chevron-down pointer-events-none
|
||||||
|
@ -19,6 +19,9 @@
|
|||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 3px solid var(--theme--accent-primary);
|
outline: 3px solid var(--theme--accent-primary);
|
||||||
}
|
}
|
||||||
|
*:focus-visible[role="option"] {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
@ -169,7 +169,9 @@ const renderMenu = async () => {
|
|||||||
});
|
});
|
||||||
addKeyListener("Escape", () => {
|
addKeyListener("Escape", () => {
|
||||||
const [popupOpen] = useState(["popupOpen"]);
|
const [popupOpen] = useState(["popupOpen"]);
|
||||||
if (!popupOpen) {
|
if (document.activeElement?.tagName === "INPUT") {
|
||||||
|
document.activeElement.blur();
|
||||||
|
} else if (!popupOpen) {
|
||||||
const msg = { channel: "notion-enhancer", action: "close-menu" };
|
const msg = { channel: "notion-enhancer", action: "close-menu" };
|
||||||
parent?.postMessage(msg, "*");
|
parent?.postMessage(msg, "*");
|
||||||
} else setState({ rerender: true });
|
} else setState({ rerender: true });
|
||||||
|
Loading…
Reference in New Issue
Block a user