@gez/date-time-kit
Version:
351 lines (331 loc) • 12.5 kB
text/typescript
import { closestByEvent, debounce, html } from '../../utils';
import {
type BaseAttrs,
type BaseEmits,
type Emit2EventMap,
UiBase
} from '../web-component-base';
import styleStr from './index.css';
export interface Attrs extends BaseAttrs {
/**
* The current number in the list. The component will scroll to this number when rendered.
* @type {number}
*/
'current-num': number;
/**
* The minimum number in the list (include). If not set, there is no minimum limit.
* @type {number}
* @default -Infinity
*/
'min-num'?: number;
/**
* The maximum number in the list (include). If not set, there is no maximum limit.
* @type {number}
* @default Infinity
*/
'max-num'?: number;
// /**
// * The position to scroll the current number into view.
// * @type {`"center" | "end" | "nearest" | "start"`}
// * @default "start"
// */
// position?: ScrollLogicalPosition;
}
export interface Emits extends BaseEmits {
'select-num': {
oldNum: number;
newNum: number;
};
}
export type EventMap = Emit2EventMap<Emits>;
/**
* 基础的数字列表组件。允许无限滚动。点击后可以滚动定位到当前数字。
*
* 存在一个 formatter 方法,可以重写该方法以自定义数字的显示格式。
*/
export class Ele extends UiBase<Attrs, Emits> {
public static tagName = 'dt-num-list' as const;
protected static _style = styleStr;
protected static _template =
html`<div class="container" part="container"></div>`;
static get observedAttributes(): string[] {
return [
...(super.observedAttributes as (keyof BaseAttrs)[]),
'current-num',
'min-num',
'max-num'
] satisfies (keyof Attrs)[];
}
get _staticEls() {
return {
...super._staticEls,
container: this.$0`.container`!
} as const;
}
get _dynamicEls() {
return {
...super._dynamicEls,
currentItem: this.$0`.item-current`,
items: this.$`.item`
} as const;
}
public get currentNum() {
return Number(this._getAttr('current-num'));
}
public set currentNum(val: number) {
this.setAttribute('current-num', String(val));
}
public get minNum() {
return Number(this._getAttr('min-num', '-Infinity'));
}
public set minNum(val: number) {
let min = +val;
if (Number.isNaN(min)) min = Number.NEGATIVE_INFINITY;
if (min > this.maxNum) [this.maxNum, min] = [min, this.maxNum];
this.setAttribute('min-num', String(val));
}
public get maxNum() {
return Number(this._getAttr('max-num', 'Infinity'));
}
public set maxNum(val: number) {
let max = +val;
if (Number.isNaN(max)) max = Number.POSITIVE_INFINITY;
if (max < this.minNum) [this.minNum, max] = [max, this.minNum];
this.setAttribute('max-num', String(val));
}
private _createItem = (num: number, currentNum = this.currentNum) => {
const ele = document.createElement('div');
ele.setAttribute('part', (ele.className = 'item'));
ele.classList.toggle('item-current', num === currentNum);
ele.part.toggle('item-current', num === currentNum);
ele.dataset.number = num + '';
ele.textContent = this.formatter(num);
return ele;
};
private _intersectionOb: IntersectionObserver | null = null;
private _destroyOb() {
this._intersectionOb?.disconnect();
this._intersectionOb = null;
}
private _initOb() {
this._destroyOb();
if (!this.shadowRoot) return;
this._intersectionOb = new IntersectionObserver(
(
entries: IntersectionObserverEntry[],
observer: IntersectionObserver
) => {
const container = this._els.container;
const firstItem = container.firstElementChild as HTMLElement;
const lastItem = container.lastElementChild as HTMLElement;
for (const {
target,
isIntersecting,
intersectionRatio,
rootBounds,
boundingClientRect
} of entries) {
if (!isIntersecting) continue;
observer.unobserve(target);
// 只有在滚动的时候才会观察当前元素
if (target === this._els.currentItem) {
observer.observe(firstItem!);
observer.observe(lastItem!);
// 继续观察:如果元素没有完全进入可视区域 || 滚动还没有结束
if (
intersectionRatio !== 1 ||
(rootBounds &&
boundingClientRect &&
Math.abs(
rootBounds.top - boundingClientRect.top
) > 2)
) {
observer.observe(target);
} else {
this._isScrolling = false;
}
}
if (target === firstItem) {
this._loadBefore();
} else if (target === lastItem) {
this._loadAfter();
}
}
},
{ root: this }
);
}
private _loadBefore = debounce(() => {
const container = this._els.container;
const firstItem = container.firstElementChild as HTMLElement;
const lastItem = container.lastElementChild as HTMLElement;
const firstNum = Number(firstItem.dataset.number);
const curNum = this.currentNum;
const pageSize = this._pageSize;
const itemHeight = this._itemHeight;
const items = [...Array(pageSize * 2)].map((_, i) =>
this._createItem(firstNum - pageSize * 2 + i, curNum)
);
this._intersectionOb?.unobserve(firstItem);
this._intersectionOb?.unobserve(lastItem);
const scrollTop = this.scrollTop;
container.prepend(...items);
for (let i = 0; i < items.length; ++i) {
container.removeChild(container.lastElementChild!);
}
const addedHeight = items.length * itemHeight;
this.scrollTo({
top: scrollTop + addedHeight,
behavior: 'instant'
});
this._intersectionOb?.observe(items[0]);
this._intersectionOb?.observe(container.lastElementChild!);
if (this._isScrolling) this.scrollToCurrent();
});
private _loadAfter = debounce(() => {
const container = this._els.container;
const firstItem = container.firstElementChild as HTMLElement;
const lastItem = container.lastElementChild as HTMLElement;
const curNum = this.currentNum;
const pageSize = this._pageSize;
const itemHeight = this._itemHeight;
const lastNum = Number(lastItem.textContent);
const items = [...Array(pageSize * 2)].map((_, i) =>
this._createItem(lastNum + i + 1, curNum)
);
this._intersectionOb?.unobserve(firstItem);
this._intersectionOb?.unobserve(lastItem);
const scrollTop = this.scrollTop;
container.append(...items);
for (let i = 0; i < items.length; ++i) {
container.removeChild(container.firstElementChild!);
}
const addedHeight = items.length * itemHeight;
this.scrollTo({
top: scrollTop - addedHeight,
behavior: 'instant'
});
this._intersectionOb?.observe(container.firstElementChild!);
this._intersectionOb?.observe(items[items.length - 1]);
if (this._isScrolling) this.scrollToCurrent();
});
public connectedCallback() {
if (!super.connectedCallback()) return;
this._render();
this.addEventListener('click', this._onClick);
}
public disconnectedCallback() {
this.removeEventListener('click', this._onClick);
this._destroyOb();
return super.disconnectedCallback();
}
protected _onAttrChanged(
name: string,
oldValue: string | null,
newValue: string | null
) {
super._onAttrChanged(name, oldValue, newValue);
// 选中选项后,会更新 dom class,此时触发的更新不需要重新渲染。
// 这里是针对无限滚动时重新渲染会导致元素滚动异常。
if (
name === 'current-num' &&
newValue === this._els.currentItem?.dataset.number
) {
return;
}
this._render();
}
private get _itemHeight() {
const container = this._els.container;
const items = Array.from(
container.querySelectorAll<HTMLElement>('.item')
);
const len = items.length;
if (len === 1) {
container.append(this._createItem(0, 1));
} else if (len === 0) {
container.append(this._createItem(0, 1), this._createItem(0, 1));
}
const h = (container.firstElementChild as HTMLElement).offsetHeight;
const itemNum = Math.max(2, len);
const gap = Math.max(
0,
(container.clientHeight - h * itemNum) / (itemNum - 1)
);
if (len === 0) {
container.removeChild(container.lastElementChild!);
container.removeChild(container.lastElementChild!);
} else if (len === 1) {
container.removeChild(container.lastElementChild!);
}
return h + gap;
}
private get _pageSize() {
const thisHeight = this.clientHeight;
if (thisHeight === 0) return 10;
return Math.min(10, Math.ceil(thisHeight / this._itemHeight));
}
private _isScrolling = false;
public scrollToCurrent = () => {
const ele = this._els.currentItem;
if (!ele) return;
const thisRect = this.getBoundingClientRect();
// 如果当前元素不可见,则不执行滚动
if (thisRect.height === 0) return;
this._intersectionOb?.observe(ele);
this._isScrolling = true;
const eleRect = ele.getBoundingClientRect();
const offsetTop = eleRect.top - thisRect.top + this.scrollTop;
this.scrollTo({
top: offsetTop
});
};
private _render = super._genRenderFn(() => {
this._destroyOb();
const container = this._els.container;
container.innerHTML = '';
if (!this.hasAttribute('current-num')) return;
const currentNum = this.currentNum;
const minNum = this.minNum;
const maxNum = this.maxNum;
if (
minNum === Number.NEGATIVE_INFINITY &&
maxNum === Number.POSITIVE_INFINITY
) {
this._initOb();
const pageSize = this._pageSize;
for (let i = -pageSize * 2; i <= pageSize * 2; ++i) {
container.appendChild(
this._createItem(currentNum + i, currentNum)
);
}
} else
for (let i = minNum; i <= maxNum; ++i) {
container.appendChild(this._createItem(i, currentNum));
}
setTimeout(this.scrollToCurrent, 0);
});
private _onClick = (e: MouseEvent) => {
if (!this.isConnected) return;
const container = this._els.container;
const oldCurrent =
container.querySelector<HTMLElement>('.item-current');
const item = closestByEvent(e, '.item', this);
if (!item || item === oldCurrent) return;
oldCurrent?.classList.remove('item-current');
oldCurrent?.part.remove('item-current');
item.classList.add('item-current');
item.part.add('item-current');
this.scrollToCurrent();
super.dispatchEvent(
'select-num',
{
oldNum: +(oldCurrent?.dataset.number ?? this.currentNum),
newNum: +item.dataset.number!
},
true
);
};
public formatter = (num: number) => '' + num;
}
Ele.define();