From 9bf80aec4ff5067ea2b04ede5f17d60321222e8d Mon Sep 17 00:00:00 2001
From: mantou132 <709922234@qq.com>
Date: Sat, 27 Jan 2024 15:25:16 +0800
Subject: [PATCH] [duoyun-ui] Support arrow navigation
Closed #120
---
.../duoyun-ui/docs/en/02-elements/form.md | 2 +-
.../duoyun-ui/docs/zh/02-elements/form.md | 2 +-
packages/duoyun-ui/package.json | 2 +-
.../duoyun-ui/src/elements/contextmenu.ts | 22 +-
packages/duoyun-ui/src/elements/date-panel.ts | 6 +
packages/duoyun-ui/src/elements/form.ts | 34 +--
.../duoyun-ui/src/elements/keyboard-access.ts | 207 ++++++++++++------
packages/duoyun-ui/src/elements/options.ts | 1 -
packages/duoyun-ui/src/lib/cache.ts | 20 +-
packages/duoyun-ui/src/lib/element.ts | 38 ++++
packages/duoyun-ui/src/patterns/form.ts | 2 +-
packages/gem/src/lib/element.ts | 23 +-
12 files changed, 237 insertions(+), 122 deletions(-)
diff --git a/packages/duoyun-ui/docs/en/02-elements/form.md b/packages/duoyun-ui/docs/en/02-elements/form.md
index 3da4d9b7..96f2bcef 100644
--- a/packages/duoyun-ui/docs/en/02-elements/form.md
+++ b/packages/duoyun-ui/docs/en/02-elements/form.md
@@ -6,7 +6,7 @@
name="dy-form"
props='{"style": "width: 100%;", "@change": "(evt) => {Object.keys(evt.detail).forEach(key => evt.target.querySelector(`[name=${key}]`).value = evt.detail[key])}"}'
html='
-
+
diff --git a/packages/duoyun-ui/docs/zh/02-elements/form.md b/packages/duoyun-ui/docs/zh/02-elements/form.md
index 3da4d9b7..96f2bcef 100644
--- a/packages/duoyun-ui/docs/zh/02-elements/form.md
+++ b/packages/duoyun-ui/docs/zh/02-elements/form.md
@@ -6,7 +6,7 @@
name="dy-form"
props='{"style": "width: 100%;", "@change": "(evt) => {Object.keys(evt.detail).forEach(key => evt.target.querySelector(`[name=${key}]`).value = evt.detail[key])}"}'
html='
-
+
diff --git a/packages/duoyun-ui/package.json b/packages/duoyun-ui/package.json
index ac743ce9..8097d24d 100644
--- a/packages/duoyun-ui/package.json
+++ b/packages/duoyun-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "duoyun-ui",
- "version": "1.1.17",
+ "version": "1.1.18",
"description": "A lightweight desktop UI component library, implemented using Gem",
"keywords": [
"frontend",
diff --git a/packages/duoyun-ui/src/elements/contextmenu.ts b/packages/duoyun-ui/src/elements/contextmenu.ts
index 8fd98abc..71659e7c 100644
--- a/packages/duoyun-ui/src/elements/contextmenu.ts
+++ b/packages/duoyun-ui/src/elements/contextmenu.ts
@@ -227,9 +227,7 @@ export class DuoyunContextmenuElement extends GemElement {
],
});
} else {
- update({
- menuStack: menuStack.slice(0, menuStackIndex + 1),
- });
+ update({ menuStack: menuStack.slice(0, menuStackIndex + 1) });
}
};
@@ -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);
};
@@ -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);
diff --git a/packages/duoyun-ui/src/elements/date-panel.ts b/packages/duoyun-ui/src/elements/date-panel.ts
index fe894abb..21d4fe85 100644
--- a/packages/duoyun-ui/src/elements/date-panel.ts
+++ b/packages/duoyun-ui/src/elements/date-panel.ts
@@ -258,6 +258,9 @@ export class DuoyunDatePanelElement extends GemElement {
${Array.from({ length: 12 }).map(
(_, index) => html`
{
${Array.from({ length: 12 }, (_, index) => this.state.year - 7 + index).map(
(year) => html`
dy-form-item {
+ width: 0;
+ flex-grow: 1;
+ }
}
@media ${mediaQuery.PHONE} {
dy-form-item-inline-group {
@@ -161,6 +163,10 @@ const formItemStyle = createCSSSheet(css`
display: flex;
flex-direction: column;
}
+ :host([type='checkbox']) {
+ flex-direction: row;
+ align-items: center;
+ }
:host([required]) .label::after {
content: '*';
}
@@ -178,16 +184,10 @@ const formItemStyle = createCSSSheet(css`
}
.input {
width: 100%;
- flex-grow: 1;
- flex-shrink: 1;
}
.input + .input,
.footer {
- margin-top: 10px;
- }
- :host([type='checkbox']) {
- flex-direction: row;
- align-items: center;
+ margin-top: 0.7em;
}
`);
diff --git a/packages/duoyun-ui/src/elements/keyboard-access.ts b/packages/duoyun-ui/src/elements/keyboard-access.ts
index c2a187e4..34735ede 100644
--- a/packages/duoyun-ui/src/elements/keyboard-access.ts
+++ b/packages/duoyun-ui/src/elements/keyboard-access.ts
@@ -1,5 +1,5 @@
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';
@@ -7,12 +7,46 @@ 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;
@@ -38,6 +72,7 @@ type FocusableElement = {
key: string;
top: number;
left: number;
+ element: Element;
};
type State = {
@@ -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
@@ -69,6 +97,8 @@ export class DuoyunKeyboardAccessElement extends GemElement {
@property scrollContainer?: HTMLElement;
+ @emitter navigation: Emitter;
+
get #activeKey() {
return this.activekey || 'f';
}
@@ -83,32 +113,43 @@ export class DuoyunKeyboardAccessElement extends GemElement {
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 }),
@@ -116,48 +157,27 @@ export class DuoyunKeyboardAccessElement extends GemElement {
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,
});
};
@@ -172,9 +192,56 @@ export class DuoyunKeyboardAccessElement extends GemElement {
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,
@@ -182,6 +249,10 @@ export class DuoyunKeyboardAccessElement extends GemElement {
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);
diff --git a/packages/duoyun-ui/src/elements/options.ts b/packages/duoyun-ui/src/elements/options.ts
index 55372280..0401b1cb 100644
--- a/packages/duoyun-ui/src/elements/options.ts
+++ b/packages/duoyun-ui/src/elements/options.ts
@@ -282,7 +282,6 @@ export class DuoyunOptionsElement extends GemElement {
delete: !!onRemove,
})}
@pointerenter=${onPointerEnter}
- @focus=${onPointerEnter}
@pointerleave=${onPointerLeave}
@pointerdown=${onPointerDown}
@pointerup=${onPointerUp}
diff --git a/packages/duoyun-ui/src/lib/cache.ts b/packages/duoyun-ui/src/lib/cache.ts
index e86ec620..fc23d9ae 100644
--- a/packages/duoyun-ui/src/lib/cache.ts
+++ b/packages/duoyun-ui/src/lib/cache.ts
@@ -3,6 +3,7 @@ import { LinkedList } from '@mantou/gem/lib/utils';
interface CacheOptions {
max?: number;
maxAge?: number;
+ renewal?: boolean;
}
interface CacheItem {
@@ -13,14 +14,16 @@ interface CacheItem {
export class Cache {
#max: number;
#maxAge: number;
+ #renewal: boolean;
#map = new Map>();
#reverseMap = new Map();
#linkedList = new LinkedList();
- constructor({ max = Infinity, maxAge = Infinity }: CacheOptions = {}) {
+ constructor({ max = Infinity, maxAge = Infinity, renewal = false }: CacheOptions = {}) {
this.#max = max;
this.#maxAge = maxAge;
+ this.#renewal = renewal;
}
#trim() {
@@ -37,21 +40,28 @@ export class Cache {
this.#reverseMap.set(value, key);
this.#map.set(key, { value, timestamp: Date.now() });
this.#trim();
+ return value;
}
- get(key: string, callback?: (result: T) => void) {
+ get(key: string): T | undefined;
+ get(key: string, init: (key: string) => T): T;
+ get(key: string, init?: (key: string) => T) {
const cache = this.#map.get(key);
- if (!cache) return;
+ if (!cache) {
+ return init && this.set(key, init(key));
+ }
const { timestamp, value } = cache;
if (Date.now() - timestamp > this.#maxAge) {
this.#linkedList.delete(value);
this.#reverseMap.delete(value);
this.#map.delete(key);
- return;
+ return init && this.set(key, init(key));
+ }
+ if (this.#renewal) {
+ cache.timestamp = Date.now();
}
this.#linkedList.get();
this.#linkedList.add(value);
- callback?.(value);
return value;
}
}
diff --git a/packages/duoyun-ui/src/lib/element.ts b/packages/duoyun-ui/src/lib/element.ts
index d66a4c5b..d31fe8b6 100644
--- a/packages/duoyun-ui/src/lib/element.ts
+++ b/packages/duoyun-ui/src/lib/element.ts
@@ -61,3 +61,41 @@ 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;
+}
+
+export function containsElement(ele: Element, other: Element) {
+ let node: Element | null = other;
+ while (node) {
+ if (ele.contains(node)) return true;
+ node = (node.getRootNode() as ShadowRoot).host;
+ }
+ return false;
+}
diff --git a/packages/duoyun-ui/src/patterns/form.ts b/packages/duoyun-ui/src/patterns/form.ts
index 5e5cd898..754da34e 100644
--- a/packages/duoyun-ui/src/patterns/form.ts
+++ b/packages/duoyun-ui/src/patterns/form.ts
@@ -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;
diff --git a/packages/gem/src/lib/element.ts b/packages/gem/src/lib/element.ts
index 54cb94b9..4418bfb4 100644
--- a/packages/gem/src/lib/element.ts
+++ b/packages/gem/src/lib/element.ts
@@ -419,22 +419,17 @@ export abstract class GemElement> extends HTMLElemen
closestElement(tag: K): HTMLElementTagNameMap[K] | null;
closestElement any>(constructor: K): InstanceType | null;
- closestElement any>(constructorOrTag: K | string): Element | null {
+ closestElement any>(constructorOrTag: K | string) {
const isConstructor = typeof constructorOrTag === 'function';
const tagName = typeof constructorOrTag === 'string' && constructorOrTag.toUpperCase();
- const getRootElement = (ele: Element): Element | null => {
- const rootEle = ele.parentElement || (ele.getRootNode() as ShadowRoot).host;
- if (!rootEle) return null;
- if (isConstructor) {
- if (rootEle.constructor === constructorOrTag) {
- return rootEle;
- }
- } else if (rootEle.tagName === tagName) {
- return rootEle;
- }
- return getRootElement(rootEle);
- };
- return getRootElement(this);
+ const is = (ele: Element) => (isConstructor ? ele.constructor === constructorOrTag : ele.tagName === tagName);
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ let node: Element | null = this;
+ while (node) {
+ if (is(node)) break;
+ node = node.parentElement || (node.getRootNode() as ShadowRoot).host;
+ }
+ return node;
}
/**