diff --git a/jest.config.ts b/jest.config.ts
index b7f770614..d546dfb96 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -15,7 +15,7 @@ export default {
"^.+\\.js$": ["@swc/jest"],
},
transformIgnorePatterns: [
- "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose)/)",
+ "node_modules/(?!(nanoid|@jsep|fastpriorityqueue|@datastructures-js|jose|lit.*|@lit)/)",
],
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.d.ts"],
coverageThreshold: {
diff --git a/package-lock.json b/package-lock.json
index 1f6cd8a0f..b9417e1e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -3825,9 +3825,9 @@
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
- "version": "3.14.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
- "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+ "version": "3.14.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
+ "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts
index 48ce400c1..09d840048 100644
--- a/src/client/HostLobbyModal.ts
+++ b/src/client/HostLobbyModal.ts
@@ -25,6 +25,7 @@ import {
import { generateID } from "../core/Util";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
+import "./components/FluentSlider";
import "./components/LobbyTeamView";
import "./components/Maps";
import { JoinLobbyEvent } from "./Main";
@@ -331,25 +332,17 @@ export class HostLobbyModal extends LitElement {
${translateText("host_modal.options_title")}
-
+ .value=${this.bots}
+ labelKey="host_modal.bots"
+ disabledKey="host_modal.bots_disabled"
+ @value-changed=${this.handleBotsChange}
+ >
+
${
!(
@@ -661,7 +654,8 @@ export class HostLobbyModal extends LitElement {
// Modified to include debouncing
private handleBotsChange(e: Event) {
- const value = parseInt((e.target as HTMLInputElement).value);
+ const customEvent = e as CustomEvent<{ value: number }>;
+ const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts
index 8b937ae30..f65c955cf 100644
--- a/src/client/SinglePlayerModal.ts
+++ b/src/client/SinglePlayerModal.ts
@@ -21,6 +21,7 @@ import { generateID } from "../core/Util";
import "./components/baseComponents/Button";
import "./components/baseComponents/Modal";
import "./components/Difficulties";
+import "./components/FluentSlider";
import "./components/Maps";
import { fetchCosmetics } from "./Cosmetics";
import { FlagInput } from "./FlagInput";
@@ -236,24 +237,17 @@ export class SinglePlayerModal extends LitElement {
${translateText("single_modal.options_title")}
-
+ .value=${this.bots}
+ labelKey="single_modal.bots"
+ disabledKey="single_modal.bots_disabled"
+ @value-changed=${this.handleBotsChange}
+ >
+
${!(
this.gameMode === GameMode.Team &&
@@ -448,7 +442,8 @@ export class SinglePlayerModal extends LitElement {
}
private handleBotsChange(e: Event) {
- const value = parseInt((e.target as HTMLInputElement).value);
+ const customEvent = e as CustomEvent<{ value: number }>;
+ const value = customEvent.detail.value;
if (isNaN(value) || value < 0 || value > 400) {
return;
}
diff --git a/src/client/components/FluentSlider.ts b/src/client/components/FluentSlider.ts
new file mode 100644
index 000000000..56957d49c
--- /dev/null
+++ b/src/client/components/FluentSlider.ts
@@ -0,0 +1,152 @@
+import { LitElement, css, html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { translateText } from "../Utils";
+
+@customElement("fluent-slider")
+export class FluentSlider extends LitElement {
+ static styles = css`
+ :host {
+ display: block;
+ width: 100%;
+ font-family: inherit;
+ }
+ .slider-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ width: 100%;
+ text-align: center;
+ }
+ .option-card-title {
+ font-size: 14px; /* match other cards */
+ color: #aaa; /* light gray text */
+ text-align: center;
+ margin: 0 0 4px 0;
+ font-weight: normal;
+ }
+ input[type="range"] {
+ width: 100%;
+ max-width: 100%;
+ background-color: transparent;
+ }
+ input[type="number"] {
+ width: 60px;
+ background-color: #2d3748;
+ color: #aaa; /* match label color */
+ border: 1px solid #4a5568;
+ text-align: center;
+ border-radius: 4px;
+ font-weight: normal;
+ font-family: inherit;
+ }
+ span.editable {
+ cursor: pointer;
+ min-width: 60px;
+ display: inline-block;
+ text-align: center;
+ color: #aaa; /* match label color */
+ font-weight: normal;
+ user-select: none;
+ }
+ `;
+
+ @property({ type: Number }) value = 0;
+ @property({ type: Number }) min = 0;
+ @property({ type: Number }) max = 400;
+ @property({ type: Number }) step = 1;
+ @property({ type: String }) labelKey = "";
+ @property({ type: String }) disabledKey = "";
+
+ @state() private isEditing = false;
+
+ @query("input[type='number']") private numberInput!: HTMLInputElement;
+
+ private dispatchValueChange() {
+ this.dispatchEvent(
+ new CustomEvent("value-changed", {
+ detail: { value: this.value },
+ bubbles: true,
+ composed: true,
+ }),
+ );
+ }
+
+ private handleSliderInput(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.value = target.valueAsNumber;
+ }
+
+ private handleSliderChange(e: Event) {
+ const target = e.target as HTMLInputElement;
+ this.value = target.valueAsNumber;
+ this.dispatchValueChange();
+ }
+
+ private handleNumberChange(e: Event) {
+ const target = e.target as HTMLInputElement;
+ let val = target.valueAsNumber;
+ if (isNaN(val)) {
+ val = this.min;
+ }
+ if (val < this.min) val = this.min;
+ if (val > this.max) val = this.max;
+ this.value = val;
+ this.dispatchValueChange();
+ }
+
+ private handleNumberKeyDown(e: KeyboardEvent) {
+ if (e.key === "Enter") this.isEditing = false;
+ }
+
+ private enableEditing() {
+ this.isEditing = true;
+ this.updateComplete.then(() => this.numberInput?.focus());
+ }
+
+ render() {
+ return html`
+
+ `;
+ }
+}
diff --git a/tests/client/components/FluentSlider.test.ts b/tests/client/components/FluentSlider.test.ts
new file mode 100644
index 000000000..44856f918
--- /dev/null
+++ b/tests/client/components/FluentSlider.test.ts
@@ -0,0 +1,285 @@
+/**
+ * @jest-environment jsdom
+ */
+import { FluentSlider } from "../../../src/client/components/FluentSlider";
+
+// Mock the translateText function
+jest.mock("../../../src/client/Utils", () => ({
+ translateText: jest.fn((key: string) => key),
+}));
+
+describe("FluentSlider", () => {
+ let slider: FluentSlider;
+
+ beforeEach(async () => {
+ // Define the custom element if not already defined
+ if (!customElements.get("fluent-slider")) {
+ customElements.define("fluent-slider", FluentSlider);
+ }
+ slider = document.createElement("fluent-slider") as FluentSlider;
+ document.body.appendChild(slider);
+ await slider.updateComplete;
+ });
+
+ afterEach(() => {
+ document.body.removeChild(slider);
+ });
+
+ describe("Initialization", () => {
+ it("should initialize with default values", () => {
+ expect(slider.value).toBe(0);
+ expect(slider.min).toBe(0);
+ expect(slider.max).toBe(400);
+ expect(slider.step).toBe(1);
+ expect(slider.labelKey).toBe("");
+ expect(slider.disabledKey).toBe("");
+ });
+
+ it("should accept custom property values", async () => {
+ slider.value = 150;
+ slider.min = 10;
+ slider.max = 300;
+ slider.step = 5;
+ slider.labelKey = "host_modal.bots";
+ slider.disabledKey = "host_modal.bots_disabled";
+
+ await slider.updateComplete;
+
+ expect(slider.value).toBe(150);
+ expect(slider.min).toBe(10);
+ expect(slider.max).toBe(300);
+ expect(slider.step).toBe(5);
+ expect(slider.labelKey).toBe("host_modal.bots");
+ expect(slider.disabledKey).toBe("host_modal.bots_disabled");
+ });
+ });
+
+ describe("Value Updates from Range Slider", () => {
+ it("should update value when slider input changes", async () => {
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+ expect(rangeInput).toBeTruthy();
+
+ // Simulate slider input
+ rangeInput.valueAsNumber = 250;
+ rangeInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await slider.updateComplete;
+
+ expect(slider.value).toBe(250);
+ });
+
+ it("should update value when slider change event fires", async () => {
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+
+ rangeInput.valueAsNumber = 100;
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true }));
+ await slider.updateComplete;
+
+ expect(slider.value).toBe(100);
+ });
+ });
+
+ describe("Value-Changed Event - CRITICAL FOR BUG FIX", () => {
+ it("should dispatch CustomEvent with detail.value (not event.target.value)", async () => {
+ const eventSpy = jest.fn();
+ slider.addEventListener("value-changed", eventSpy);
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+ rangeInput.valueAsNumber = 200;
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true }));
+ await slider.updateComplete;
+
+ expect(eventSpy).toHaveBeenCalled();
+ const event = eventSpy.mock.calls[0][0] as CustomEvent<{
+ value: number;
+ }>;
+
+ // CRITICAL: Event must have detail.value, not target.value
+ expect(event.detail).toBeDefined();
+ expect(event.detail.value).toBe(200);
+ expect(event.bubbles).toBe(true);
+ expect(event.composed).toBe(true);
+ });
+
+ it("should not dispatch event on input, only on change", async () => {
+ const eventSpy = jest.fn();
+ slider.addEventListener("value-changed", eventSpy);
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+
+ // Input event should NOT trigger value-changed
+ rangeInput.valueAsNumber = 150;
+ rangeInput.dispatchEvent(new Event("input", { bubbles: true }));
+ await slider.updateComplete;
+ expect(eventSpy).not.toHaveBeenCalled();
+
+ // Change event SHOULD trigger value-changed
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true }));
+ await slider.updateComplete;
+ expect(eventSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("should work with the handler pattern used in HostLobbyModal", async () => {
+ // This simulates the actual handler code in HostLobbyModal.ts:656-660
+ const mockHandler = jest.fn((e: Event) => {
+ const customEvent = e as CustomEvent<{ value: number }>;
+ const value = customEvent.detail.value;
+ if (isNaN(value) || value < 0 || value > 400) {
+ return;
+ }
+ // If we get here, the event structure is correct!
+ expect(value).toBeDefined();
+ expect(typeof value).toBe("number");
+ });
+
+ slider.addEventListener("value-changed", mockHandler);
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+ rangeInput.valueAsNumber = 250;
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true }));
+ await slider.updateComplete;
+
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(slider.value).toBe(250);
+ });
+
+ it("should work with the handler pattern used in SinglePlayerModal", async () => {
+ // This simulates the actual handler code in SinglePlayerModal.ts:444-451
+ const mockHandler = jest.fn((e: Event) => {
+ const customEvent = e as CustomEvent<{ value: number }>;
+ const value = customEvent.detail.value;
+ if (isNaN(value) || value < 0 || value > 400) {
+ return;
+ }
+ expect(value).toBeGreaterThanOrEqual(0);
+ expect(value).toBeLessThanOrEqual(400);
+ });
+
+ slider.addEventListener("value-changed", mockHandler);
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+ rangeInput.valueAsNumber = 350;
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true }));
+ await slider.updateComplete;
+
+ expect(mockHandler).toHaveBeenCalledTimes(1);
+ expect(slider.value).toBe(350);
+ });
+ });
+
+ describe("Value Validation via Number Input", () => {
+ it("should clamp values to min", () => {
+ slider.min = 10;
+ slider.max = 100;
+
+ // Simulate the handleNumberChange logic
+ let testValue = 5; // Below min
+ if (isNaN(testValue)) {
+ testValue = slider.min;
+ }
+ if (testValue < slider.min) testValue = slider.min;
+ if (testValue > slider.max) testValue = slider.max;
+
+ expect(testValue).toBe(10);
+ });
+
+ it("should clamp values to max", () => {
+ slider.min = 10;
+ slider.max = 100;
+
+ let testValue = 150; // Above max
+ if (isNaN(testValue)) {
+ testValue = slider.min;
+ }
+ if (testValue < slider.min) testValue = slider.min;
+ if (testValue > slider.max) testValue = slider.max;
+
+ expect(testValue).toBe(100);
+ });
+
+ it("should default to min when NaN", () => {
+ slider.min = 5;
+
+ let testValue = NaN;
+ if (isNaN(testValue)) {
+ testValue = slider.min;
+ }
+ if (testValue < slider.min) testValue = slider.min;
+ if (testValue > slider.max) testValue = slider.max;
+
+ expect(testValue).toBe(5);
+ });
+ });
+
+ describe("Component Structure", () => {
+ it("should render a range input", () => {
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ );
+ expect(rangeInput).toBeTruthy();
+ });
+
+ it("should have correct range input properties", () => {
+ slider.value = 150;
+ slider.min = 0;
+ slider.max = 400;
+ slider.step = 1;
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+
+ expect(rangeInput.min).toBe("0");
+ expect(rangeInput.max).toBe("400");
+ expect(rangeInput.step).toBe("1");
+ });
+
+ it("should render an editable span for the value display", () => {
+ const editableSpan = slider.shadowRoot?.querySelector("span.editable");
+ expect(editableSpan).toBeTruthy();
+ expect(editableSpan?.getAttribute("role")).toBe("button");
+ expect(editableSpan?.getAttribute("tabindex")).toBe("0");
+ });
+ });
+
+ describe("Bot Count Scenario - Regression Test", () => {
+ it("should correctly update bot count from 0 to 400 and dispatch proper events", async () => {
+ const capturedValues: number[] = [];
+
+ slider.addEventListener("value-changed", (e) => {
+ const customEvent = e as CustomEvent<{ value: number }>;
+ capturedValues.push(customEvent.detail.value);
+ });
+
+ slider.value = 0;
+ await slider.updateComplete;
+
+ const rangeInput = slider.shadowRoot?.querySelector(
+ 'input[type="range"]',
+ ) as HTMLInputElement;
+
+ // Simulate dragging the slider from 0 to 400
+ for (const val of [0, 100, 200, 300, 400]) {
+ rangeInput.valueAsNumber = val;
+ rangeInput.dispatchEvent(new Event("input", { bubbles: true })); // Updates display
+ rangeInput.dispatchEvent(new Event("change", { bubbles: true })); // Triggers event
+ await slider.updateComplete;
+ }
+
+ // Should have captured all change events (not input events)
+ expect(capturedValues).toEqual([0, 100, 200, 300, 400]);
+ expect(slider.value).toBe(400);
+ });
+ });
+});