mirror of
https://github.com/redphx/better-xcloud.git
synced 2025-08-13 00:19:17 +02:00
Controller customization feature
This commit is contained in:
195
src/web-components/bx-dual-number-stepper.ts
Normal file
195
src/web-components/bx-dual-number-stepper.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
|
@@ -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);
|
||||
|
@@ -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';
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user