Controller customization feature

This commit is contained in:
redphx
2024-12-22 17:17:03 +07:00
parent 8ef5a95c88
commit 7b60ba3a3e
89 changed files with 3286 additions and 1188 deletions

View File

@@ -0,0 +1,195 @@
import type { DualNumberStepperParams } from "@/types/setting-definition";
import { CE, escapeCssSelector } from "@/utils/html";
import { setNearby } from "@/utils/navigation-utils";
import type { BxHtmlSettingElement } from "@/utils/setting-element";
import { t } from "@/utils/translation";
export class BxDualNumberStepper extends HTMLInputElement implements BxHtmlSettingElement {
private controlValues!: [number, number];
private controlMin!: number;
private controlMinDiff!: number;
private controlMax!: number;
private steps!: number;
private options!: DualNumberStepperParams;
private onChange: any;
private $text!: HTMLSpanElement;
private $rangeFrom!: HTMLInputElement;
private $rangeTo!: HTMLInputElement;
private $activeRange!: HTMLInputElement;
onRangeInput!: typeof BxDualNumberStepper['onRangeInput'];
setValue!: typeof BxDualNumberStepper['setValues'];
getValue!: typeof BxDualNumberStepper['getValues'];
normalizeValue!: typeof BxDualNumberStepper['normalizeValues'];
static create(key: string, values: [number, number], options: DualNumberStepperParams, onChange?: any) {
options.suffix = options.suffix || '';
options.disabled = !!options.disabled;
let $text: HTMLSpanElement;
let $rangeFrom: HTMLInputElement;
let $rangeTo: HTMLInputElement;
const $wrapper = CE('div', {
class: 'bx-dual-number-stepper',
id: `bx_setting_${escapeCssSelector(key)}`,
},
$text = CE('span') as HTMLSpanElement,
) as BxDualNumberStepper;
const self = $wrapper;
self.$text = $text;
self.onChange = onChange;
self.onRangeInput = BxDualNumberStepper.onRangeInput.bind(self);
self.controlMin = options.min;
self.controlMax = options.max;
self.controlMinDiff = options.minDiff;
self.options = options;
self.steps = Math.max(options.steps || 1, 1);
if (options.disabled) {
(self as any).disabled = true;
return self;
}
$rangeFrom = CE('input', {
// id: `bx_inp_setting_${key}`,
type: 'range',
min: self.controlMin,
max: self.controlMax,
step: self.steps,
tabindex: 0,
});
$rangeTo = $rangeFrom.cloneNode() as HTMLInputElement;
self.$rangeFrom = $rangeFrom;
self.$rangeTo = $rangeTo;
self.$activeRange = $rangeFrom;
self.getValue = BxDualNumberStepper.getValues.bind(self);
self.setValue = BxDualNumberStepper.setValues.bind(self);
$rangeFrom.addEventListener('input', self.onRangeInput);
$rangeTo.addEventListener('input', self.onRangeInput);
self.addEventListener('input', self.onRangeInput);
self.append(CE('div', {}, $rangeFrom, $rangeTo));
// Set values
BxDualNumberStepper.setValues.call(self, values);
self.addEventListener('contextmenu', BxDualNumberStepper.onContextMenu);
setNearby(self, {
focus: $rangeFrom,
orientation: 'vertical',
});
Object.defineProperty(self, 'value', {
get() { return self.controlValues; },
set(value) {
let from: number | undefined;
let to: number | undefined;
if (typeof value === 'string') {
const tmp = value.split(',');
from = parseInt(tmp[0]);
to = parseInt(tmp[1]);
} else if (Array.isArray(value)) {
[from, to] = value;
}
if (typeof from !== 'undefined' && typeof to !== 'undefined') {
BxDualNumberStepper.setValues.call(self, [from, to]);
}
},
});
return self;
}
private static setValues(this: BxDualNumberStepper, values: [number, number] | undefined | null) {
let from: number;
let to: number;
if (values) {
[from, to] = BxDualNumberStepper.normalizeValues.call(this, values);
} else {
from = this.controlMin;
to = this.controlMax;
values = [from, to];
}
this.controlValues = [from, to];
this.$text.textContent = BxDualNumberStepper.updateTextValue.call(this);
this.$rangeFrom.value = from.toString();
this.$rangeTo.value = to.toString();
const ratio = 100 / (this.controlMax - this.controlMin);
this.style.setProperty('--from', (ratio * (from - this.controlMin)) + '%');
this.style.setProperty('--to', (ratio * (to - this.controlMin)) + '%');
}
private static getValues(this: BxDualNumberStepper) {
return this.controlValues || [this.controlMin, this.controlMax];
}
private static normalizeValues(this: BxDualNumberStepper, values: [number, number]): [number, number] {
let [from, to] = values;
if (this.$activeRange === this.$rangeFrom) {
to = Math.min(this.controlMax, to);
from = Math.min(from, to);
from = Math.min(to - this.controlMinDiff, from);
} else {
from = Math.max(this.controlMin, from);
to = Math.max(from, to);
to = Math.max(this.controlMinDiff + from, to);
}
to = Math.min(this.controlMax, to);
from = Math.min(from, to);
return [from, to];
}
private static onRangeInput(this: BxDualNumberStepper, e: Event) {
this.$activeRange = e.target as HTMLInputElement;
const values = BxDualNumberStepper.normalizeValues.call(this, [parseInt(this.$rangeFrom.value), parseInt(this.$rangeTo.value)]);
BxDualNumberStepper.setValues.call(this, values);
if (!(e as any).ignoreOnChange && this.onChange) {
this.onChange(e, values);
}
}
private static onContextMenu(e: Event) {
e.preventDefault();
}
private static updateTextValue(this: BxDualNumberStepper): string | null {
const values = this.controlValues;
let textContent = null;
if (this.options.customTextValue) {
textContent = this.options.customTextValue(values, this.controlMin, this.controlMax);
}
if (textContent === null) {
const [from, to] = values;
if (from === this.controlMin && to === this.controlMax) {
textContent = t('default');
} else {
const pad = to.toString().length;
textContent = `${from.toString().padStart(pad)} - ${to.toString().padEnd(pad)}${this.options.suffix}`;
}
}
return textContent;
}
}

View File

@@ -35,10 +35,10 @@ export class BxKeyBindingButton extends HTMLButtonElement {
unbindKey!: typeof BxKeyBindingButton['unbindKey'];
static create(options: BxKeyBindingButtonOptions) {
const $btn = CE<BxKeyBindingButton>('button', {
const $btn = CE('button', {
class: 'bx-binding-button bx-focusable',
type: 'button',
});
}) as BxKeyBindingButton;
$btn.title = options.title;
$btn.isPrompt = !!options.isPrompt;

View File

@@ -44,7 +44,7 @@ export class BxNumberStepper extends HTMLInputElement implements BxHtmlSettingEl
let $btnDec: HTMLButtonElement;
let $range: HTMLInputElement | null;
const $wrapper = CE<BxNumberStepper>('div', {
const $wrapper = CE('div', {
class: 'bx-number-stepper',
id: `bx_setting_${escapeCssSelector(key)}`,
},
@@ -67,7 +67,7 @@ export class BxNumberStepper extends HTMLInputElement implements BxHtmlSettingEl
tabindex: options.hideSlider ? 0 : -1,
}, '+') as HTMLButtonElement,
),
);
) as BxNumberStepper;
const self = $wrapper;
self.$text = $text;
@@ -102,7 +102,7 @@ export class BxNumberStepper extends HTMLInputElement implements BxHtmlSettingEl
return self;
}
$range = CE<HTMLInputElement>('input', {
$range = CE('input', {
id: `bx_inp_setting_${key}`,
type: 'range',
min: self.uiMin,
@@ -131,13 +131,13 @@ export class BxNumberStepper extends HTMLInputElement implements BxHtmlSettingEl
}
for (let i = start; i < max; i += options.exactTicks) {
$markers.appendChild(CE<HTMLOptionElement>('option', {
$markers.appendChild(CE('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 }));
$markers.appendChild(CE('option', { value: i }));
}
}
self.appendChild($markers);

View File

@@ -6,6 +6,7 @@ import { getPref } from "@/utils/settings-storages/global-settings-storage";
import { ButtonStyle, CE, clearDataSet, createButton } from "@utils/html";
export class BxSelectElement extends HTMLSelectElement {
isControllerFriendly!: boolean;
private optionsList!: HTMLOptionElement[];
private indicatorsList!: HTMLElement[];
private $indicators!: HTMLElement;
@@ -13,14 +14,16 @@ export class BxSelectElement extends HTMLSelectElement {
private isMultiple!: boolean;
private $select!: HTMLSelectElement;
private $btnNext!: HTMLButtonElement;
private $btnPrev!: HTMLButtonElement;
private $label!: HTMLLabelElement;
private $btnNext!: HTMLButtonElement | undefined;
private $btnPrev!: HTMLButtonElement | undefined;
private $label!: HTMLSpanElement;
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)) {
const isControllerFriendly = forceFriendly || getPref(PrefKey.UI_CONTROLLER_FRIENDLY);
// Return normal <select> if it's non-controller friendly <select multiple>
if ($select.multiple && !isControllerFriendly) {
$select.classList.add('bx-select');
// @ts-ignore
return $select;
@@ -29,25 +32,22 @@ export class BxSelectElement extends 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,
});
const $wrapper = CE('div', {
class: 'bx-select',
_dataset: {
controllerFriendly: isControllerFriendly,
},
}) as unknown as (BxSelectElement & NavigationElement);
const $btnNext = createButton({
label: '>',
style: ButtonStyle.FOCUSABLE,
});
setNearby($wrapper, {
orientation: 'horizontal',
focus: $btnNext,
});
// Copy bx-full-width class
if ($select.classList.contains('bx-full-width')) {
$wrapper.classList.add('bx-full-width');
}
let $content;
const self = $wrapper;
self.isControllerFriendly = isControllerFriendly;
self.isMultiple = $select.multiple;
self.visibleIndex = $select.selectedIndex;
@@ -55,8 +55,41 @@ export class BxSelectElement extends HTMLSelectElement {
self.optionsList = Array.from($select.querySelectorAll<HTMLOptionElement>('option'));
self.$indicators = CE('div', { class: 'bx-select-indicators' });
self.indicatorsList = [];
self.$btnNext = $btnNext;
self.$btnPrev = $btnPrev;
let $btnPrev;
let $btnNext;
if (isControllerFriendly) {
// Setup prev/next buttons
$btnPrev = createButton({
label: '<',
style: ButtonStyle.FOCUSABLE,
});
$btnNext = createButton({
label: '>',
style: ButtonStyle.FOCUSABLE,
});
setNearby($wrapper, {
orientation: 'horizontal',
focus: $btnNext,
});
self.$btnNext = $btnNext;
self.$btnPrev = $btnPrev;
const boundOnPrevNext = BxSelectElement.onPrevNext.bind(self);
$btnPrev.addEventListener('click', boundOnPrevNext);
$btnNext.addEventListener('click', boundOnPrevNext);
} else {
// Setup 'change' event for $select
$select.addEventListener('change', e => {
self.visibleIndex = $select.selectedIndex;
// Re-render
BxSelectElement.resetIndicators.call(self);
BxSelectElement.render.call(self);
});
}
if (self.isMultiple) {
$content = CE('button', {
@@ -88,11 +121,7 @@ export class BxSelectElement extends HTMLSelectElement {
);
}
const boundOnPrevNext = BxSelectElement.onPrevNext.bind(self);
$select.addEventListener('input', BxSelectElement.render.bind(self));
$btnPrev.addEventListener('click', boundOnPrevNext);
$btnNext.addEventListener('click', boundOnPrevNext);
const observer = new MutationObserver((mutationList, observer) => {
mutationList.forEach(mutation => {
@@ -114,9 +143,9 @@ export class BxSelectElement extends HTMLSelectElement {
self.append(
$select,
$btnPrev,
$btnPrev || '',
$content,
$btnNext,
$btnNext || '',
);
BxSelectElement.resetIndicators.call(self);
@@ -208,7 +237,6 @@ export class BxSelectElement extends HTMLSelectElement {
$btnNext,
$btnPrev,
$checkBox,
visibleIndex,
optionsList,
indicatorsList,
} = this;
@@ -225,7 +253,7 @@ export class BxSelectElement extends HTMLSelectElement {
const $parent = $option.parentElement!;
const hasLabel = $parent instanceof HTMLOptGroupElement || this.$select.querySelector('optgroup');
content = $option.textContent || '';
content = $option.dataset.label || $option.textContent || '';
if (content && hasLabel) {
const groupLabel = $parent instanceof HTMLOptGroupElement ? $parent.label : ' ';
@@ -253,8 +281,8 @@ export class BxSelectElement extends HTMLSelectElement {
// 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);
$btnPrev?.classList.toggle('bx-inactive', disableButtons);
$btnNext?.classList.toggle('bx-inactive', disableButtons);
// Update indicators
for (let i = 0; i < optionsList.length; i++) {
@@ -269,7 +297,7 @@ export class BxSelectElement extends HTMLSelectElement {
$indicator.dataset.selected = 'true';
}
if ($option.index === visibleIndex) {
if ($option.index === this.visibleIndex) {
$indicator.dataset.highlighted = 'true';
}
}