175 lines
7.5 KiB
TypeScript
175 lines
7.5 KiB
TypeScript
|
import { $Element } from "..";
|
||
|
|
||
|
export class $FocusManager {
|
||
|
layerMap = new Map<number, $FocusLayer>();
|
||
|
currentLayer?: $FocusLayer;
|
||
|
historyList: $FocusLayer[] = [];
|
||
|
constructor() {}
|
||
|
|
||
|
layer(id: number) {
|
||
|
const layer = this.layerMap.get(id) ?? new $FocusLayer(id);
|
||
|
this.layerMap.set(layer.id, layer);
|
||
|
return layer;
|
||
|
}
|
||
|
next() { return this.select($FocusNavigation.Next) }
|
||
|
prev() { return this.select($FocusNavigation.Prev) }
|
||
|
up() { return this.select($FocusNavigation.Up) }
|
||
|
down() { return this.select($FocusNavigation.Down) }
|
||
|
right() { return this.select($FocusNavigation.Right) }
|
||
|
left() { return this.select($FocusNavigation.Left) }
|
||
|
|
||
|
blur() {
|
||
|
this.currentLayer?.blur();
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
select(navigation: $FocusNavigation) {
|
||
|
this.currentLayer = this.currentLayer ?? [...this.layerMap.values()].at(0);
|
||
|
if (!this.currentLayer) return this;
|
||
|
const $focused = this.currentLayer.currentFocus;
|
||
|
const eleList = this.currentLayer.elementSet.array;
|
||
|
if (!$focused) { this.currentLayer.focus(this.currentLayer.beforeBlur ?? eleList.at(0)); return this; }
|
||
|
const eleIndex = eleList.indexOf($focused)
|
||
|
switch (navigation) {
|
||
|
case $FocusNavigation.Next:
|
||
|
case $FocusNavigation.Prev: {
|
||
|
let targetIndex = navigation === 0 ? eleIndex + 1 : eleIndex - 1;
|
||
|
if (targetIndex === eleList.length && this.currentLayer.loop()) targetIndex = 0;
|
||
|
else if (targetIndex === -1 && !this.currentLayer.loop()) targetIndex = 0;
|
||
|
this.currentLayer.focus(eleList.at(targetIndex));
|
||
|
break;
|
||
|
}
|
||
|
case $FocusNavigation.Down:
|
||
|
case $FocusNavigation.Left:
|
||
|
case $FocusNavigation.Right:
|
||
|
case $FocusNavigation.Up: {
|
||
|
const focusedPosition = $focused.coordinate();
|
||
|
if (!focusedPosition) break;
|
||
|
const focusedCoordinate = $.call(() => {
|
||
|
switch (navigation) {
|
||
|
case $FocusNavigation.Up: return {y: focusedPosition.y, x: focusedPosition.x / 2}
|
||
|
case $FocusNavigation.Down: return {y: focusedPosition.y + focusedPosition.height, x: focusedPosition.x / 2}
|
||
|
case $FocusNavigation.Left: return {y: focusedPosition.y / 2, x: focusedPosition.x}
|
||
|
case $FocusNavigation.Right: return {y: focusedPosition.y / 2, x: focusedPosition.x + focusedPosition.width}
|
||
|
}
|
||
|
})
|
||
|
const eleInfoList = eleList.map($ele => {
|
||
|
if ($ele === $focused) return;
|
||
|
const elePosition = $ele.coordinate();
|
||
|
if (!elePosition) return;
|
||
|
const eleCoordinate = $.call(() => {
|
||
|
switch (navigation) {
|
||
|
case $FocusNavigation.Up: return {y: elePosition.y + elePosition.height, x: elePosition.x / 2};
|
||
|
case $FocusNavigation.Down: return {y: elePosition.y, x: elePosition.x / 2};
|
||
|
case $FocusNavigation.Left: return {y: elePosition.y / 2, x: elePosition.x + elePosition.width};
|
||
|
case $FocusNavigation.Right: return {y: elePosition.y / 2, x: elePosition.x};
|
||
|
}
|
||
|
})
|
||
|
return {
|
||
|
$ele, elePosition,
|
||
|
distance: Math.sqrt((eleCoordinate.x - focusedCoordinate.x) ** 2 + (eleCoordinate.y - focusedCoordinate.y) ** 2)
|
||
|
}
|
||
|
}).detype(undefined).filter(({elePosition}) => {
|
||
|
switch (navigation) {
|
||
|
case $FocusNavigation.Up: if (elePosition.y + elePosition.height >= focusedPosition.y) return false; break;
|
||
|
case $FocusNavigation.Down: if (elePosition.y <= focusedPosition.y + focusedPosition.height) return false; break;
|
||
|
case $FocusNavigation.Left: if (elePosition.x + elePosition.width >= focusedPosition.x) return false; break;
|
||
|
case $FocusNavigation.Right: if (elePosition.x <= focusedPosition.x + focusedPosition.width) return false; break;
|
||
|
}
|
||
|
return true;
|
||
|
})
|
||
|
const $target = eleInfoList.sort((a, b) => a.distance - b.distance).at(0)?.$ele;
|
||
|
this.currentLayer.focus($target);
|
||
|
}
|
||
|
}
|
||
|
return this;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export enum $FocusNavigation { Next, Prev, Up, Down, Right, Left }
|
||
|
|
||
|
export class $FocusLayer {
|
||
|
id: number;
|
||
|
elementSet = new Set<$Element>();
|
||
|
entrySet = new Set<$Element>();
|
||
|
beforeBlur?: $Element;
|
||
|
currentFocus?: $Element;
|
||
|
private __$property__ = {
|
||
|
loop: true,
|
||
|
scrollThreshold: 0
|
||
|
}
|
||
|
constructor(id: number) {
|
||
|
this.id = id
|
||
|
this.add = this.add.bind(this);
|
||
|
this.entry = this.entry.bind(this);
|
||
|
}
|
||
|
|
||
|
add($elements: OrArray<$Element>) {
|
||
|
$.orArrayResolve($elements).forEach($element => {
|
||
|
this.elementSet.add($element);
|
||
|
$element.tabIndex(0);
|
||
|
});
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
remove($element: $Element) {
|
||
|
this.elementSet.delete($element);
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
entry($elements: OrArray<$Element>) {
|
||
|
$.orArrayResolve($elements).forEach(this.entrySet.add.bind(this.entrySet))
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
focus($element: $Element | undefined) {
|
||
|
if (!$element) return this;
|
||
|
$element.hide(false);
|
||
|
const {scrollTop, scrollLeft} = document.documentElement;
|
||
|
const position = $.call(() => {
|
||
|
const rect = $element.domRect()
|
||
|
return {
|
||
|
left: rect.left + scrollLeft,
|
||
|
top: rect.top + scrollTop,
|
||
|
right: rect.right + scrollLeft,
|
||
|
bottom: rect.bottom + scrollTop,
|
||
|
height: rect.height,
|
||
|
width: rect.width
|
||
|
}
|
||
|
})
|
||
|
const {scrollThreshold} = this.__$property__;
|
||
|
this.blur();
|
||
|
this.currentFocus = $element;
|
||
|
if (scrollTop > position.top - scrollThreshold // scroll after item threshold
|
||
|
|| scrollTop > position.bottom + scrollThreshold
|
||
|
) document.documentElement.scrollTo({left: position.left - scrollThreshold, top: position.top - scrollThreshold});
|
||
|
if (scrollTop + innerHeight < position.top + scrollThreshold // scroll before item
|
||
|
|| scrollTop + innerHeight < position.bottom + scrollThreshold
|
||
|
) document.documentElement.scrollTo({left: position.left - scrollThreshold, top: (position.bottom - innerHeight) + scrollThreshold});
|
||
|
$element.attribute('focus', '')
|
||
|
$element.focus({preventScroll: true});
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
blur() {
|
||
|
if (!this.currentFocus) return this;
|
||
|
this.beforeBlur = this.currentFocus;
|
||
|
this.currentFocus.attribute('focus', null);
|
||
|
this.currentFocus?.blur();
|
||
|
this.currentFocus = undefined;
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
removeAll() {
|
||
|
this.elementSet.clear();
|
||
|
return this;
|
||
|
}
|
||
|
|
||
|
loop(): boolean;
|
||
|
loop(boolean: boolean): this;
|
||
|
loop(boolean?: boolean) { return $.fluent(this, arguments, () => this.__$property__.loop, () => $.set(this.__$property__, 'loop', boolean)) }
|
||
|
|
||
|
scrollThreshold(): number;
|
||
|
scrollThreshold(number: number): this;
|
||
|
scrollThreshold(number?: number) { return $.fluent(this, arguments, () => this.__$property__.scrollThreshold, () => $.set(this.__$property__, 'scrollThreshold', number)) }
|
||
|
}
|