mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-08-13 00:19:17 +02:00
6.0
This commit is contained in:
270
src/web-components/bx-key-binding-button.ts
Executable file
270
src/web-components/bx-key-binding-button.ts
Executable file
@@ -0,0 +1,270 @@
|
||||
import type { PrompFont } from "@/enums/prompt-font";
|
||||
import { KeyHelper, type KeyEventInfo } from "@/modules/mkb/key-helper";
|
||||
import { BxEvent } from "@/utils/bx-event";
|
||||
import { CE } from "@/utils/html";
|
||||
import { t } from "@/utils/translation";
|
||||
|
||||
export const enum BxKeyBindingButtonFlag {
|
||||
KEYBOARD_PRESS = 1,
|
||||
KEYBOARD_MODIFIER = 2,
|
||||
MOUSE_CLICK = 4,
|
||||
MOUSE_WHEEL = 8,
|
||||
}
|
||||
|
||||
export type BxKeyBindingButtonOptions = {
|
||||
title: string | PrompFont;
|
||||
isPrompt: boolean;
|
||||
onChanged: (e: Event) => void;
|
||||
|
||||
allowedFlags: BxKeyBindingButtonFlag[];
|
||||
};
|
||||
|
||||
type KeyBindingDialogOptions = {
|
||||
$elm: BxKeyBindingButton;
|
||||
};
|
||||
|
||||
|
||||
export class BxKeyBindingButton extends HTMLButtonElement {
|
||||
title!: string;
|
||||
isPrompt = false;
|
||||
allowedFlags!: BxKeyBindingButtonFlag[];
|
||||
keyInfo: KeyEventInfo | null = null;
|
||||
|
||||
// Fake methods
|
||||
bindKey!: typeof BxKeyBindingButton['bindKey'];
|
||||
unbindKey!: typeof BxKeyBindingButton['unbindKey'];
|
||||
|
||||
static create(options: BxKeyBindingButtonOptions) {
|
||||
const $btn = CE<BxKeyBindingButton>('button', {
|
||||
class: 'bx-binding-button bx-focusable',
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
$btn.title = options.title;
|
||||
$btn.isPrompt = !!options.isPrompt;
|
||||
$btn.allowedFlags = options.allowedFlags;
|
||||
|
||||
$btn.bindKey = BxKeyBindingButton.bindKey.bind($btn);
|
||||
$btn.unbindKey = BxKeyBindingButton.unbindKey.bind($btn);
|
||||
|
||||
$btn.addEventListener('click', BxKeyBindingButton.onClick.bind($btn))
|
||||
$btn.addEventListener('contextmenu', BxKeyBindingButton.onContextMenu);
|
||||
$btn.addEventListener('change', options.onChanged);
|
||||
|
||||
return $btn;
|
||||
}
|
||||
|
||||
private static onClick(this: BxKeyBindingButton, e: Event) {
|
||||
KeyBindingDialog.getInstance().show({
|
||||
$elm: this,
|
||||
});
|
||||
}
|
||||
|
||||
private static onContextMenu = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const $btn = e.target as BxKeyBindingButton;
|
||||
if (!$btn.disabled) {
|
||||
$btn.unbindKey.apply($btn);
|
||||
}
|
||||
}
|
||||
|
||||
private static bindKey(this: BxKeyBindingButton, key: KeyEventInfo | null, force=false) {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (force || this.keyInfo === null || key.code !== this.keyInfo?.code || key.modifiers !== this.keyInfo?.modifiers) {
|
||||
this.textContent = KeyHelper.codeToKeyName(key);
|
||||
this.keyInfo = key;
|
||||
|
||||
if (!force) {
|
||||
BxEvent.dispatch(this, 'change');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static unbindKey(this: BxKeyBindingButton, force=false) {
|
||||
this.textContent = '';
|
||||
this.keyInfo = null;
|
||||
|
||||
!force && BxEvent.dispatch(this, 'change');
|
||||
}
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class KeyBindingDialog {
|
||||
private static instance: KeyBindingDialog;
|
||||
public static getInstance = () => KeyBindingDialog.instance ?? (KeyBindingDialog.instance = new KeyBindingDialog());
|
||||
|
||||
$dialog: HTMLElement;
|
||||
$wait!: HTMLElement;
|
||||
$title: HTMLElement;
|
||||
$inputList: HTMLElement;
|
||||
$overlay: HTMLElement;
|
||||
|
||||
$currentElm!: BxKeyBindingButton;
|
||||
countdownIntervalId!: number | null;
|
||||
|
||||
constructor() {
|
||||
// Create dialog overlay
|
||||
this.$overlay = CE('div', { class: 'bx-key-binding-dialog-overlay bx-gone' });
|
||||
|
||||
// Disable right click
|
||||
this.$overlay.addEventListener('contextmenu', e => e.preventDefault());
|
||||
document.documentElement.appendChild(this.$overlay);
|
||||
|
||||
this.$dialog = CE('div', { class: `bx-key-binding-dialog bx-gone` },
|
||||
this.$title = CE('h2', {}),
|
||||
CE('div', { class: 'bx-key-binding-dialog-content' },
|
||||
CE('div', {},
|
||||
this.$wait = CE('p', { class: 'bx-blink-me' }),
|
||||
this.$inputList = CE('ul', {},
|
||||
CE('li', { _dataset: { flag: BxKeyBindingButtonFlag.KEYBOARD_PRESS } }, t('keyboard-key')),
|
||||
CE('li', { _dataset: { flag: BxKeyBindingButtonFlag.KEYBOARD_MODIFIER } }, t('modifiers-note')),
|
||||
CE('li', { _dataset: { flag: BxKeyBindingButtonFlag.MOUSE_CLICK } }, t('mouse-click')),
|
||||
CE('li', { _dataset: { flag: BxKeyBindingButtonFlag.MOUSE_WHEEL } }, t('mouse-wheel')),
|
||||
),
|
||||
CE('i', {}, t('press-esc-to-cancel')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Disable right click
|
||||
this.$dialog.addEventListener('contextmenu', e => e.preventDefault());
|
||||
|
||||
document.documentElement.appendChild(this.$dialog);
|
||||
}
|
||||
|
||||
show(options: KeyBindingDialogOptions) {
|
||||
this.$currentElm = options.$elm;
|
||||
this.addEventListeners();
|
||||
|
||||
const allowedFlags = this.$currentElm.allowedFlags;
|
||||
this.$inputList.dataset.flags = '[' + allowedFlags.join('][') + ']';
|
||||
|
||||
// Clear focus
|
||||
document.activeElement && (document.activeElement as HTMLElement).blur();
|
||||
|
||||
this.$title.textContent = this.$currentElm.title;
|
||||
this.$title.classList.toggle('bx-prompt', this.$currentElm.isPrompt);
|
||||
|
||||
this.$dialog.classList.remove('bx-gone');
|
||||
this.$overlay.classList.remove('bx-gone');
|
||||
|
||||
// Start counting down
|
||||
this.startCountdown();
|
||||
}
|
||||
|
||||
private startCountdown() {
|
||||
this.stopCountdown();
|
||||
|
||||
let count = 9;
|
||||
this.$wait.textContent = `[${count}] ${t('waiting-for-input')}`;
|
||||
|
||||
this.countdownIntervalId = window.setInterval(() => {
|
||||
count -= 1;
|
||||
if (count === 0) {
|
||||
this.stopCountdown();
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.$wait.textContent = `[${count}] ${t('waiting-for-input')}`;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private stopCountdown() {
|
||||
this.countdownIntervalId && clearInterval(this.countdownIntervalId);
|
||||
this.countdownIntervalId = null;
|
||||
}
|
||||
|
||||
private hide = () => {
|
||||
this.clearEventListeners();
|
||||
|
||||
this.$dialog.classList.add('bx-gone');
|
||||
this.$overlay.classList.add('bx-gone');
|
||||
}
|
||||
|
||||
private addEventListeners() {
|
||||
const allowedFlags = this.$currentElm.allowedFlags;
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.KEYBOARD_PRESS)) {
|
||||
window.addEventListener('keyup', this);
|
||||
}
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.MOUSE_CLICK)) {
|
||||
window.addEventListener('mousedown', this);
|
||||
}
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.MOUSE_WHEEL)) {
|
||||
window.addEventListener('wheel', this);
|
||||
}
|
||||
}
|
||||
|
||||
private clearEventListeners() {
|
||||
window.removeEventListener('keyup', this);
|
||||
window.removeEventListener('mousedown', this);
|
||||
window.removeEventListener('wheel', this);
|
||||
}
|
||||
|
||||
handleEvent(e: Event) {
|
||||
const allowedFlags = this.$currentElm.allowedFlags;
|
||||
let handled = false;
|
||||
let valid = false;
|
||||
|
||||
switch (e.type) {
|
||||
case 'wheel':
|
||||
handled = true;
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.MOUSE_WHEEL)) {
|
||||
valid = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'mousedown':
|
||||
handled = true;
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.MOUSE_CLICK)) {
|
||||
valid = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'keyup':
|
||||
handled = true;
|
||||
|
||||
if (allowedFlags.includes(BxKeyBindingButtonFlag.KEYBOARD_PRESS)) {
|
||||
const keyboardEvent = e as KeyboardEvent;
|
||||
valid = keyboardEvent.code !== 'Escape';
|
||||
|
||||
if (valid && allowedFlags.includes(BxKeyBindingButtonFlag.KEYBOARD_MODIFIER)) {
|
||||
const key = keyboardEvent.key;
|
||||
valid = key !== 'Control' && key !== 'Shift' && key !== 'Alt';
|
||||
handled = valid;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (valid) {
|
||||
this.$currentElm.bindKey(KeyHelper.getKeyFromEvent(e));
|
||||
this.stopCountdown();
|
||||
} else {
|
||||
// Restart countDown
|
||||
this.startCountdown();
|
||||
}
|
||||
|
||||
// Prevent activating the key binding dialog by accident
|
||||
window.setTimeout(this.hide, 200);
|
||||
}
|
||||
}
|
||||
}
|
297
src/web-components/bx-number-stepper.ts
Executable file
297
src/web-components/bx-number-stepper.ts
Executable file
@@ -0,0 +1,297 @@
|
||||
import type { NumberStepperParams } from "@/types/setting-definition";
|
||||
import { BxEvent } from "@/utils/bx-event";
|
||||
import { CE, escapeCssSelector } from "@/utils/html";
|
||||
import { setNearby } from "@/utils/navigation-utils";
|
||||
import type { BxHtmlSettingElement } from "@/utils/setting-element";
|
||||
|
||||
|
||||
type ButtonType = 'inc' | 'dec';
|
||||
|
||||
export class BxNumberStepper extends HTMLInputElement implements BxHtmlSettingElement {
|
||||
private intervalId: number | null = null;
|
||||
private isHolding!: boolean;
|
||||
|
||||
private controlValue!: number;
|
||||
private controlMin!: number;
|
||||
private controlMax!: number;
|
||||
|
||||
private uiMin!: number;
|
||||
private uiMax!: number;
|
||||
private steps!: number;
|
||||
private options!: NumberStepperParams;
|
||||
private onChange: any;
|
||||
|
||||
private $text!: HTMLSpanElement;
|
||||
private $btnInc!: HTMLButtonElement;
|
||||
private $btnDec!: HTMLButtonElement;
|
||||
private $range!: HTMLInputElement | null;
|
||||
|
||||
onInput!: typeof BxNumberStepper['onInput'];
|
||||
onRangeInput!: typeof BxNumberStepper['onRangeInput'];
|
||||
onClick!: typeof BxNumberStepper['onClick'];
|
||||
onPointerUp!: typeof BxNumberStepper['onPointerUp'];
|
||||
onPointerDown!: typeof BxNumberStepper['onPointerDown'];
|
||||
|
||||
setValue!: typeof BxNumberStepper['setValue'];
|
||||
normalizeValue!: typeof BxNumberStepper['normalizeValue'];
|
||||
|
||||
static create(key: string, value: number, min: number, max: number, options: NumberStepperParams={}, onChange: any) {
|
||||
options = options || {};
|
||||
options.suffix = options.suffix || '';
|
||||
options.disabled = !!options.disabled;
|
||||
options.hideSlider = !!options.hideSlider;
|
||||
|
||||
let $text: HTMLSpanElement;
|
||||
let $btnInc: HTMLButtonElement;
|
||||
let $btnDec: HTMLButtonElement;
|
||||
let $range: HTMLInputElement | null;
|
||||
|
||||
const $wrapper = CE<BxNumberStepper>('div', {
|
||||
class: 'bx-number-stepper',
|
||||
id: `bx_setting_${escapeCssSelector(key)}`,
|
||||
},
|
||||
CE('div', {},
|
||||
$btnDec = CE('button', {
|
||||
_dataset: {
|
||||
type: 'dec' as ButtonType,
|
||||
},
|
||||
type: 'button',
|
||||
class: options.hideSlider ? 'bx-focusable' : '',
|
||||
tabindex: options.hideSlider ? 0 : -1,
|
||||
}, '-') as HTMLButtonElement,
|
||||
$text = CE('span') as HTMLSpanElement,
|
||||
$btnInc = CE('button', {
|
||||
_dataset: {
|
||||
type: 'inc' as ButtonType,
|
||||
},
|
||||
type: 'button',
|
||||
class: options.hideSlider ? 'bx-focusable' : '',
|
||||
tabindex: options.hideSlider ? 0 : -1,
|
||||
}, '+') as HTMLButtonElement,
|
||||
),
|
||||
);
|
||||
|
||||
const self = $wrapper;
|
||||
self.$text = $text;
|
||||
self.$btnInc = $btnInc;
|
||||
self.$btnDec = $btnDec;
|
||||
self.onChange = onChange;
|
||||
|
||||
self.onInput = BxNumberStepper.onInput.bind(self);
|
||||
self.onRangeInput = BxNumberStepper.onRangeInput.bind(self);
|
||||
self.onClick = BxNumberStepper.onClick.bind(self);
|
||||
self.onPointerUp = BxNumberStepper.onPointerUp.bind(self);
|
||||
self.onPointerDown = BxNumberStepper.onPointerDown.bind(self);
|
||||
|
||||
self.controlMin = min;
|
||||
self.controlMax = max;
|
||||
self.isHolding = false;
|
||||
|
||||
self.options = options;
|
||||
self.uiMin = options.reverse ? -max : min;
|
||||
self.uiMax = options.reverse ? -min : max;
|
||||
self.steps = Math.max(options.steps || 1, 1);
|
||||
|
||||
BxNumberStepper.setValue.call(self, value);
|
||||
|
||||
if (options.disabled) {
|
||||
$btnInc.disabled = true;
|
||||
$btnInc.classList.add('bx-inactive');
|
||||
|
||||
$btnDec.disabled = true;
|
||||
$btnDec.classList.add('bx-inactive');
|
||||
|
||||
(self as any).disabled = true;
|
||||
return self;
|
||||
}
|
||||
|
||||
$range = CE<HTMLInputElement>('input', {
|
||||
id: `bx_inp_setting_${key}`,
|
||||
type: 'range',
|
||||
min: self.uiMin,
|
||||
max: self.uiMax,
|
||||
value: options.reverse ? -value : value,
|
||||
step: self.steps,
|
||||
tabindex: 0,
|
||||
});
|
||||
self.$range = $range;
|
||||
options.hideSlider && $range.classList.add('bx-gone');
|
||||
|
||||
$range.addEventListener('input', self.onRangeInput);
|
||||
self.addEventListener('input', self.onInput);
|
||||
self.appendChild($range);
|
||||
|
||||
if (options.ticks || options.exactTicks) {
|
||||
const markersId = `markers-${key}`;
|
||||
const $markers = CE('datalist', {id: markersId});
|
||||
$range.setAttribute('list', markersId);
|
||||
|
||||
if (options.exactTicks) {
|
||||
let start = Math.max(Math.floor(min / options.exactTicks), 1) * options.exactTicks;
|
||||
|
||||
if (start === min) {
|
||||
start += options.exactTicks;
|
||||
}
|
||||
|
||||
for (let i = start; i < max; i += options.exactTicks) {
|
||||
$markers.appendChild(CE<HTMLOptionElement>('option', {
|
||||
value: options.reverse ? -i : i,
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
for (let i = self.uiMin + options.ticks!; i < self.uiMax; i += options.ticks!) {
|
||||
$markers.appendChild(CE<HTMLOptionElement>('option', {value: i}));
|
||||
}
|
||||
}
|
||||
self.appendChild($markers);
|
||||
}
|
||||
|
||||
BxNumberStepper.updateButtonsVisibility.call(self);
|
||||
|
||||
self.addEventListener('click', self.onClick);
|
||||
self.addEventListener('pointerdown', self.onPointerDown);
|
||||
self.addEventListener('contextmenu', BxNumberStepper.onContextMenu);
|
||||
setNearby(self, {
|
||||
focus: options.hideSlider ? $btnInc : $range,
|
||||
});
|
||||
|
||||
Object.defineProperty(self, 'value', {
|
||||
get() { return self.controlValue; },
|
||||
set(value) { BxNumberStepper.setValue.call(self, value); },
|
||||
});
|
||||
|
||||
return self;
|
||||
}
|
||||
|
||||
private static setValue(this: BxNumberStepper, value: any) {
|
||||
this.controlValue = BxNumberStepper.normalizeValue.call(this, value);
|
||||
|
||||
this.$text.textContent = BxNumberStepper.updateTextValue.call(this);
|
||||
if (this.$range) {
|
||||
this.$range.value = this.options.reverse ? -value : value;
|
||||
}
|
||||
|
||||
BxNumberStepper.updateButtonsVisibility.call(this);
|
||||
}
|
||||
|
||||
private static normalizeValue(this: BxNumberStepper, value: number | string): number {
|
||||
value = parseInt(value as string);
|
||||
|
||||
value = Math.max(this.controlMin, value);
|
||||
value = Math.min(this.controlMax, value);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static onInput(this: BxNumberStepper, e: Event) {
|
||||
BxEvent.dispatch(this.$range, 'input');
|
||||
}
|
||||
|
||||
private static onRangeInput(this: BxNumberStepper, e: Event) {
|
||||
let value = parseInt((e.target as HTMLInputElement).value);
|
||||
if (this.options.reverse) {
|
||||
value *= -1;
|
||||
}
|
||||
|
||||
/*
|
||||
const valueChanged = this.controlValue !== value;
|
||||
if (!valueChanged) {
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
BxNumberStepper.setValue.call(this, value);
|
||||
BxNumberStepper.updateButtonsVisibility.call(this);
|
||||
|
||||
if (!(e as any).ignoreOnChange && this.onChange) {
|
||||
this.onChange(e, value);
|
||||
}
|
||||
}
|
||||
|
||||
private static onClick(this: BxNumberStepper, e: Event) {
|
||||
e.preventDefault();
|
||||
if (this.isHolding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $btn = (e.target as HTMLElement).closest('button') as HTMLElement;
|
||||
$btn && BxNumberStepper.buttonPressed.call(this, e, $btn);
|
||||
|
||||
BxNumberStepper.clearIntervalId.call(this);
|
||||
this.isHolding = false;
|
||||
}
|
||||
|
||||
private static onPointerDown(this: BxNumberStepper, e: Event) {
|
||||
BxNumberStepper.clearIntervalId.call(this);
|
||||
|
||||
const $btn = (e.target as HTMLElement).closest('button') as HTMLElement;
|
||||
if (!$btn) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isHolding = true;
|
||||
e.preventDefault();
|
||||
|
||||
this.intervalId = window.setInterval((e: Event) => {
|
||||
BxNumberStepper.buttonPressed.call(this, e, $btn);
|
||||
}, 200);
|
||||
|
||||
window.addEventListener('pointerup', this.onPointerUp, { once: true });
|
||||
window.addEventListener('pointercancel', this.onPointerUp, { once: true });
|
||||
}
|
||||
|
||||
private static onPointerUp(this: BxNumberStepper, e: Event) {
|
||||
BxNumberStepper.clearIntervalId.call(this);
|
||||
this.isHolding = false;
|
||||
}
|
||||
|
||||
private static onContextMenu(e: Event) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
private static updateTextValue(this: BxNumberStepper): string | null {
|
||||
const value = this.controlValue;
|
||||
|
||||
let textContent = null;
|
||||
if (this.options.customTextValue) {
|
||||
textContent = this.options.customTextValue(value, this.controlMin, this.controlMax);
|
||||
}
|
||||
|
||||
if (textContent === null) {
|
||||
textContent = value.toString() + this.options.suffix;
|
||||
}
|
||||
|
||||
return textContent;
|
||||
}
|
||||
|
||||
private static buttonPressed(this: BxNumberStepper, e: Event, $btn: HTMLElement) {
|
||||
let value = this.controlValue;
|
||||
value = this.options.reverse ? -value : value;
|
||||
|
||||
const btnType = $btn.dataset.type as ButtonType;
|
||||
if (btnType === 'dec') {
|
||||
value = Math.max(this.uiMin, value - this.steps);
|
||||
} else {
|
||||
value = Math.min(this.uiMax, value + this.steps);
|
||||
}
|
||||
|
||||
value = this.options.reverse ? -value : value;
|
||||
BxNumberStepper.setValue.call(this, value);
|
||||
BxNumberStepper.updateButtonsVisibility.call(this);
|
||||
this.onChange && this.onChange(e, value);
|
||||
}
|
||||
|
||||
private static clearIntervalId(this: BxNumberStepper) {
|
||||
this.intervalId && clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
private static updateButtonsVisibility(this: BxNumberStepper) {
|
||||
this.$btnDec.classList.toggle('bx-inactive', this.controlValue === this.uiMin);
|
||||
this.$btnInc.classList.toggle('bx-inactive', this.controlValue === this.uiMax);
|
||||
|
||||
if (this.controlValue === this.uiMin || this.controlValue === this.uiMax) {
|
||||
BxNumberStepper.clearIntervalId.call(this);
|
||||
}
|
||||
}
|
||||
}
|
355
src/web-components/bx-select.ts
Normal file → Executable file
355
src/web-components/bx-select.ts
Normal file → Executable file
@@ -1,13 +1,35 @@
|
||||
import { PrefKey } from "@/enums/pref-keys";
|
||||
import type { NavigationElement } from "@/modules/ui/dialog/navigation-dialog";
|
||||
import { BxEvent } from "@/utils/bx-event";
|
||||
import type { BxSelectSettingElement } from "@/utils/setting-element";
|
||||
import { ButtonStyle, CE, createButton } from "@utils/html";
|
||||
import { setNearby } from "@/utils/navigation-utils";
|
||||
import { getPref } from "@/utils/settings-storages/global-settings-storage";
|
||||
import { ButtonStyle, CE, clearDataSet, createButton } from "@utils/html";
|
||||
|
||||
export class BxSelectElement extends HTMLSelectElement {
|
||||
private optionsList!: HTMLOptionElement[];
|
||||
private indicatorsList!: HTMLElement[];
|
||||
private $indicators!: HTMLElement;
|
||||
private visibleIndex!: number;
|
||||
private isMultiple!: boolean;
|
||||
|
||||
private $select!: HTMLSelectElement;
|
||||
private $btnNext!: HTMLButtonElement;
|
||||
private $btnPrev!: HTMLButtonElement;
|
||||
private $label!: HTMLLabelElement;
|
||||
private $checkBox!: HTMLInputElement;
|
||||
|
||||
static create($select: HTMLSelectElement, forceFriendly=false): BxSelectElement {
|
||||
// Return normal <select> if not using controller-friendly UI
|
||||
if (!forceFriendly && !getPref(PrefKey.UI_CONTROLLER_FRIENDLY)) {
|
||||
$select.classList.add('bx-select');
|
||||
// @ts-ignore
|
||||
return $select;
|
||||
}
|
||||
|
||||
export class BxSelectElement {
|
||||
static wrap($select: HTMLSelectElement) {
|
||||
// Remove "tabindex" attribute from <select>
|
||||
$select.removeAttribute('tabindex');
|
||||
|
||||
const $wrapper = CE<BxSelectElement & NavigationElement>('div', { class: 'bx-select' });
|
||||
const $btnPrev = createButton({
|
||||
label: '<',
|
||||
style: ButtonStyle.FOCUSABLE,
|
||||
@@ -18,124 +40,68 @@ export class BxSelectElement {
|
||||
style: ButtonStyle.FOCUSABLE,
|
||||
});
|
||||
|
||||
const isMultiple = $select.multiple;
|
||||
let $checkBox: HTMLInputElement;
|
||||
let $label: HTMLElement;
|
||||
let visibleIndex = $select.selectedIndex;
|
||||
setNearby($wrapper, {
|
||||
orientation: 'horizontal',
|
||||
focus: $btnNext,
|
||||
});
|
||||
|
||||
let $content;
|
||||
|
||||
if (isMultiple) {
|
||||
const self = $wrapper;
|
||||
self.isMultiple = $select.multiple;
|
||||
self.visibleIndex = $select.selectedIndex;
|
||||
|
||||
self.$select = $select;
|
||||
self.optionsList = Array.from($select.querySelectorAll<HTMLOptionElement>('option'));
|
||||
self.$indicators = CE('div', { class: 'bx-select-indicators' });
|
||||
self.indicatorsList = [];
|
||||
self.$btnNext = $btnNext;
|
||||
self.$btnPrev = $btnPrev;
|
||||
|
||||
if (self.isMultiple) {
|
||||
$content = CE('button', {
|
||||
class: 'bx-select-value bx-focusable',
|
||||
tabindex: 0,
|
||||
},
|
||||
$checkBox = CE('input', {type: 'checkbox'}),
|
||||
$label = CE('span', {}, ''),
|
||||
CE('div', {},
|
||||
self.$checkBox = CE('input', { type: 'checkbox' }),
|
||||
self.$label = CE('span', {}, ''),
|
||||
),
|
||||
|
||||
self.$indicators,
|
||||
);
|
||||
|
||||
$content.addEventListener('click', e => {
|
||||
$checkBox.click();
|
||||
self.$checkBox.click();
|
||||
});
|
||||
|
||||
$checkBox.addEventListener('input', e => {
|
||||
const $option = getOptionAtIndex(visibleIndex);
|
||||
self.$checkBox.addEventListener('input', e => {
|
||||
const $option = BxSelectElement.getOptionAtIndex.call(self, self.visibleIndex);
|
||||
$option && ($option.selected = (e.target as HTMLInputElement).checked);
|
||||
|
||||
BxEvent.dispatch($select, 'input');
|
||||
});
|
||||
} else {
|
||||
$content = CE('div', {},
|
||||
$label = CE('label', {for: $select.id + '_checkbox'}, ''),
|
||||
self.$label = CE('label', { for: $select.id + '_checkbox' }, ''),
|
||||
self.$indicators,
|
||||
);
|
||||
}
|
||||
|
||||
const getOptionAtIndex = (index: number): HTMLOptionElement | undefined => {
|
||||
const options = Array.from($select.querySelectorAll('option'));
|
||||
return options[index];
|
||||
}
|
||||
const boundOnPrevNext = BxSelectElement.onPrevNext.bind(self);
|
||||
|
||||
const render = (e?: Event) => {
|
||||
// console.log('options', this.options, 'selectedIndices', this.selectedIndices, 'selectedOptions', this.selectedOptions);
|
||||
if (e && (e as any).manualTrigger) {
|
||||
visibleIndex = $select.selectedIndex;
|
||||
}
|
||||
|
||||
visibleIndex = normalizeIndex(visibleIndex);
|
||||
const $option = getOptionAtIndex(visibleIndex);
|
||||
let content = '';
|
||||
if ($option) {
|
||||
content = $option.textContent || '';
|
||||
|
||||
if (content && $option.parentElement!.tagName === 'OPTGROUP') {
|
||||
$label.innerHTML = '';
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(CE('span', {}, ($option.parentElement as HTMLOptGroupElement).label));
|
||||
fragment.appendChild(document.createTextNode(content));
|
||||
|
||||
$label.appendChild(fragment);
|
||||
} else {
|
||||
$label.textContent = content;
|
||||
}
|
||||
} else {
|
||||
$label.textContent = content;
|
||||
}
|
||||
|
||||
// Add line-through on disabled option
|
||||
$label.classList.toggle('bx-line-through', $option && $option.disabled);
|
||||
|
||||
// Hide checkbox when the selection is empty
|
||||
if (isMultiple) {
|
||||
$checkBox.checked = $option?.selected || false;
|
||||
$checkBox.classList.toggle('bx-gone', !content);
|
||||
}
|
||||
|
||||
const disablePrev = visibleIndex <= 0;
|
||||
const disableNext = visibleIndex === $select.querySelectorAll('option').length - 1;
|
||||
|
||||
$btnPrev.classList.toggle('bx-inactive', disablePrev);
|
||||
$btnNext.classList.toggle('bx-inactive', disableNext);
|
||||
|
||||
// Focus the other button when reaching the beginning/end
|
||||
disablePrev && !disableNext && document.activeElement === $btnPrev && $btnNext.focus();
|
||||
disableNext && !disablePrev && document.activeElement === $btnNext && $btnPrev.focus();
|
||||
}
|
||||
|
||||
const normalizeIndex = (index: number): number => {
|
||||
return Math.min(Math.max(index, 0), $select.querySelectorAll('option').length - 1);
|
||||
}
|
||||
|
||||
const onPrevNext = (e: Event) => {
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const goNext = (e.target as any).closest('button') === $btnNext;
|
||||
|
||||
const currentIndex = visibleIndex;
|
||||
let newIndex = goNext ? currentIndex + 1 : currentIndex - 1;
|
||||
newIndex = normalizeIndex(newIndex);
|
||||
|
||||
visibleIndex = newIndex;
|
||||
if (!isMultiple && newIndex !== currentIndex) {
|
||||
$select.selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
render();
|
||||
} else {
|
||||
BxEvent.dispatch($select, 'input');
|
||||
}
|
||||
};
|
||||
|
||||
$select.addEventListener('input', render);
|
||||
$btnPrev.addEventListener('click', onPrevNext);
|
||||
$btnNext.addEventListener('click', onPrevNext);
|
||||
$select.addEventListener('input', BxSelectElement.render.bind(self));
|
||||
$btnPrev.addEventListener('click', boundOnPrevNext);
|
||||
$btnNext.addEventListener('click', boundOnPrevNext);
|
||||
|
||||
const observer = new MutationObserver((mutationList, observer) => {
|
||||
mutationList.forEach(mutation => {
|
||||
if (mutation.type === 'childList' || mutation.type === 'attributes') {
|
||||
render();
|
||||
self.visibleIndex = $select.selectedIndex;
|
||||
self.optionsList = Array.from($select.querySelectorAll<HTMLOptionElement>('option'));
|
||||
|
||||
BxSelectElement.resetIndicators.call(self);
|
||||
BxSelectElement.render.call(self);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -146,54 +112,201 @@ export class BxSelectElement {
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
const $div = CE<NavigationElement>('div', {
|
||||
class: 'bx-select',
|
||||
_nearby: {
|
||||
orientation: 'horizontal',
|
||||
focus: $btnNext,
|
||||
}
|
||||
},
|
||||
self.append(
|
||||
$select,
|
||||
$btnPrev,
|
||||
$content,
|
||||
$btnNext,
|
||||
);
|
||||
|
||||
Object.defineProperty($div, 'value', {
|
||||
get() {
|
||||
return $select.value;
|
||||
},
|
||||
BxSelectElement.resetIndicators.call(self);
|
||||
BxSelectElement.render.call(self);
|
||||
|
||||
Object.defineProperty(self, 'value', {
|
||||
get() { return $select.value; },
|
||||
set(value) {
|
||||
($div as any).setValue(value);
|
||||
}
|
||||
self.optionsList = Array.from($select.querySelectorAll<HTMLOptionElement>('option'));
|
||||
|
||||
$select.value = value;
|
||||
|
||||
// Update visible index
|
||||
self.visibleIndex = $select.selectedIndex;
|
||||
// Re-render
|
||||
BxSelectElement.resetIndicators.call(self);
|
||||
BxSelectElement.render.call(self);
|
||||
},
|
||||
});
|
||||
|
||||
$div.addEventListener = function() {
|
||||
Object.defineProperty(self, 'disabled', {
|
||||
get() { return $select.disabled; },
|
||||
set(value) { $select.disabled = value; },
|
||||
});
|
||||
|
||||
self.addEventListener = function() {
|
||||
// @ts-ignore
|
||||
$select.addEventListener.apply($select, arguments);
|
||||
};
|
||||
|
||||
$div.removeEventListener = function() {
|
||||
self.removeEventListener = function() {
|
||||
// @ts-ignore
|
||||
$select.removeEventListener.apply($select, arguments);
|
||||
};
|
||||
|
||||
$div.dispatchEvent = function() {
|
||||
self.dispatchEvent = function() {
|
||||
// @ts-ignore
|
||||
return $select.dispatchEvent.apply($select, arguments);
|
||||
};
|
||||
|
||||
($div as any).setValue = (value: any) => {
|
||||
if ('setValue' in $select) {
|
||||
($select as BxSelectSettingElement).setValue(value);
|
||||
} else {
|
||||
$select.value = value;
|
||||
}
|
||||
self.appendChild = function(node) {
|
||||
$select.appendChild(node);
|
||||
return node;
|
||||
};
|
||||
|
||||
return $div;
|
||||
return self as BxSelectElement;
|
||||
}
|
||||
|
||||
private static resetIndicators(this: BxSelectElement) {
|
||||
const {
|
||||
optionsList,
|
||||
indicatorsList,
|
||||
$indicators,
|
||||
} = this;
|
||||
|
||||
const targetSize = optionsList.length;
|
||||
|
||||
if (indicatorsList.length > targetSize) {
|
||||
// Detach indicator from parent
|
||||
while (indicatorsList.length > targetSize) {
|
||||
indicatorsList.pop()?.remove();
|
||||
}
|
||||
} else if (indicatorsList.length < targetSize) {
|
||||
// Add empty indicators
|
||||
while (indicatorsList.length < targetSize) {
|
||||
const $indicator = CE('span', {});
|
||||
indicatorsList.push($indicator);
|
||||
|
||||
$indicators.appendChild($indicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset dataset
|
||||
for (const $indicator of indicatorsList) {
|
||||
clearDataSet($indicator);
|
||||
}
|
||||
|
||||
// Toggle visibility
|
||||
$indicators.classList.toggle('bx-invisible', targetSize <= 1);
|
||||
}
|
||||
|
||||
private static getOptionAtIndex(this: BxSelectElement, index: number): HTMLOptionElement | undefined {
|
||||
return this.optionsList[index];
|
||||
}
|
||||
|
||||
private static render(this: BxSelectElement, e?: Event) {
|
||||
const {
|
||||
$label,
|
||||
$btnNext,
|
||||
$btnPrev,
|
||||
$checkBox,
|
||||
visibleIndex,
|
||||
optionsList,
|
||||
indicatorsList,
|
||||
} = this;
|
||||
|
||||
// console.log('options', this.options, 'selectedIndices', this.selectedIndices, 'selectedOptions', this.selectedOptions);
|
||||
if (e && (e as any).manualTrigger) {
|
||||
this.visibleIndex = this.$select.selectedIndex;
|
||||
}
|
||||
|
||||
this.visibleIndex = BxSelectElement.normalizeIndex.call(this, this.visibleIndex);
|
||||
const $option = BxSelectElement.getOptionAtIndex.call(this, this.visibleIndex);
|
||||
let content = '';
|
||||
if ($option) {
|
||||
const $parent = $option.parentElement!;
|
||||
const hasLabel = $parent instanceof HTMLOptGroupElement || this.$select.querySelector('optgroup');
|
||||
|
||||
content = $option.textContent || '';
|
||||
if (content && hasLabel) {
|
||||
const groupLabel = $parent instanceof HTMLOptGroupElement ? $parent.label : ' ';
|
||||
|
||||
$label.innerHTML = '';
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(CE('span', {}, groupLabel));
|
||||
fragment.appendChild(document.createTextNode(content));
|
||||
|
||||
$label.appendChild(fragment);
|
||||
} else {
|
||||
$label.textContent = content;
|
||||
}
|
||||
} else {
|
||||
$label.textContent = content;
|
||||
}
|
||||
|
||||
// Add line-through on disabled option
|
||||
$label.classList.toggle('bx-line-through', $option && $option.disabled);
|
||||
|
||||
// Hide checkbox when the selection is empty
|
||||
if (this.isMultiple) {
|
||||
$checkBox.checked = $option?.selected || false;
|
||||
$checkBox.classList.toggle('bx-gone', !content);
|
||||
}
|
||||
|
||||
// Disable buttons when there is only one option or fewer
|
||||
const disableButtons = optionsList.length <= 1;
|
||||
$btnPrev.classList.toggle('bx-inactive', disableButtons);
|
||||
$btnNext.classList.toggle('bx-inactive', disableButtons);
|
||||
|
||||
// Update indicators
|
||||
for (let i = 0; i < optionsList.length; i++) {
|
||||
const $option = optionsList[i];
|
||||
const $indicator = indicatorsList[i];
|
||||
|
||||
clearDataSet($indicator);
|
||||
if ($option.selected) {
|
||||
$indicator.dataset.selected = 'true';
|
||||
}
|
||||
|
||||
if ($option.index === visibleIndex) {
|
||||
$indicator.dataset.highlighted = 'true';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static normalizeIndex(this: BxSelectElement, index: number): number {
|
||||
return Math.min(Math.max(index, 0), this.optionsList.length - 1);
|
||||
}
|
||||
|
||||
private static onPrevNext(this: BxSelectElement, e: Event) {
|
||||
if (!e.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
$btnNext,
|
||||
$select,
|
||||
isMultiple,
|
||||
visibleIndex: currentIndex,
|
||||
} = this;
|
||||
|
||||
const goNext = (e.target as any).closest('button') === $btnNext;
|
||||
|
||||
let newIndex = goNext ? currentIndex + 1 : currentIndex - 1;
|
||||
if (newIndex > this.optionsList.length - 1) {
|
||||
newIndex = 0;
|
||||
} else if (newIndex < 0) {
|
||||
newIndex = this.optionsList.length - 1;
|
||||
}
|
||||
newIndex = BxSelectElement.normalizeIndex.call(this, newIndex);
|
||||
|
||||
this.visibleIndex = newIndex;
|
||||
if (!isMultiple && newIndex !== currentIndex) {
|
||||
$select.selectedIndex = newIndex;
|
||||
}
|
||||
|
||||
if (isMultiple) {
|
||||
BxSelectElement.render.call(this);
|
||||
} else {
|
||||
BxEvent.dispatch($select, 'input');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
Reference in New Issue
Block a user