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..9e4aab13 100644 --- a/packages/duoyun-ui/src/elements/date-panel.ts +++ b/packages/duoyun-ui/src/elements/date-panel.ts @@ -258,6 +258,8 @@ 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` { + 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/element.ts b/packages/duoyun-ui/src/lib/element.ts index d66a4c5b..76ee76d3 100644 --- a/packages/duoyun-ui/src/lib/element.ts +++ b/packages/duoyun-ui/src/lib/element.ts @@ -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; +} 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; } /**