Skip to content

Commit

Permalink
[duoyun-ui] Support arrow navigation
Browse files Browse the repository at this point in the history
Closed #120
  • Loading branch information
mantou132 committed Jan 27, 2024
1 parent 34d4585 commit 3fbc284
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 97 deletions.
22 changes: 9 additions & 13 deletions packages/duoyun-ui/src/elements/contextmenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,9 +227,7 @@ export class DuoyunContextmenuElement extends GemElement {
],
});
} else {
update({
menuStack: menuStack.slice(0, menuStackIndex + 1),
});
update({ menuStack: menuStack.slice(0, menuStackIndex + 1) });
}
};

Expand All @@ -242,16 +240,11 @@ export class DuoyunContextmenuElement extends GemElement {

#onKeydown = (evt: KeyboardEvent, menuStackIndex: number) => {
evt.stopPropagation();
const focusPrevMenu = () => {
update({
menuStack: contextmenuStore.menuStack.slice(0, menuStackIndex),
});
this.#menuElements[menuStackIndex - 1]?.focus();
};
hotkeys({
esc: menuStackIndex === 0 ? ContextMenu.close : focusPrevMenu,
left: focusPrevMenu,
right: () => this.#menuElements[menuStackIndex + 1]?.focus(),
esc:
menuStackIndex === 0
? ContextMenu.close
: () => update({ menuStack: contextmenuStore.menuStack.slice(0, menuStackIndex) }),
})(evt);
};

Expand Down Expand Up @@ -289,7 +282,10 @@ export class DuoyunContextmenuElement extends GemElement {
};

mounted = () => {
this.#menuElements.shift()?.focus();
this.effect(
() => this.#menuElements.at(-1)?.focus(),
() => [contextmenuStore.menuStack.length],
);
const restoreInert = setBodyInert(this);
ContextMenu.instance = this;
this.addEventListener('contextmenu', this.#preventDefault);
Expand Down
4 changes: 4 additions & 0 deletions packages/duoyun-ui/src/elements/date-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ export class DuoyunDatePanelElement extends GemElement<State> {
${Array.from({ length: 12 }).map(
(_, index) => html`
<span
tabindex="0"
role="button"
class=${classMap({
item: true,
highlight: isCurrentYear && index === this.state.month,
Expand All @@ -284,6 +286,8 @@ export class DuoyunDatePanelElement extends GemElement<State> {
${Array.from({ length: 12 }, (_, index) => this.state.year - 7 + index).map(
(year) => html`
<span
tabindex="0"
role="button"
class=${classMap({
item: true,
highlight: currentYear === year,
Expand Down
207 changes: 139 additions & 68 deletions packages/duoyun-ui/src/elements/keyboard-access.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
import { GemElement, html } from '@mantou/gem/lib/element';
import { adoptedStyle, customElement, attribute, part, property } from '@mantou/gem/lib/decorators';
import { adoptedStyle, customElement, attribute, part, property, emitter, Emitter } from '@mantou/gem/lib/decorators';
import { addListener, createCSSSheet, css, styleMap } from '@mantou/gem/lib/utils';
import { logger } from '@mantou/gem/helper/logger';

import { hotkeys, HotKeyHandles, unlock } from '../lib/hotkeys';
import { isNotNullish } from '../lib/types';
import { theme } from '../lib/theme';
import { contentsContainer } from '../lib/styles';
import { closestElement, findActiveElement, isInputElement } from '../lib/element';

import { Toast } from './toast';
import { DuoyunLinkElement } from './link';

import 'deep-query-selector';
import './paragraph';

const getFocusableElements = () => {
const get = (root: Document | Element) =>
root
.deepQuerySelectorAll('>>> :is(input,textarea,button,select,area,summary,audio,video,[tabindex],a[href])')
.map((element) => {
// details 内容 Chrome 能检测到尺寸,Bug?
if (element.checkVisibility && !element.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true })) {
return;
}
if (
(element as unknown as HTMLOrSVGElement).tabIndex < 0 ||
(element as any).disabled ||
(element as GemElement).internals?.ariaDisabled === 'true' ||
(element as GemElement).internals?.ariaHidden === 'true' ||
closestElement(element, ':is([inert],[disabled],[aria-disabled=true],[aria-hidden=true])')
) {
return;
}
const rect = element.getBoundingClientRect();
if (!rect.width || !rect.height) {
return;
}
return { rect, element };
})
.filter(isNotNullish);

const elements = get(document);
return elements.filter(({ element }) => {
return get(element).length === 0;
});
};

const style = createCSSSheet(css`
:host {
font-size: 0.75em;
Expand All @@ -38,6 +72,7 @@ type FocusableElement = {
key: string;
top: number;
left: number;
element: Element;
};

type State = {
Expand All @@ -47,14 +82,7 @@ type State = {
focusableElements?: FocusableElement[];
};

/**
* a,b,b...,y,za,zb...,zy
*/
function getChars(index: number) {
if (index > 50) return;
const prefix = index >= 25 ? 'z' : '';
return prefix + String.fromCharCode(97 + (index % 25));
}
export type NavigationDirection = 'up' | 'down' | 'left' | 'right';

/**
* @customElement dy-keyboard-access
Expand All @@ -69,6 +97,8 @@ export class DuoyunKeyboardAccessElement extends GemElement<State> {

@property scrollContainer?: HTMLElement;

@emitter navigation: Emitter<NavigationDirection>;

get #activeKey() {
return this.activekey || 'f';
}
Expand All @@ -83,81 +113,71 @@ export class DuoyunKeyboardAccessElement extends GemElement<State> {
keydownHandles: {},
};

#isInputTarget(evt: Event) {
const originElement = evt.composedPath()[0] as HTMLElement;
if (
originElement.isContentEditable ||
originElement instanceof HTMLInputElement ||
originElement instanceof HTMLTextAreaElement
) {
return true;
}
}
#onActive = () => {
const focusableElements = getFocusableElements()
.map(({ rect, element }) => {
const { top, left, right, bottom, width, height } = rect;
if (right < 0 || bottom < 0 || left > innerWidth || top > innerHeight) {
return;
}
// https://bugzilla.mozilla.org/show_bug.cgi?id=1750907
// https://bugs.chromium.org/p/chromium/issues/detail?id=1188919&q=elementFromPoint&can=2
const root = element.getRootNode() as ShadowRoot | Document;
const elementsFromLeftTop = root.elementsFromPoint(left + 2, top + 2);
const elementsFromRightBottom = root.elementsFromPoint(left + width - 2, top + height - 2);
// `elementsFromPoint` 不包含 SVG a 元素,不知道原因
const elements = [...elementsFromLeftTop, ...elementsFromRightBottom].map((e) =>
e instanceof SVGElement ? e.closest('a') : e,
);
if (!elements.includes(element)) {
return;
}

#onActive = (evt: KeyboardEvent) => {
if (this.#isInputTarget(evt)) return;
const { active } = this.state;
if (active) return;
return { key: '', top, left, element };
})
.filter(isNotNullish)
.map((e, index) => {
// a,b,c...,y,za,zb...,zy
if (index >= 50) return;
const prefix = index >= 25 ? 'z' : '';
e.key = prefix + String.fromCharCode(97 + (index % 25));
return e;
})
.filter(isNotNullish);

const elements = document.deepQuerySelectorAll(
'>>> :is([tabindex],input,textarea,button,select,area,a[href])',
) as HTMLElement[];
if (!elements.length) {
if (!focusableElements.length) {
Toast.open('default', 'Not found focusable element');
return;
}

let index = 0;

const keydownHandles: HotKeyHandles = {
esc: this.#onInactive,
onLock: () => this.setState({ waiting: true }),
onUnlock: () => this.setState({ waiting: false }),
onUncapture: () => logger.warn('Un Capture!'),
};

focusableElements.forEach(({ key, element }) => {
// `a-b`
keydownHandles[[...key].join('-')] = () => {
this.setState({ active: false });
if (element instanceof HTMLElement) {
// BasePickerElement 的 `showPicker` 一样支持通过 `click` 触发
element.focus();
element.click();
} else if (element instanceof SVGAElement) {
const link = new DuoyunLinkElement();
link.href = element.getAttribute('href')!;
link.click();
}
};
});

this.setState({
active: true,
waiting: false,
keydownHandles,
focusableElements: elements
.map((element) => {
const { top, left, right, bottom, width, height } = element.getBoundingClientRect();
if (
(element as any).disabled ||
element.inert ||
element.tabIndex < 0 ||
!width ||
!height ||
right < 0 ||
bottom < 0 ||
left > innerWidth ||
top > innerHeight
) {
return;
}
// https://bugzilla.mozilla.org/show_bug.cgi?id=1750907
// https://bugs.chromium.org/p/chromium/issues/detail?id=1188919&q=elementFromPoint&can=2
const root = element.getRootNode() as ShadowRoot | (Document & { host: undefined });
const elementsFromLeftTop = root.elementsFromPoint(left + 2, top + 2);
const elementsFromRightBottom = root.elementsFromPoint(left + width - 2, top + height - 2);
if (!elementsFromLeftTop.includes(element) && !elementsFromRightBottom.includes(element)) {
return;
}

const key = getChars(index);
if (!key) return;
// `a-b`
keydownHandles[[...key].join('-')] = () => {
this.setState({ active: false });
// BasePickerElement 的 `showPicker` 一样支持通过 `click` 触发
element.focus();
element.click();
};
index++;
return { key, top, left };
})
.filter(isNotNullish),
focusableElements,
});
};

Expand All @@ -172,16 +192,67 @@ export class DuoyunKeyboardAccessElement extends GemElement<State> {
this.setState({ active: false });
};

#onNavigation = (dir: NavigationDirection) => {
const activeElement = findActiveElement();
const focusableElements = getFocusableElements();
const current = focusableElements.find(({ element }) => activeElement === element);
const currentRect = current?.rect || {
left: 0,
right: 0,
top: 0,
bottom: 0,
};
const elements = focusableElements
.filter((ele) => {
return ele !== current;
})
.filter(({ rect }) => {
if (!current) return true;
switch (dir) {
case 'down':
return rect.bottom > currentRect.bottom;
case 'up':
return rect.top < currentRect.top;
case 'right':
return rect.right > currentRect.right;
case 'left':
return rect.left < currentRect.left;
}
})
.sort((a, b) => {
const getPoint = (rect: typeof currentRect) => [(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2];
const originPoint = getPoint(currentRect);
const getDistance = (rect: typeof currentRect) => {
const point = getPoint(rect);
const isHorizontal = dir === 'left' || dir === 'right';
const weights = [isHorizontal ? 1 : 100, !isHorizontal ? 1 : 100];
return Math.sqrt(
(point[0] - originPoint[0]) ** 2 * weights[0] + (point[1] - originPoint[1]) ** 2 * weights[1],
);
};
return getDistance(a.rect) - getDistance(b.rect);
});

if (elements.length) {
(elements[0].element as any).focus?.();
this.navigation(dir);
}
};

#onKeydown = (evt: KeyboardEvent) => {
if (this.state.active) return;
if (this.#isInputTarget(evt)) return;
if (isInputElement(evt.composedPath()[0] as HTMLElement)) return;
hotkeys(
{
[this.#activeKey]: this.#onActive,
j: () => this.#container.scrollBy(0, -innerHeight / 3),
k: () => this.#container.scrollBy(0, innerHeight / 3),
h: () => this.#container.scrollBy(0, -innerHeight),
l: () => this.#container.scrollBy(0, innerHeight),
down: () => this.#onNavigation('down'),
up: () => this.#onNavigation('up'),
left: () => this.#onNavigation('left'),
right: () => this.#onNavigation('right'),
},
{ stopPropagation: true },
)(evt);
Expand Down
1 change: 0 additions & 1 deletion packages/duoyun-ui/src/elements/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,6 @@ export class DuoyunOptionsElement extends GemElement<State> {
delete: !!onRemove,
})}
@pointerenter=${onPointerEnter}
@focus=${onPointerEnter}
@pointerleave=${onPointerLeave}
@pointerdown=${onPointerDown}
@pointerup=${onPointerUp}
Expand Down
29 changes: 29 additions & 0 deletions packages/duoyun-ui/src/lib/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,32 @@ export function findRanges(root: Node, text: string) {
}
return ranges;
}

export function findActiveElement() {
let element = document.activeElement;
while (element) {
if (!element.shadowRoot?.activeElement) break;
element = element.shadowRoot.activeElement;
}
return element;
}

export function isInputElement(originElement: HTMLElement) {
if (
originElement.isContentEditable ||
originElement instanceof HTMLInputElement ||
originElement instanceof HTMLTextAreaElement
) {
return true;
}
}

export function closestElement(ele: Element, selector: string) {
let node: Element | null = ele;
while (node) {
const e = node.closest(selector);
if (e) return e;
node = (node.getRootNode() as ShadowRoot).host;
}
return null;
}
2 changes: 1 addition & 1 deletion packages/duoyun-ui/src/patterns/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const style = createCSSSheet(css`
margin-block-end: 1.8em;
}
summary {
cursor: default;
cursor: pointer;
display: flex;
align-items: center;
gap: 1px;
Expand Down
Loading

0 comments on commit 3fbc284

Please sign in to comment.