diff --git a/apps/oxfmt/.gitignore b/apps/oxfmt/.gitignore
index 8f64b69e52470..29b4df735dc2f 100644
--- a/apps/oxfmt/.gitignore
+++ b/apps/oxfmt/.gitignore
@@ -1,5 +1,5 @@
/node_modules/
/dist/
-/conformance/fixtures/prettier/
-/conformance/fixtures/vue-vben-admin/
+/conformance/fixtures/**
+!/conformance/fixtures/edge-cases
*.node
diff --git a/apps/oxfmt/conformance/download-fixtures.js b/apps/oxfmt/conformance/download-fixtures.js
index 984d558d268dc..5bf5c0689ef9a 100644
--- a/apps/oxfmt/conformance/download-fixtures.js
+++ b/apps/oxfmt/conformance/download-fixtures.js
@@ -19,6 +19,11 @@ const sources = [
repo: "vbenjs/vue-vben-admin/packages",
version: "main",
},
+ {
+ name: "webawesome",
+ repo: "shoelace-style/webawesome/packages/webawesome/src/components",
+ version: "next",
+ },
// {
// name: "plugin-svelte",
// repo: "sveltejs/prettier-plugin-svelte/tests",
diff --git a/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/as-const.ts b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/as-const.ts
new file mode 100644
index 0000000000000..40d05475a2c2c
--- /dev/null
+++ b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/as-const.ts
@@ -0,0 +1,6 @@
+const HTML_WITH_CONST = /* HTML */ `
+
+` as const;
diff --git a/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/css-in-html-in-js.js b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/css-in-html-in-js.js
new file mode 100644
index 0000000000000..504a173fa8a4b
--- /dev/null
+++ b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/css-in-html-in-js.js
@@ -0,0 +1,13 @@
+// CSS in `;
+
+// CSS in style="" attributes is NOT formatted (`parentParser` blocks attribute-level sub-formatters).
+const styleAttr = /* HTML */ `hello
`;
+
+// Both combined: hello
`;
+
+// With expressions
+function d(color) {
+ return /* HTML */ `${color}
`;
+}
diff --git a/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/last-argument-expansion.js b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/last-argument-expansion.js
new file mode 100644
index 0000000000000..6ad36c16c2767
--- /dev/null
+++ b/apps/oxfmt/conformance/fixtures/edge-cases/html-in-js/last-argument-expansion.js
@@ -0,0 +1,11 @@
+// Tagged: html`...`
+foo(html``);
+foo(html` `);
+const a = b => html``;
+const c = b => html` `;
+
+// Comment: /* HTML */ `...`
+foo(/* HTML */ ``);
+foo(/* HTML */ ` `);
+const e = b => /* HTML */ ``;
+const g = b => /* HTML */ ` `;
diff --git a/apps/oxfmt/conformance/run.ts b/apps/oxfmt/conformance/run.ts
index d73a93dd2af66..39082df1a283a 100644
--- a/apps/oxfmt/conformance/run.ts
+++ b/apps/oxfmt/conformance/run.ts
@@ -78,6 +78,30 @@ const categories: Category[] = [
"styled-components.js": "`Xxx.extend` not recognized as tag",
},
},
+ {
+ name: "html-in-js",
+ sources: [
+ {
+ dir: join(PRETTIER_FIXTURES_DIR, "js/multiparser-html"),
+ ext: ".js",
+ excludes: ["format.test.js"],
+ },
+ {
+ dir: join(FIXTURES_DIR, "webawesome"),
+ ext: ".ts",
+ },
+ { dir: join(EDGE_CASES_DIR, "html-in-js") },
+ ],
+ optionSets: [{ printWidth: 80 }, { printWidth: 100, htmlWhitespaceSensitivity: "ignore" }],
+ notes: {
+ "issue-10691.js":
+ "js-in-html(``;
++ return /* HTML */ `
++
++ `;
+ }
+
+`````
+
+### Actual (oxfmt)
+
+`````js
+export default function include_photoswipe(gallery_selector = ".my-gallery") {
+ return /* HTML */ `
+
+ `;
+}
+
+`````
+
+### Expected (prettier)
+
+`````js
+export default function include_photoswipe(gallery_selector = ".my-gallery") {
+ return /* HTML */ ` `;
+}
+
+`````
+
+## Option 2
+
+`````json
+{"printWidth":100,"htmlWhitespaceSensitivity":"ignore"}
+`````
+
+### Diff
+
+`````diff
+===================================================================
+--- prettier
++++ oxfmt
+@@ -1,7 +1,9 @@
+ export default function include_photoswipe(gallery_selector = ".my-gallery") {
+ return /* HTML */ `
+
+ `;
+ }
+
+`````
+
+### Actual (oxfmt)
+
+`````js
+export default function include_photoswipe(gallery_selector = ".my-gallery") {
+ return /* HTML */ `
+
+ `;
+}
+
+`````
+
+### Expected (prettier)
+
+`````js
+export default function include_photoswipe(gallery_selector = ".my-gallery") {
+ return /* HTML */ `
+
+ `;
+}
+
+`````
diff --git a/apps/oxfmt/conformance/snapshots/diffs/html-in-js/relative-time__relative-time.test.ts.md b/apps/oxfmt/conformance/snapshots/diffs/html-in-js/relative-time__relative-time.test.ts.md
new file mode 100644
index 0000000000000..ece21d06a0d47
--- /dev/null
+++ b/apps/oxfmt/conformance/snapshots/diffs/html-in-js/relative-time__relative-time.test.ts.md
@@ -0,0 +1,638 @@
+# relative-time/relative-time.test.ts
+
+> html-in-js: Need to solve `label({ embed, hug }))` + `shouldExpandLastArg`
+
+## Option 1
+
+`````json
+{"printWidth":80}
+`````
+
+### Diff
+
+`````diff
+===================================================================
+--- prettier
++++ oxfmt
+@@ -119,16 +119,15 @@
+
+ it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
+ const dateString = testCase.date.toISOString();
+
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+
+ await expectFormattedRelativeTimeToBe(
+ relativeTime,
+ testCase.expectedOutput,
+@@ -136,27 +135,25 @@
+ });
+ });
+
+ it("always shows numeric if requested via numeric property", async () => {
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "1 day ago");
+ });
+
+ it("shows human readable form if appropriate and numeric property is auto", async () => {
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "yesterday");
+ });
+@@ -175,17 +172,16 @@
+ it("allows to use a short form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 yr. ago");
+ });
+@@ -193,28 +189,26 @@
+ it("allows to use a long form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 years ago");
+ });
+
+ it("is formatted according to the requested locale", async () => {
+- const relativeTime: WaRelativeTime = await fixture(
+- html`
++ const relativeTime: WaRelativeTime =
++ await fixture(html`
+
+- `,
+- );
++ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "gestern");
+ });
+
+`````
+
+### Actual (oxfmt)
+
+`````ts
+import { expect } from "@open-wc/testing";
+import { html } from "lit";
+import sinon from "sinon";
+import type { hydratedFixture } from "../../internal/test/fixture.js";
+import { clientFixture } from "../../internal/test/fixture.js";
+import type WaRelativeTime from "./relative-time.js";
+
+interface WaRelativeTimeTestCase {
+ date: Date;
+ expectedOutput: string;
+}
+
+const extractTimeElement = (
+ relativeTime: WaRelativeTime,
+): HTMLTimeElement | null => {
+ return relativeTime.shadowRoot?.querySelector("time") || null;
+};
+
+const expectFormattedRelativeTimeToBe = async (
+ relativeTime: WaRelativeTime,
+ expectedOutput: string,
+): Promise => {
+ await relativeTime.updateComplete;
+ const textContent = extractTimeElement(relativeTime)?.textContent;
+ expect(textContent).to.equal(expectedOutput);
+};
+
+const createRelativeTimeWithDate = async (
+ relativeDate: Date,
+ fixture: typeof hydratedFixture | typeof clientFixture,
+): Promise => {
+ const relativeTime: WaRelativeTime = await fixture(html`
+
+ `);
+ relativeTime.date = relativeDate;
+ return relativeTime;
+};
+
+const minuteInSeconds = 60_000;
+const hourInSeconds = minuteInSeconds * 60;
+const dayInSeconds = hourInSeconds * 24;
+const weekInSeconds = dayInSeconds * 7;
+const monthInSeconds = dayInSeconds * 30;
+const nonLeapYearInSeconds = dayInSeconds * 356;
+
+const currentTime = new Date("2022-10-30T15:22:10.100Z");
+const yesterday = new Date(currentTime.getTime() - dayInSeconds);
+const testCases: WaRelativeTimeTestCase[] = [
+ {
+ date: new Date(currentTime.getTime() - minuteInSeconds),
+ expectedOutput: "1 minute ago",
+ },
+ {
+ date: new Date(currentTime.getTime() - hourInSeconds),
+ expectedOutput: "1 hour ago",
+ },
+ {
+ date: yesterday,
+ expectedOutput: "yesterday",
+ },
+ {
+ date: new Date(currentTime.getTime() - 4 * dayInSeconds),
+ expectedOutput: "4 days ago",
+ },
+ {
+ date: new Date(currentTime.getTime() - weekInSeconds),
+ expectedOutput: "last week",
+ },
+ {
+ date: new Date(currentTime.getTime() - monthInSeconds),
+ expectedOutput: "last month",
+ },
+ {
+ date: new Date(currentTime.getTime() - nonLeapYearInSeconds),
+ expectedOutput: "last year",
+ },
+ {
+ date: new Date(currentTime.getTime() + minuteInSeconds),
+ expectedOutput: "in 1 minute",
+ },
+];
+
+describe("wa-relative-time", () => {
+ // @TODO: figure out why hydratedFixture behaves differently from clientFixture
+ for (const fixture of [clientFixture]) {
+ describe(`with "${fixture.type}" rendering`, () => {
+ it("should pass accessibility tests", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ currentTime,
+ fixture,
+ );
+
+ await expect(relativeTime).to.be.accessible();
+ });
+
+ describe("handles time correctly", () => {
+ let clock: sinon.SinonFakeTimers | null = null;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(currentTime);
+ });
+
+ afterEach(() => {
+ clock?.restore();
+ });
+
+ testCases.forEach((testCase) => {
+ it(`shows the correct relative time given a Date object: ${testCase.expectedOutput}`, async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ testCase.date,
+ fixture,
+ );
+
+ await expectFormattedRelativeTimeToBe(
+ relativeTime,
+ testCase.expectedOutput,
+ );
+ });
+
+ it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
+ const dateString = testCase.date.toISOString();
+
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+
+ await expectFormattedRelativeTimeToBe(
+ relativeTime,
+ testCase.expectedOutput,
+ );
+ });
+ });
+
+ it("always shows numeric if requested via numeric property", async () => {
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "1 day ago");
+ });
+
+ it("shows human readable form if appropriate and numeric property is auto", async () => {
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "yesterday");
+ });
+
+ it("shows the set date with the proper attributes at the time object", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ yesterday,
+ fixture,
+ );
+
+ await relativeTime.updateComplete;
+ const timeElement = extractTimeElement(relativeTime);
+ expect(timeElement?.dateTime).to.equal(yesterday.toISOString());
+ });
+
+ it("allows to use a short form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 yr. ago");
+ });
+
+ it("allows to use a long form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 years ago");
+ });
+
+ it("is formatted according to the requested locale", async () => {
+ const relativeTime: WaRelativeTime =
+ await fixture(html`
+
+ `);
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "gestern");
+ });
+
+ it("keeps the component in sync if requested", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ yesterday,
+ fixture,
+ );
+ relativeTime.sync = true;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "yesterday");
+
+ clock?.tick(dayInSeconds);
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 days ago");
+ });
+ });
+
+ it("does not display a time element on invalid time string", async () => {
+ const invalidDateString = "thisIsNotATimeString";
+
+ const relativeTime: WaRelativeTime = await fixture(html`
+
+ `);
+
+ await relativeTime.updateComplete;
+ expect(extractTimeElement(relativeTime)).to.be.null;
+ });
+ });
+ }
+});
+
+`````
+
+### Expected (prettier)
+
+`````ts
+import { expect } from "@open-wc/testing";
+import { html } from "lit";
+import sinon from "sinon";
+import type { hydratedFixture } from "../../internal/test/fixture.js";
+import { clientFixture } from "../../internal/test/fixture.js";
+import type WaRelativeTime from "./relative-time.js";
+
+interface WaRelativeTimeTestCase {
+ date: Date;
+ expectedOutput: string;
+}
+
+const extractTimeElement = (
+ relativeTime: WaRelativeTime,
+): HTMLTimeElement | null => {
+ return relativeTime.shadowRoot?.querySelector("time") || null;
+};
+
+const expectFormattedRelativeTimeToBe = async (
+ relativeTime: WaRelativeTime,
+ expectedOutput: string,
+): Promise => {
+ await relativeTime.updateComplete;
+ const textContent = extractTimeElement(relativeTime)?.textContent;
+ expect(textContent).to.equal(expectedOutput);
+};
+
+const createRelativeTimeWithDate = async (
+ relativeDate: Date,
+ fixture: typeof hydratedFixture | typeof clientFixture,
+): Promise => {
+ const relativeTime: WaRelativeTime = await fixture(html`
+
+ `);
+ relativeTime.date = relativeDate;
+ return relativeTime;
+};
+
+const minuteInSeconds = 60_000;
+const hourInSeconds = minuteInSeconds * 60;
+const dayInSeconds = hourInSeconds * 24;
+const weekInSeconds = dayInSeconds * 7;
+const monthInSeconds = dayInSeconds * 30;
+const nonLeapYearInSeconds = dayInSeconds * 356;
+
+const currentTime = new Date("2022-10-30T15:22:10.100Z");
+const yesterday = new Date(currentTime.getTime() - dayInSeconds);
+const testCases: WaRelativeTimeTestCase[] = [
+ {
+ date: new Date(currentTime.getTime() - minuteInSeconds),
+ expectedOutput: "1 minute ago",
+ },
+ {
+ date: new Date(currentTime.getTime() - hourInSeconds),
+ expectedOutput: "1 hour ago",
+ },
+ {
+ date: yesterday,
+ expectedOutput: "yesterday",
+ },
+ {
+ date: new Date(currentTime.getTime() - 4 * dayInSeconds),
+ expectedOutput: "4 days ago",
+ },
+ {
+ date: new Date(currentTime.getTime() - weekInSeconds),
+ expectedOutput: "last week",
+ },
+ {
+ date: new Date(currentTime.getTime() - monthInSeconds),
+ expectedOutput: "last month",
+ },
+ {
+ date: new Date(currentTime.getTime() - nonLeapYearInSeconds),
+ expectedOutput: "last year",
+ },
+ {
+ date: new Date(currentTime.getTime() + minuteInSeconds),
+ expectedOutput: "in 1 minute",
+ },
+];
+
+describe("wa-relative-time", () => {
+ // @TODO: figure out why hydratedFixture behaves differently from clientFixture
+ for (const fixture of [clientFixture]) {
+ describe(`with "${fixture.type}" rendering`, () => {
+ it("should pass accessibility tests", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ currentTime,
+ fixture,
+ );
+
+ await expect(relativeTime).to.be.accessible();
+ });
+
+ describe("handles time correctly", () => {
+ let clock: sinon.SinonFakeTimers | null = null;
+
+ beforeEach(() => {
+ clock = sinon.useFakeTimers(currentTime);
+ });
+
+ afterEach(() => {
+ clock?.restore();
+ });
+
+ testCases.forEach((testCase) => {
+ it(`shows the correct relative time given a Date object: ${testCase.expectedOutput}`, async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ testCase.date,
+ fixture,
+ );
+
+ await expectFormattedRelativeTimeToBe(
+ relativeTime,
+ testCase.expectedOutput,
+ );
+ });
+
+ it(`shows the correct relative time given a String object: ${testCase.expectedOutput}`, async () => {
+ const dateString = testCase.date.toISOString();
+
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+
+ await expectFormattedRelativeTimeToBe(
+ relativeTime,
+ testCase.expectedOutput,
+ );
+ });
+ });
+
+ it("always shows numeric if requested via numeric property", async () => {
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "1 day ago");
+ });
+
+ it("shows human readable form if appropriate and numeric property is auto", async () => {
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "yesterday");
+ });
+
+ it("shows the set date with the proper attributes at the time object", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ yesterday,
+ fixture,
+ );
+
+ await relativeTime.updateComplete;
+ const timeElement = extractTimeElement(relativeTime);
+ expect(timeElement?.dateTime).to.equal(yesterday.toISOString());
+ });
+
+ it("allows to use a short form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 yr. ago");
+ });
+
+ it("allows to use a long form of the unit", async () => {
+ const twoYearsAgo = new Date(
+ currentTime.getTime() - 2 * nonLeapYearInSeconds,
+ );
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+ relativeTime.date = twoYearsAgo;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 years ago");
+ });
+
+ it("is formatted according to the requested locale", async () => {
+ const relativeTime: WaRelativeTime = await fixture(
+ html`
+
+ `,
+ );
+ relativeTime.date = yesterday;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "gestern");
+ });
+
+ it("keeps the component in sync if requested", async () => {
+ const relativeTime = await createRelativeTimeWithDate(
+ yesterday,
+ fixture,
+ );
+ relativeTime.sync = true;
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "yesterday");
+
+ clock?.tick(dayInSeconds);
+
+ await expectFormattedRelativeTimeToBe(relativeTime, "2 days ago");
+ });
+ });
+
+ it("does not display a time element on invalid time string", async () => {
+ const invalidDateString = "thisIsNotATimeString";
+
+ const relativeTime: WaRelativeTime = await fixture(html`
+
+ `);
+
+ await relativeTime.updateComplete;
+ expect(extractTimeElement(relativeTime)).to.be.null;
+ });
+ });
+ }
+});
+
+`````
diff --git a/apps/oxfmt/conformance/snapshots/diffs/html-in-js/slider__slider.ts.md b/apps/oxfmt/conformance/snapshots/diffs/html-in-js/slider__slider.ts.md
new file mode 100644
index 0000000000000..e52ad88623662
--- /dev/null
+++ b/apps/oxfmt/conformance/snapshots/diffs/html-in-js/slider__slider.ts.md
@@ -0,0 +1,2281 @@
+# slider/slider.ts
+
+> `@decorator` + union type: https://github.com/oxc-project/oxc/issues/20519
+
+## Option 1
+
+`````json
+{"printWidth":80}
+`````
+
+### Diff
+
+`````diff
+===================================================================
+--- prettier
++++ oxfmt
+@@ -198,10 +198,13 @@
+ @property({ attribute: "tooltip-distance", type: Number }) tooltipDistance =
+ 8;
+
+ /** The placement of the tooltip in reference to the slider's thumb. */
+- @property({ attribute: "tooltip-placement", reflect: true })
+- tooltipPlacement: "top" | "right" | "bottom" | "left" = "top";
++ @property({ attribute: "tooltip-placement", reflect: true }) tooltipPlacement:
++ | "top"
++ | "right"
++ | "bottom"
++ | "left" = "top";
+
+ /** Draws markers at each step along the slider. */
+ @property({ attribute: "with-markers", type: Boolean }) withMarkers = false;
+
+
+`````
+
+### Actual (oxfmt)
+
+`````ts
+import type { PropertyValues } from "lit";
+import { html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { styleMap } from "lit/directives/style-map.js";
+import { DraggableElement } from "../../internal/drag.js";
+import { clamp } from "../../internal/math.js";
+import { HasSlotController } from "../../internal/slot.js";
+import { submitOnEnter } from "../../internal/submit-on-enter.js";
+import { SliderValidator } from "../../internal/validators/slider-validator.js";
+import { WebAwesomeFormAssociatedElement } from "../../internal/webawesome-form-associated-element.js";
+import formControlStyles from "../../styles/component/form-control.styles.js";
+import sizeStyles from "../../styles/component/size.styles.js";
+import { LocalizeController } from "../../utilities/localize.js";
+import "../tooltip/tooltip.js";
+import type WaTooltip from "../tooltip/tooltip.js";
+import styles from "./slider.styles.js";
+
+/**
+ *
+ *
+ * @summary Ranges allow the user to select a single value within a given range using a slider.
+ * @documentation https://webawesome.com/docs/components/range
+ * @status stable
+ * @since 2.0
+ *
+ * @dependency wa-tooltip
+ *
+ * @slot label - The slider label. Alternatively, you can use the `label` attribute.
+ * @slot hint - Text that describes how to use the input. Alternatively, you can use the `hint` attribute.
+ * instead.
+ * @slot reference - One or more reference labels to show visually below the slider.
+ *
+ * @event blur - Emitted when the control loses focus.
+ * @event change - Emitted when an alteration to the control's value is committed by the user.
+ * @event focus - Emitted when the control gains focus.
+ * @event input - Emitted when the control receives input.
+ * @event wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
+ *
+ * @csspart label - The element that contains the sliders's label.
+ * @csspart hint - The element that contains the slider's description.
+ * @csspart slider - The focusable element with `role="slider"`. Contains the track and reference slot.
+ * @csspart track - The slider's track.
+ * @csspart indicator - The colored indicator that shows from the start of the slider to the current value.
+ * @csspart markers - The container that holds all the markers when `with-markers` is used.
+ * @csspart marker - The individual markers that are shown when `with-markers` is used.
+ * @csspart references - The container that holds references that get slotted in.
+ * @csspart thumb - The slider's thumb.
+ * @csspart thumb-min - The min value thumb in a range slider.
+ * @csspart thumb-max - The max value thumb in a range slider.
+ * @csspart tooltip - The tooltip, a `` element.
+ * @csspart tooltip__tooltip - The tooltip's `tooltip` part.
+ * @csspart tooltip__content - The tooltip's `content` part.
+ * @csspart tooltip__arrow - The tooltip's `arrow` part.
+ *
+ * @cssstate disabled - Applied when the slider is disabled.
+ * @cssstate dragging - Applied when the slider is being dragged.
+ * @cssstate focused - Applied when the slider has focus.
+ * @cssstate user-valid - Applied when the slider is valid and the user has sufficiently interacted with it.
+ * @cssstate user-invalid - Applied when the slider is invalid and the user has sufficiently interacted with it.
+ *
+ * @cssproperty [--track-size=0.75em] - The height or width of the slider's track.
+ * @cssproperty [--marker-width=0.1875em] - The width of each individual marker.
+ * @cssproperty [--marker-height=0.1875em] - The height of each individual marker.
+ * @cssproperty [--thumb-width=1.25em] - The width of the thumb.
+ * @cssproperty [--thumb-height=1.25em] - The height of the thumb.
+ */
+@customElement("wa-slider")
+export default class WaSlider extends WebAwesomeFormAssociatedElement {
+ static formAssociated = true;
+ static observeSlots = true;
+ static css = [sizeStyles, formControlStyles, styles];
+
+ static get validators() {
+ return [...super.validators, SliderValidator()];
+ }
+
+ private draggableTrack: DraggableElement;
+ private draggableThumbMin: DraggableElement | null = null;
+ private draggableThumbMax: DraggableElement | null = null;
+ private readonly hasSlotController = new HasSlotController(
+ this,
+ "hint",
+ "label",
+ );
+ private readonly localize = new LocalizeController(this);
+ private trackBoundingClientRect: DOMRect;
+ private valueWhenDraggingStarted: number | undefined | null;
+ private activeThumb: "min" | "max" | null = null;
+ private lastTrackPosition: number | null = null; // Track last position for direction detection
+
+ protected get focusableAnchor() {
+ return this.isRange ? this.thumbMin || this.slider : this.slider;
+ }
+
+ /** Override validation target to point to the focusable element */
+ get validationTarget() {
+ return this.focusableAnchor;
+ }
+
+ @query("#slider") slider: HTMLElement;
+ @query("#thumb") thumb: HTMLElement;
+ @query("#thumb-min") thumbMin: HTMLElement;
+ @query("#thumb-max") thumbMax: HTMLElement;
+ @query("#track") track: HTMLElement;
+ @query("#tooltip") tooltip: WaTooltip;
+
+ /**
+ * The slider's label. If you need to provide HTML in the label, use the `label` slot instead.
+ */
+ @property() label: string = "";
+
+ /** The slider hint. If you need to display HTML, use the hint slot instead. */
+ @property({ attribute: "hint" }) hint = "";
+
+ /** The name of the slider. This will be submitted with the form as a name/value pair. */
+ @property({ reflect: true }) name: string;
+
+ /** The minimum value of a range selection. Used only when range attribute is set. */
+ @property({ type: Number, attribute: "min-value" }) minValue = 0;
+
+ /** The maximum value of a range selection. Used only when range attribute is set. */
+ @property({ type: Number, attribute: "max-value" }) maxValue = 50;
+
+ /** The default value of the form control. Primarily used for resetting the form control. */
+ @property({ attribute: "value", reflect: true, type: Number })
+ defaultValue: number =
+ this.getAttribute("value") == null
+ ? this.minValue
+ : Number(this.getAttribute("value"));
+
+ private _value: number | null = null;
+
+ /** The current value of the slider, submitted as a name/value pair with form data. */
+ get value(): number {
+ if (this.valueHasChanged) {
+ const val = this._value ?? this.minValue ?? 0;
+ return clamp(val, this.min, this.max);
+ }
+
+ const val = this._value ?? this.defaultValue;
+ return clamp(val, this.min, this.max);
+ }
+
+ @state()
+ set value(val: number | null) {
+ val = Number(val) ?? this.minValue;
+
+ if (this._value === val) {
+ return;
+ }
+
+ this.valueHasChanged = true;
+ this._value = val;
+ }
+
+ /** Converts the slider to a range slider with two thumbs. */
+ @property({ type: Boolean, reflect: true }) range = false;
+
+ /** Get if this is a range slider */
+ get isRange(): boolean {
+ return this.range;
+ }
+
+ /** Disables the slider. */
+ @property({ type: Boolean }) disabled = false;
+
+ /** Makes the slider a read-only field. */
+ @property({ type: Boolean, reflect: true }) readonly = false;
+
+ /** The orientation of the slider. */
+ @property({ reflect: true }) orientation: "horizontal" | "vertical" =
+ "horizontal";
+
+ /** The slider's size. */
+ @property({ reflect: true }) size: "small" | "medium" | "large" = "medium";
+
+ /** The starting value from which to draw the slider's fill, which is based on its current value. */
+ @property({ attribute: "indicator-offset", type: Number })
+ indicatorOffset: number;
+
+ /** The minimum value allowed. */
+ @property({ type: Number }) min: number = 0;
+
+ /** The maximum value allowed. */
+ @property({ type: Number }) max: number = 100;
+
+ /** The granularity the value must adhere to when incrementing and decrementing. */
+ @property({ type: Number }) step: number = 1;
+
+ /** Makes the slider a required field. */
+ @property({ type: Boolean, reflect: true }) required = false;
+
+ /** Tells the browser to focus the slider when the page loads or a dialog is shown. */
+ @property({ type: Boolean }) autofocus: boolean;
+
+ /** The distance of the tooltip from the slider's thumb. */
+ @property({ attribute: "tooltip-distance", type: Number }) tooltipDistance =
+ 8;
+
+ /** The placement of the tooltip in reference to the slider's thumb. */
+ @property({ attribute: "tooltip-placement", reflect: true }) tooltipPlacement:
+ | "top"
+ | "right"
+ | "bottom"
+ | "left" = "top";
+
+ /** Draws markers at each step along the slider. */
+ @property({ attribute: "with-markers", type: Boolean }) withMarkers = false;
+
+ /** Draws a tooltip above the thumb when the control has focus or is dragged. */
+ @property({ attribute: "with-tooltip", type: Boolean }) withTooltip = false;
+
+ /**
+ * A custom formatting function to apply to the value. This will be shown in the tooltip and announced by screen
+ * readers. Must be set with JavaScript. Property only.
+ */
+ @property({ attribute: false }) valueFormatter: (value: number) => string;
+
+ firstUpdated() {
+ // Setup dragging based on range or single thumb mode
+ if (this.isRange) {
+ // Enable dragging on both thumbs for range slider
+ this.draggableThumbMin = new DraggableElement(this.thumbMin, {
+ start: () => {
+ this.activeThumb = "min";
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.minValue;
+ this.customStates.set("dragging", true);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ this.setThumbValueFromCoordinates(x, y, "min");
+ },
+ stop: () => {
+ if (this.minValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+
+ this.draggableThumbMax = new DraggableElement(this.thumbMax, {
+ start: () => {
+ this.activeThumb = "max";
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.maxValue;
+ this.customStates.set("dragging", true);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ this.setThumbValueFromCoordinates(x, y, "max");
+ },
+ stop: () => {
+ if (this.maxValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+
+ // Enable track dragging for finding the closest thumb
+ this.draggableTrack = new DraggableElement(this.track, {
+ start: (x, y) => {
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+
+ // When a drag starts, we need to determine which thumb to move
+ // If the thumbs are in nearly the same position, we prioritize the one that's already active
+ // or the one that received focus most recently
+ if (this.activeThumb) {
+ // Keep using the already active thumb (useful for keyboard interactions)
+ this.valueWhenDraggingStarted =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ } else {
+ // Otherwise select by closest distance
+ const value = this.getValueFromCoordinates(x, y);
+ const minDistance = Math.abs(value - this.minValue);
+ const maxDistance = Math.abs(value - this.maxValue);
+
+ if (minDistance === maxDistance) {
+ // If distances are equal, prioritize the max thumb when value is higher than both thumbs
+ // and min thumb when value is lower than both thumbs
+ if (value > this.maxValue) {
+ this.activeThumb = "max";
+ } else if (value < this.minValue) {
+ this.activeThumb = "min";
+ } else {
+ // If the value is between the thumbs and they're at the same distance,
+ // prioritize the thumb that's in the direction of movement
+ const isRtl = this.localize.dir() === "rtl";
+ const isVertical = this.orientation === "vertical";
+ const position = isVertical ? y : x;
+ const previousPosition = this.lastTrackPosition || position;
+ this.lastTrackPosition = position;
+
+ // Determine direction of movement
+ const movingForward =
+ (position > previousPosition !== isRtl && !isVertical) ||
+ (position < previousPosition && isVertical);
+
+ this.activeThumb = movingForward ? "max" : "min";
+ }
+ } else {
+ // Select the closest thumb
+ this.activeThumb = minDistance <= maxDistance ? "min" : "max";
+ }
+
+ this.valueWhenDraggingStarted =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ }
+
+ this.customStates.set("dragging", true);
+ this.setThumbValueFromCoordinates(x, y, this.activeThumb);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ if (this.activeThumb) {
+ this.setThumbValueFromCoordinates(x, y, this.activeThumb);
+ }
+ },
+ stop: () => {
+ if (this.activeThumb) {
+ const currentValue =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ if (currentValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+ } else {
+ // Single thumb mode - original behavior
+ this.draggableTrack = new DraggableElement(this.slider, {
+ start: (x, y) => {
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.value;
+ this.customStates.set("dragging", true);
+ this.setValueFromCoordinates(x, y);
+ this.showTooltip();
+ },
+ move: (x, y) => {
+ this.setValueFromCoordinates(x, y);
+ },
+ stop: () => {
+ if (this.value !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+
+ this.hasInteracted = true;
+ }
+ this.hideTooltip();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ },
+ });
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ // Handle range mode changes
+ if (changedProperties.has("range")) {
+ this.requestUpdate();
+ }
+
+ if (this.isRange) {
+ // Handle min/max values for range mode
+ if (
+ changedProperties.has("minValue") ||
+ changedProperties.has("maxValue")
+ ) {
+ // Ensure min doesn't exceed max
+ this.minValue = clamp(this.minValue, this.min, this.maxValue);
+ this.maxValue = clamp(this.maxValue, this.minValue, this.max);
+ // Update form value
+ this.updateFormValue();
+ }
+ } else {
+ // Handle value for single thumb mode
+ if (changedProperties.has("value")) {
+ this.setValue(String(this.value));
+ }
+ }
+
+ // Handle min/max
+ if (changedProperties.has("min") || changedProperties.has("max")) {
+ if (this.isRange) {
+ this.minValue = clamp(this.minValue, this.min, this.max);
+ this.maxValue = clamp(this.maxValue, this.min, this.max);
+ }
+ }
+
+ // Handle disabled
+ if (changedProperties.has("disabled")) {
+ this.customStates.set("disabled", this.disabled);
+ }
+
+ // Disable dragging when disabled or readonly
+ if (
+ changedProperties.has("disabled") ||
+ changedProperties.has("readonly")
+ ) {
+ const enabled = !(this.disabled || this.readonly);
+
+ if (this.isRange) {
+ if (this.draggableThumbMin) this.draggableThumbMin.toggle(enabled);
+ if (this.draggableThumbMax) this.draggableThumbMax.toggle(enabled);
+ }
+
+ if (this.draggableTrack) {
+ this.draggableTrack.toggle(enabled);
+ }
+ }
+
+ super.updated(changedProperties);
+ }
+
+ /** @internal Called when a containing fieldset is disabled. */
+ formDisabledCallback(isDisabled: boolean) {
+ this.disabled = isDisabled;
+ }
+
+ /** @internal Called when the form is reset. */
+ formResetCallback() {
+ if (this.isRange) {
+ this.minValue = parseFloat(
+ this.getAttribute("min-value") ?? String(this.min),
+ );
+ this.maxValue = parseFloat(
+ this.getAttribute("max-value") ?? String(this.max),
+ );
+ } else {
+ this._value = null;
+ this.defaultValue =
+ this.defaultValue ??
+ parseFloat(this.getAttribute("value") ?? String(this.min));
+ }
+ this.valueHasChanged = false;
+ this.hasInteracted = false;
+ super.formResetCallback();
+ }
+
+ /** Clamps a number to min/max while ensuring it's a valid step interval. */
+ private clampAndRoundToStep(value: number) {
+ const stepPrecision = (String(this.step).split(".")[1] || "").replace(
+ /0+$/g,
+ "",
+ ).length;
+
+ // Ensure we're working with numbers (in case the user passes strings to the respective properties)
+ const step = Number(this.step);
+ const min = Number(this.min);
+ const max = Number(this.max);
+
+ value = Math.round(value / step) * step;
+ value = clamp(value, min, max);
+
+ return parseFloat(value.toFixed(stepPrecision));
+ }
+
+ /** Given a value, returns its percentage within a range of min/max. */
+ private getPercentageFromValue(value: number) {
+ return ((value - this.min) / (this.max - this.min)) * 100;
+ }
+
+ /** Converts coordinates to slider value */
+ private getValueFromCoordinates(x: number, y: number) {
+ const isRtl = this.localize.dir() === "rtl";
+ const isVertical = this.orientation === "vertical";
+ const { top, right, bottom, left, height, width } =
+ this.trackBoundingClientRect;
+ const pointerPosition = isVertical ? y : x;
+ const sliderCoords = isVertical
+ ? { start: top, end: bottom, size: height }
+ : { start: left, end: right, size: width };
+ const relativePosition = isVertical
+ ? sliderCoords.end - pointerPosition
+ : isRtl
+ ? sliderCoords.end - pointerPosition
+ : pointerPosition - sliderCoords.start;
+ const percentage = relativePosition / sliderCoords.size;
+ return this.clampAndRoundToStep(
+ this.min + (this.max - this.min) * percentage,
+ );
+ }
+
+ private handleBlur() {
+ // Only hide tooltips if neither thumb has focus
+ if (this.isRange) {
+ // Allow a subsequent focus event to fire on the other thumb if the user is tabbing
+ requestAnimationFrame(() => {
+ const focusedElement = this.shadowRoot?.activeElement;
+ const thumbHasFocus =
+ focusedElement === this.thumbMin || focusedElement === this.thumbMax;
+ if (!thumbHasFocus) {
+ this.hideRangeTooltips();
+ }
+ });
+ } else {
+ this.hideTooltip();
+ }
+ this.customStates.set("focused", false);
+ this.dispatchEvent(
+ new FocusEvent("blur", { bubbles: true, composed: true }),
+ );
+ }
+
+ private handleFocus(event: FocusEvent) {
+ const target = event.target as HTMLElement;
+
+ // Handle focus for specific thumbs in range mode
+ if (this.isRange) {
+ if (target === this.thumbMin) {
+ this.activeThumb = "min";
+ } else if (target === this.thumbMax) {
+ this.activeThumb = "max";
+ }
+ this.showRangeTooltips();
+ } else {
+ this.showTooltip();
+ }
+
+ this.customStates.set("focused", true);
+ this.dispatchEvent(
+ new FocusEvent("focus", { bubbles: true, composed: true }),
+ );
+ }
+
+ private handleKeyDown(event: KeyboardEvent) {
+ const isRtl = this.localize.dir() === "rtl";
+ const target = event.target as HTMLElement;
+
+ if (this.disabled || this.readonly) return;
+
+ // For range slider, determine which thumb is active
+ if (this.isRange) {
+ if (target === this.thumbMin) {
+ this.activeThumb = "min";
+ } else if (target === this.thumbMax) {
+ this.activeThumb = "max";
+ }
+
+ if (!this.activeThumb) return;
+ }
+
+ // Get current value based on slider mode
+ const current = this.isRange
+ ? this.activeThumb === "min"
+ ? this.minValue
+ : this.maxValue
+ : this.value;
+
+ let newValue = current;
+
+ // Handle key presses
+ switch (event.key) {
+ // Increase
+ case "ArrowUp":
+ case isRtl ? "ArrowLeft" : "ArrowRight":
+ event.preventDefault();
+ newValue = this.clampAndRoundToStep(current + this.step);
+ break;
+
+ // Decrease
+ case "ArrowDown":
+ case isRtl ? "ArrowRight" : "ArrowLeft":
+ event.preventDefault();
+ newValue = this.clampAndRoundToStep(current - this.step);
+ break;
+
+ // Minimum value
+ case "Home":
+ event.preventDefault();
+ newValue =
+ this.isRange && this.activeThumb === "min"
+ ? this.min
+ : this.isRange
+ ? this.minValue
+ : this.min;
+ break;
+
+ // Maximum value
+ case "End":
+ event.preventDefault();
+ newValue =
+ this.isRange && this.activeThumb === "max"
+ ? this.max
+ : this.isRange
+ ? this.maxValue
+ : this.max;
+ break;
+
+ // Move up 10%
+ case "PageUp":
+ event.preventDefault();
+ const stepUp = Math.max(
+ current + (this.max - this.min) / 10,
+ current + this.step, // make sure we at least move up to the next step
+ );
+ newValue = this.clampAndRoundToStep(stepUp);
+ break;
+
+ // Move down 10%
+ case "PageDown":
+ event.preventDefault();
+ const stepDown = Math.min(
+ current - (this.max - this.min) / 10,
+ current - this.step, // make sure we at least move down to the previous step
+ );
+ newValue = this.clampAndRoundToStep(stepDown);
+ break;
+
+ // Handle form submission on Enter
+ case "Enter":
+ submitOnEnter(event, this);
+ return;
+ }
+
+ // If no value change, exit early
+ if (newValue === current) return;
+
+ // Apply the new value with appropriate constraints
+ if (this.isRange) {
+ if (this.activeThumb === "min") {
+ if (newValue > this.maxValue) {
+ // If min thumb exceeds max thumb, move both
+ this.maxValue = newValue;
+ this.minValue = newValue;
+ } else {
+ this.minValue = Math.max(this.min, newValue);
+ }
+ } else {
+ if (newValue < this.minValue) {
+ // If max thumb goes below min thumb, move both
+ this.minValue = newValue;
+ this.maxValue = newValue;
+ } else {
+ this.maxValue = Math.min(this.max, newValue);
+ }
+ }
+ this.updateFormValue();
+ } else {
+ this.value = clamp(newValue, this.min, this.max);
+ }
+
+ // Dispatch events
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+
+ private handleLabelPointerDown(event: PointerEvent) {
+ event.preventDefault();
+
+ if (!this.disabled) {
+ if (this.isRange) {
+ this.thumbMin?.focus();
+ } else {
+ this.slider.focus();
+ }
+ }
+ }
+
+ private setValueFromCoordinates(x: number, y: number) {
+ const oldValue = this.value;
+ this.value = this.getValueFromCoordinates(x, y);
+
+ // Dispatch input events when the value changes by dragging
+ if (this.value !== oldValue) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ });
+ }
+ }
+
+ private setThumbValueFromCoordinates(
+ x: number,
+ y: number,
+ thumb: "min" | "max",
+ ) {
+ const value = this.getValueFromCoordinates(x, y);
+ const oldValue = thumb === "min" ? this.minValue : this.maxValue;
+
+ if (thumb === "min") {
+ // If min thumb is being dragged and would exceed max thumb
+ if (value > this.maxValue) {
+ // Move both thumbs, keeping their distance at 0
+ this.maxValue = value;
+ this.minValue = value;
+ } else {
+ // Normal case - just move min thumb
+ this.minValue = Math.max(this.min, value);
+ }
+ } else {
+ // thumb === 'max'
+ // If max thumb is being dragged and would go below min thumb
+ if (value < this.minValue) {
+ // Move both thumbs, keeping their distance at 0
+ this.minValue = value;
+ this.maxValue = value;
+ } else {
+ // Normal case - just move max thumb
+ this.maxValue = Math.min(this.max, value);
+ }
+ }
+
+ // Dispatch input events
+ if (oldValue !== (thumb === "min" ? this.minValue : this.maxValue)) {
+ this.updateFormValue();
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ });
+ }
+ }
+
+ private showTooltip() {
+ if (this.withTooltip && this.tooltip) {
+ this.tooltip.open = true;
+ }
+ }
+
+ private hideTooltip() {
+ if (this.withTooltip && this.tooltip) {
+ this.tooltip.open = false;
+ }
+ }
+
+ private showRangeTooltips() {
+ if (!this.withTooltip) return;
+
+ // Show only the active tooltip, hide the other
+ const tooltipMin = this.shadowRoot?.getElementById(
+ "tooltip-thumb-min",
+ ) as WaTooltip;
+ const tooltipMax = this.shadowRoot?.getElementById(
+ "tooltip-thumb-max",
+ ) as WaTooltip;
+
+ if (this.activeThumb === "min") {
+ if (tooltipMin) tooltipMin.open = true;
+ if (tooltipMax) tooltipMax.open = false;
+ } else if (this.activeThumb === "max") {
+ if (tooltipMax) tooltipMax.open = true;
+ if (tooltipMin) tooltipMin.open = false;
+ }
+ }
+
+ private hideRangeTooltips() {
+ if (!this.withTooltip) return;
+
+ const tooltipMin = this.shadowRoot?.getElementById(
+ "tooltip-thumb-min",
+ ) as WaTooltip;
+ const tooltipMax = this.shadowRoot?.getElementById(
+ "tooltip-thumb-max",
+ ) as WaTooltip;
+
+ if (tooltipMin) tooltipMin.open = false;
+ if (tooltipMax) tooltipMax.open = false;
+ }
+
+ /** Updates the form value submission for range sliders */
+ private updateFormValue() {
+ if (this.isRange) {
+ // Submit both values using FormData for range sliders
+ const formData = new FormData();
+ formData.append(this.name || "", String(this.minValue));
+ formData.append(this.name || "", String(this.maxValue));
+ this.setValue(formData);
+ }
+ }
+
+ /** Sets focus to the slider. */
+ public focus() {
+ if (this.isRange) {
+ this.thumbMin?.focus();
+ } else {
+ this.slider.focus();
+ }
+ }
+
+ /** Removes focus from the slider. */
+ public blur() {
+ if (this.isRange) {
+ if (document.activeElement === this.thumbMin) {
+ this.thumbMin.blur();
+ } else if (document.activeElement === this.thumbMax) {
+ this.thumbMax.blur();
+ }
+ } else {
+ this.slider.blur();
+ }
+ }
+
+ /**
+ * Decreases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be
+ * emitted when this is called.
+ */
+ public stepDown() {
+ if (this.isRange) {
+ // If in range mode, default to stepping down the min value
+ const newValue = this.clampAndRoundToStep(this.minValue - this.step);
+ this.minValue = clamp(newValue, this.min, this.maxValue);
+ this.updateFormValue();
+ } else {
+ const newValue = this.clampAndRoundToStep(this.value - this.step);
+ this.value = newValue;
+ }
+ }
+
+ /**
+ * Increases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be
+ * emitted when this is called.
+ */
+ public stepUp() {
+ if (this.isRange) {
+ // If in range mode, default to stepping up the max value
+ const newValue = this.clampAndRoundToStep(this.maxValue + this.step);
+ this.maxValue = clamp(newValue, this.minValue, this.max);
+ this.updateFormValue();
+ } else {
+ const newValue = this.clampAndRoundToStep(this.value + this.step);
+ this.value = newValue;
+ }
+ }
+
+ render() {
+ const hasLabelSlot = this.hasSlotController.test("label");
+ const hasHintSlot = this.hasSlotController.test("hint");
+ const hasLabel = this.label ? true : !!hasLabelSlot;
+ const hasHint = this.hint ? true : !!hasHintSlot;
+ const hasReference = this.hasSlotController.test("reference");
+
+ const sliderClasses = classMap({
+ small: this.size === "small",
+ medium: this.size === "medium",
+ large: this.size === "large",
+ horizontal: this.orientation === "horizontal",
+ vertical: this.orientation === "vertical",
+ disabled: this.disabled,
+ });
+
+ // Calculate marker positions
+ const markers = [];
+ if (this.withMarkers) {
+ for (let i = this.min; i <= this.max; i += this.step) {
+ markers.push(this.getPercentageFromValue(i));
+ }
+ }
+
+ // Common UI fragments
+ const label = html`
+
+ ${this.label}
+
+ `;
+
+ const hint = html`
+
+ ${this.hint}
+
+ `;
+
+ const markersTemplate = this.withMarkers
+ ? html`
+
+ ${markers.map(
+ (marker) =>
+ html` `,
+ )}
+
+ `
+ : "";
+
+ const referencesTemplate = hasReference
+ ? html`
+
+
+
+ `
+ : "";
+
+ // Create tooltip template function
+ const createTooltip = (thumbId: string, value: number) =>
+ this.withTooltip
+ ? html`
+
+
+ ${typeof this.valueFormatter === "function"
+ ? this.valueFormatter(value)
+ : this.localize.number(value)}
+
+
+ `
+ : "";
+
+ // Render based on mode
+ if (this.isRange) {
+ // Range slider mode
+ const minThumbPosition = clamp(
+ this.getPercentageFromValue(this.minValue),
+ 0,
+ 100,
+ );
+ const maxThumbPosition = clamp(
+ this.getPercentageFromValue(this.maxValue),
+ 0,
+ 100,
+ );
+
+ return html`
+ ${label}
+
+
+
+
+
+ ${markersTemplate}
+
+
+
+
+
+
+ ${referencesTemplate} ${hint}
+
+
+ ${createTooltip("thumb-min", this.minValue)}
+ ${createTooltip("thumb-max", this.maxValue)}
+ `;
+ } else {
+ // Single thumb mode
+ const thumbPosition = clamp(
+ this.getPercentageFromValue(this.value),
+ 0,
+ 100,
+ );
+ const indicatorOffsetPosition = clamp(
+ this.getPercentageFromValue(
+ typeof this.indicatorOffset === "number"
+ ? this.indicatorOffset
+ : this.min,
+ ),
+ 0,
+ 100,
+ );
+
+ return html`
+ ${label}
+
+
+
+
+
+ ${markersTemplate}
+
+
+
+ ${referencesTemplate} ${hint}
+
+
+ ${createTooltip("thumb", this.value)}
+ `;
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "wa-slider": WaSlider;
+ }
+}
+
+`````
+
+### Expected (prettier)
+
+`````ts
+import type { PropertyValues } from "lit";
+import { html } from "lit";
+import { customElement, property, query, state } from "lit/decorators.js";
+import { classMap } from "lit/directives/class-map.js";
+import { styleMap } from "lit/directives/style-map.js";
+import { DraggableElement } from "../../internal/drag.js";
+import { clamp } from "../../internal/math.js";
+import { HasSlotController } from "../../internal/slot.js";
+import { submitOnEnter } from "../../internal/submit-on-enter.js";
+import { SliderValidator } from "../../internal/validators/slider-validator.js";
+import { WebAwesomeFormAssociatedElement } from "../../internal/webawesome-form-associated-element.js";
+import formControlStyles from "../../styles/component/form-control.styles.js";
+import sizeStyles from "../../styles/component/size.styles.js";
+import { LocalizeController } from "../../utilities/localize.js";
+import "../tooltip/tooltip.js";
+import type WaTooltip from "../tooltip/tooltip.js";
+import styles from "./slider.styles.js";
+
+/**
+ *
+ *
+ * @summary Ranges allow the user to select a single value within a given range using a slider.
+ * @documentation https://webawesome.com/docs/components/range
+ * @status stable
+ * @since 2.0
+ *
+ * @dependency wa-tooltip
+ *
+ * @slot label - The slider label. Alternatively, you can use the `label` attribute.
+ * @slot hint - Text that describes how to use the input. Alternatively, you can use the `hint` attribute.
+ * instead.
+ * @slot reference - One or more reference labels to show visually below the slider.
+ *
+ * @event blur - Emitted when the control loses focus.
+ * @event change - Emitted when an alteration to the control's value is committed by the user.
+ * @event focus - Emitted when the control gains focus.
+ * @event input - Emitted when the control receives input.
+ * @event wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
+ *
+ * @csspart label - The element that contains the sliders's label.
+ * @csspart hint - The element that contains the slider's description.
+ * @csspart slider - The focusable element with `role="slider"`. Contains the track and reference slot.
+ * @csspart track - The slider's track.
+ * @csspart indicator - The colored indicator that shows from the start of the slider to the current value.
+ * @csspart markers - The container that holds all the markers when `with-markers` is used.
+ * @csspart marker - The individual markers that are shown when `with-markers` is used.
+ * @csspart references - The container that holds references that get slotted in.
+ * @csspart thumb - The slider's thumb.
+ * @csspart thumb-min - The min value thumb in a range slider.
+ * @csspart thumb-max - The max value thumb in a range slider.
+ * @csspart tooltip - The tooltip, a `` element.
+ * @csspart tooltip__tooltip - The tooltip's `tooltip` part.
+ * @csspart tooltip__content - The tooltip's `content` part.
+ * @csspart tooltip__arrow - The tooltip's `arrow` part.
+ *
+ * @cssstate disabled - Applied when the slider is disabled.
+ * @cssstate dragging - Applied when the slider is being dragged.
+ * @cssstate focused - Applied when the slider has focus.
+ * @cssstate user-valid - Applied when the slider is valid and the user has sufficiently interacted with it.
+ * @cssstate user-invalid - Applied when the slider is invalid and the user has sufficiently interacted with it.
+ *
+ * @cssproperty [--track-size=0.75em] - The height or width of the slider's track.
+ * @cssproperty [--marker-width=0.1875em] - The width of each individual marker.
+ * @cssproperty [--marker-height=0.1875em] - The height of each individual marker.
+ * @cssproperty [--thumb-width=1.25em] - The width of the thumb.
+ * @cssproperty [--thumb-height=1.25em] - The height of the thumb.
+ */
+@customElement("wa-slider")
+export default class WaSlider extends WebAwesomeFormAssociatedElement {
+ static formAssociated = true;
+ static observeSlots = true;
+ static css = [sizeStyles, formControlStyles, styles];
+
+ static get validators() {
+ return [...super.validators, SliderValidator()];
+ }
+
+ private draggableTrack: DraggableElement;
+ private draggableThumbMin: DraggableElement | null = null;
+ private draggableThumbMax: DraggableElement | null = null;
+ private readonly hasSlotController = new HasSlotController(
+ this,
+ "hint",
+ "label",
+ );
+ private readonly localize = new LocalizeController(this);
+ private trackBoundingClientRect: DOMRect;
+ private valueWhenDraggingStarted: number | undefined | null;
+ private activeThumb: "min" | "max" | null = null;
+ private lastTrackPosition: number | null = null; // Track last position for direction detection
+
+ protected get focusableAnchor() {
+ return this.isRange ? this.thumbMin || this.slider : this.slider;
+ }
+
+ /** Override validation target to point to the focusable element */
+ get validationTarget() {
+ return this.focusableAnchor;
+ }
+
+ @query("#slider") slider: HTMLElement;
+ @query("#thumb") thumb: HTMLElement;
+ @query("#thumb-min") thumbMin: HTMLElement;
+ @query("#thumb-max") thumbMax: HTMLElement;
+ @query("#track") track: HTMLElement;
+ @query("#tooltip") tooltip: WaTooltip;
+
+ /**
+ * The slider's label. If you need to provide HTML in the label, use the `label` slot instead.
+ */
+ @property() label: string = "";
+
+ /** The slider hint. If you need to display HTML, use the hint slot instead. */
+ @property({ attribute: "hint" }) hint = "";
+
+ /** The name of the slider. This will be submitted with the form as a name/value pair. */
+ @property({ reflect: true }) name: string;
+
+ /** The minimum value of a range selection. Used only when range attribute is set. */
+ @property({ type: Number, attribute: "min-value" }) minValue = 0;
+
+ /** The maximum value of a range selection. Used only when range attribute is set. */
+ @property({ type: Number, attribute: "max-value" }) maxValue = 50;
+
+ /** The default value of the form control. Primarily used for resetting the form control. */
+ @property({ attribute: "value", reflect: true, type: Number })
+ defaultValue: number =
+ this.getAttribute("value") == null
+ ? this.minValue
+ : Number(this.getAttribute("value"));
+
+ private _value: number | null = null;
+
+ /** The current value of the slider, submitted as a name/value pair with form data. */
+ get value(): number {
+ if (this.valueHasChanged) {
+ const val = this._value ?? this.minValue ?? 0;
+ return clamp(val, this.min, this.max);
+ }
+
+ const val = this._value ?? this.defaultValue;
+ return clamp(val, this.min, this.max);
+ }
+
+ @state()
+ set value(val: number | null) {
+ val = Number(val) ?? this.minValue;
+
+ if (this._value === val) {
+ return;
+ }
+
+ this.valueHasChanged = true;
+ this._value = val;
+ }
+
+ /** Converts the slider to a range slider with two thumbs. */
+ @property({ type: Boolean, reflect: true }) range = false;
+
+ /** Get if this is a range slider */
+ get isRange(): boolean {
+ return this.range;
+ }
+
+ /** Disables the slider. */
+ @property({ type: Boolean }) disabled = false;
+
+ /** Makes the slider a read-only field. */
+ @property({ type: Boolean, reflect: true }) readonly = false;
+
+ /** The orientation of the slider. */
+ @property({ reflect: true }) orientation: "horizontal" | "vertical" =
+ "horizontal";
+
+ /** The slider's size. */
+ @property({ reflect: true }) size: "small" | "medium" | "large" = "medium";
+
+ /** The starting value from which to draw the slider's fill, which is based on its current value. */
+ @property({ attribute: "indicator-offset", type: Number })
+ indicatorOffset: number;
+
+ /** The minimum value allowed. */
+ @property({ type: Number }) min: number = 0;
+
+ /** The maximum value allowed. */
+ @property({ type: Number }) max: number = 100;
+
+ /** The granularity the value must adhere to when incrementing and decrementing. */
+ @property({ type: Number }) step: number = 1;
+
+ /** Makes the slider a required field. */
+ @property({ type: Boolean, reflect: true }) required = false;
+
+ /** Tells the browser to focus the slider when the page loads or a dialog is shown. */
+ @property({ type: Boolean }) autofocus: boolean;
+
+ /** The distance of the tooltip from the slider's thumb. */
+ @property({ attribute: "tooltip-distance", type: Number }) tooltipDistance =
+ 8;
+
+ /** The placement of the tooltip in reference to the slider's thumb. */
+ @property({ attribute: "tooltip-placement", reflect: true })
+ tooltipPlacement: "top" | "right" | "bottom" | "left" = "top";
+
+ /** Draws markers at each step along the slider. */
+ @property({ attribute: "with-markers", type: Boolean }) withMarkers = false;
+
+ /** Draws a tooltip above the thumb when the control has focus or is dragged. */
+ @property({ attribute: "with-tooltip", type: Boolean }) withTooltip = false;
+
+ /**
+ * A custom formatting function to apply to the value. This will be shown in the tooltip and announced by screen
+ * readers. Must be set with JavaScript. Property only.
+ */
+ @property({ attribute: false }) valueFormatter: (value: number) => string;
+
+ firstUpdated() {
+ // Setup dragging based on range or single thumb mode
+ if (this.isRange) {
+ // Enable dragging on both thumbs for range slider
+ this.draggableThumbMin = new DraggableElement(this.thumbMin, {
+ start: () => {
+ this.activeThumb = "min";
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.minValue;
+ this.customStates.set("dragging", true);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ this.setThumbValueFromCoordinates(x, y, "min");
+ },
+ stop: () => {
+ if (this.minValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+
+ this.draggableThumbMax = new DraggableElement(this.thumbMax, {
+ start: () => {
+ this.activeThumb = "max";
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.maxValue;
+ this.customStates.set("dragging", true);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ this.setThumbValueFromCoordinates(x, y, "max");
+ },
+ stop: () => {
+ if (this.maxValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+
+ // Enable track dragging for finding the closest thumb
+ this.draggableTrack = new DraggableElement(this.track, {
+ start: (x, y) => {
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+
+ // When a drag starts, we need to determine which thumb to move
+ // If the thumbs are in nearly the same position, we prioritize the one that's already active
+ // or the one that received focus most recently
+ if (this.activeThumb) {
+ // Keep using the already active thumb (useful for keyboard interactions)
+ this.valueWhenDraggingStarted =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ } else {
+ // Otherwise select by closest distance
+ const value = this.getValueFromCoordinates(x, y);
+ const minDistance = Math.abs(value - this.minValue);
+ const maxDistance = Math.abs(value - this.maxValue);
+
+ if (minDistance === maxDistance) {
+ // If distances are equal, prioritize the max thumb when value is higher than both thumbs
+ // and min thumb when value is lower than both thumbs
+ if (value > this.maxValue) {
+ this.activeThumb = "max";
+ } else if (value < this.minValue) {
+ this.activeThumb = "min";
+ } else {
+ // If the value is between the thumbs and they're at the same distance,
+ // prioritize the thumb that's in the direction of movement
+ const isRtl = this.localize.dir() === "rtl";
+ const isVertical = this.orientation === "vertical";
+ const position = isVertical ? y : x;
+ const previousPosition = this.lastTrackPosition || position;
+ this.lastTrackPosition = position;
+
+ // Determine direction of movement
+ const movingForward =
+ (position > previousPosition !== isRtl && !isVertical) ||
+ (position < previousPosition && isVertical);
+
+ this.activeThumb = movingForward ? "max" : "min";
+ }
+ } else {
+ // Select the closest thumb
+ this.activeThumb = minDistance <= maxDistance ? "min" : "max";
+ }
+
+ this.valueWhenDraggingStarted =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ }
+
+ this.customStates.set("dragging", true);
+ this.setThumbValueFromCoordinates(x, y, this.activeThumb);
+ this.showRangeTooltips();
+ },
+ move: (x, y) => {
+ if (this.activeThumb) {
+ this.setThumbValueFromCoordinates(x, y, this.activeThumb);
+ }
+ },
+ stop: () => {
+ if (this.activeThumb) {
+ const currentValue =
+ this.activeThumb === "min" ? this.minValue : this.maxValue;
+ if (currentValue !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+ }
+ this.hideRangeTooltips();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ this.activeThumb = null;
+ },
+ });
+ } else {
+ // Single thumb mode - original behavior
+ this.draggableTrack = new DraggableElement(this.slider, {
+ start: (x, y) => {
+ this.trackBoundingClientRect = this.track.getBoundingClientRect();
+ this.valueWhenDraggingStarted = this.value;
+ this.customStates.set("dragging", true);
+ this.setValueFromCoordinates(x, y);
+ this.showTooltip();
+ },
+ move: (x, y) => {
+ this.setValueFromCoordinates(x, y);
+ },
+ stop: () => {
+ if (this.value !== this.valueWhenDraggingStarted) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+
+ this.hasInteracted = true;
+ }
+ this.hideTooltip();
+ this.customStates.set("dragging", false);
+ this.valueWhenDraggingStarted = undefined;
+ },
+ });
+ }
+ }
+
+ updated(changedProperties: PropertyValues) {
+ // Handle range mode changes
+ if (changedProperties.has("range")) {
+ this.requestUpdate();
+ }
+
+ if (this.isRange) {
+ // Handle min/max values for range mode
+ if (
+ changedProperties.has("minValue") ||
+ changedProperties.has("maxValue")
+ ) {
+ // Ensure min doesn't exceed max
+ this.minValue = clamp(this.minValue, this.min, this.maxValue);
+ this.maxValue = clamp(this.maxValue, this.minValue, this.max);
+ // Update form value
+ this.updateFormValue();
+ }
+ } else {
+ // Handle value for single thumb mode
+ if (changedProperties.has("value")) {
+ this.setValue(String(this.value));
+ }
+ }
+
+ // Handle min/max
+ if (changedProperties.has("min") || changedProperties.has("max")) {
+ if (this.isRange) {
+ this.minValue = clamp(this.minValue, this.min, this.max);
+ this.maxValue = clamp(this.maxValue, this.min, this.max);
+ }
+ }
+
+ // Handle disabled
+ if (changedProperties.has("disabled")) {
+ this.customStates.set("disabled", this.disabled);
+ }
+
+ // Disable dragging when disabled or readonly
+ if (
+ changedProperties.has("disabled") ||
+ changedProperties.has("readonly")
+ ) {
+ const enabled = !(this.disabled || this.readonly);
+
+ if (this.isRange) {
+ if (this.draggableThumbMin) this.draggableThumbMin.toggle(enabled);
+ if (this.draggableThumbMax) this.draggableThumbMax.toggle(enabled);
+ }
+
+ if (this.draggableTrack) {
+ this.draggableTrack.toggle(enabled);
+ }
+ }
+
+ super.updated(changedProperties);
+ }
+
+ /** @internal Called when a containing fieldset is disabled. */
+ formDisabledCallback(isDisabled: boolean) {
+ this.disabled = isDisabled;
+ }
+
+ /** @internal Called when the form is reset. */
+ formResetCallback() {
+ if (this.isRange) {
+ this.minValue = parseFloat(
+ this.getAttribute("min-value") ?? String(this.min),
+ );
+ this.maxValue = parseFloat(
+ this.getAttribute("max-value") ?? String(this.max),
+ );
+ } else {
+ this._value = null;
+ this.defaultValue =
+ this.defaultValue ??
+ parseFloat(this.getAttribute("value") ?? String(this.min));
+ }
+ this.valueHasChanged = false;
+ this.hasInteracted = false;
+ super.formResetCallback();
+ }
+
+ /** Clamps a number to min/max while ensuring it's a valid step interval. */
+ private clampAndRoundToStep(value: number) {
+ const stepPrecision = (String(this.step).split(".")[1] || "").replace(
+ /0+$/g,
+ "",
+ ).length;
+
+ // Ensure we're working with numbers (in case the user passes strings to the respective properties)
+ const step = Number(this.step);
+ const min = Number(this.min);
+ const max = Number(this.max);
+
+ value = Math.round(value / step) * step;
+ value = clamp(value, min, max);
+
+ return parseFloat(value.toFixed(stepPrecision));
+ }
+
+ /** Given a value, returns its percentage within a range of min/max. */
+ private getPercentageFromValue(value: number) {
+ return ((value - this.min) / (this.max - this.min)) * 100;
+ }
+
+ /** Converts coordinates to slider value */
+ private getValueFromCoordinates(x: number, y: number) {
+ const isRtl = this.localize.dir() === "rtl";
+ const isVertical = this.orientation === "vertical";
+ const { top, right, bottom, left, height, width } =
+ this.trackBoundingClientRect;
+ const pointerPosition = isVertical ? y : x;
+ const sliderCoords = isVertical
+ ? { start: top, end: bottom, size: height }
+ : { start: left, end: right, size: width };
+ const relativePosition = isVertical
+ ? sliderCoords.end - pointerPosition
+ : isRtl
+ ? sliderCoords.end - pointerPosition
+ : pointerPosition - sliderCoords.start;
+ const percentage = relativePosition / sliderCoords.size;
+ return this.clampAndRoundToStep(
+ this.min + (this.max - this.min) * percentage,
+ );
+ }
+
+ private handleBlur() {
+ // Only hide tooltips if neither thumb has focus
+ if (this.isRange) {
+ // Allow a subsequent focus event to fire on the other thumb if the user is tabbing
+ requestAnimationFrame(() => {
+ const focusedElement = this.shadowRoot?.activeElement;
+ const thumbHasFocus =
+ focusedElement === this.thumbMin || focusedElement === this.thumbMax;
+ if (!thumbHasFocus) {
+ this.hideRangeTooltips();
+ }
+ });
+ } else {
+ this.hideTooltip();
+ }
+ this.customStates.set("focused", false);
+ this.dispatchEvent(
+ new FocusEvent("blur", { bubbles: true, composed: true }),
+ );
+ }
+
+ private handleFocus(event: FocusEvent) {
+ const target = event.target as HTMLElement;
+
+ // Handle focus for specific thumbs in range mode
+ if (this.isRange) {
+ if (target === this.thumbMin) {
+ this.activeThumb = "min";
+ } else if (target === this.thumbMax) {
+ this.activeThumb = "max";
+ }
+ this.showRangeTooltips();
+ } else {
+ this.showTooltip();
+ }
+
+ this.customStates.set("focused", true);
+ this.dispatchEvent(
+ new FocusEvent("focus", { bubbles: true, composed: true }),
+ );
+ }
+
+ private handleKeyDown(event: KeyboardEvent) {
+ const isRtl = this.localize.dir() === "rtl";
+ const target = event.target as HTMLElement;
+
+ if (this.disabled || this.readonly) return;
+
+ // For range slider, determine which thumb is active
+ if (this.isRange) {
+ if (target === this.thumbMin) {
+ this.activeThumb = "min";
+ } else if (target === this.thumbMax) {
+ this.activeThumb = "max";
+ }
+
+ if (!this.activeThumb) return;
+ }
+
+ // Get current value based on slider mode
+ const current = this.isRange
+ ? this.activeThumb === "min"
+ ? this.minValue
+ : this.maxValue
+ : this.value;
+
+ let newValue = current;
+
+ // Handle key presses
+ switch (event.key) {
+ // Increase
+ case "ArrowUp":
+ case isRtl ? "ArrowLeft" : "ArrowRight":
+ event.preventDefault();
+ newValue = this.clampAndRoundToStep(current + this.step);
+ break;
+
+ // Decrease
+ case "ArrowDown":
+ case isRtl ? "ArrowRight" : "ArrowLeft":
+ event.preventDefault();
+ newValue = this.clampAndRoundToStep(current - this.step);
+ break;
+
+ // Minimum value
+ case "Home":
+ event.preventDefault();
+ newValue =
+ this.isRange && this.activeThumb === "min"
+ ? this.min
+ : this.isRange
+ ? this.minValue
+ : this.min;
+ break;
+
+ // Maximum value
+ case "End":
+ event.preventDefault();
+ newValue =
+ this.isRange && this.activeThumb === "max"
+ ? this.max
+ : this.isRange
+ ? this.maxValue
+ : this.max;
+ break;
+
+ // Move up 10%
+ case "PageUp":
+ event.preventDefault();
+ const stepUp = Math.max(
+ current + (this.max - this.min) / 10,
+ current + this.step, // make sure we at least move up to the next step
+ );
+ newValue = this.clampAndRoundToStep(stepUp);
+ break;
+
+ // Move down 10%
+ case "PageDown":
+ event.preventDefault();
+ const stepDown = Math.min(
+ current - (this.max - this.min) / 10,
+ current - this.step, // make sure we at least move down to the previous step
+ );
+ newValue = this.clampAndRoundToStep(stepDown);
+ break;
+
+ // Handle form submission on Enter
+ case "Enter":
+ submitOnEnter(event, this);
+ return;
+ }
+
+ // If no value change, exit early
+ if (newValue === current) return;
+
+ // Apply the new value with appropriate constraints
+ if (this.isRange) {
+ if (this.activeThumb === "min") {
+ if (newValue > this.maxValue) {
+ // If min thumb exceeds max thumb, move both
+ this.maxValue = newValue;
+ this.minValue = newValue;
+ } else {
+ this.minValue = Math.max(this.min, newValue);
+ }
+ } else {
+ if (newValue < this.minValue) {
+ // If max thumb goes below min thumb, move both
+ this.minValue = newValue;
+ this.maxValue = newValue;
+ } else {
+ this.maxValue = Math.min(this.max, newValue);
+ }
+ }
+ this.updateFormValue();
+ } else {
+ this.value = clamp(newValue, this.min, this.max);
+ }
+
+ // Dispatch events
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ this.dispatchEvent(
+ new Event("change", { bubbles: true, composed: true }),
+ );
+ });
+ this.hasInteracted = true;
+ }
+
+ private handleLabelPointerDown(event: PointerEvent) {
+ event.preventDefault();
+
+ if (!this.disabled) {
+ if (this.isRange) {
+ this.thumbMin?.focus();
+ } else {
+ this.slider.focus();
+ }
+ }
+ }
+
+ private setValueFromCoordinates(x: number, y: number) {
+ const oldValue = this.value;
+ this.value = this.getValueFromCoordinates(x, y);
+
+ // Dispatch input events when the value changes by dragging
+ if (this.value !== oldValue) {
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ });
+ }
+ }
+
+ private setThumbValueFromCoordinates(
+ x: number,
+ y: number,
+ thumb: "min" | "max",
+ ) {
+ const value = this.getValueFromCoordinates(x, y);
+ const oldValue = thumb === "min" ? this.minValue : this.maxValue;
+
+ if (thumb === "min") {
+ // If min thumb is being dragged and would exceed max thumb
+ if (value > this.maxValue) {
+ // Move both thumbs, keeping their distance at 0
+ this.maxValue = value;
+ this.minValue = value;
+ } else {
+ // Normal case - just move min thumb
+ this.minValue = Math.max(this.min, value);
+ }
+ } else {
+ // thumb === 'max'
+ // If max thumb is being dragged and would go below min thumb
+ if (value < this.minValue) {
+ // Move both thumbs, keeping their distance at 0
+ this.minValue = value;
+ this.maxValue = value;
+ } else {
+ // Normal case - just move max thumb
+ this.maxValue = Math.min(this.max, value);
+ }
+ }
+
+ // Dispatch input events
+ if (oldValue !== (thumb === "min" ? this.minValue : this.maxValue)) {
+ this.updateFormValue();
+ this.updateComplete.then(() => {
+ this.dispatchEvent(
+ new InputEvent("input", { bubbles: true, composed: true }),
+ );
+ });
+ }
+ }
+
+ private showTooltip() {
+ if (this.withTooltip && this.tooltip) {
+ this.tooltip.open = true;
+ }
+ }
+
+ private hideTooltip() {
+ if (this.withTooltip && this.tooltip) {
+ this.tooltip.open = false;
+ }
+ }
+
+ private showRangeTooltips() {
+ if (!this.withTooltip) return;
+
+ // Show only the active tooltip, hide the other
+ const tooltipMin = this.shadowRoot?.getElementById(
+ "tooltip-thumb-min",
+ ) as WaTooltip;
+ const tooltipMax = this.shadowRoot?.getElementById(
+ "tooltip-thumb-max",
+ ) as WaTooltip;
+
+ if (this.activeThumb === "min") {
+ if (tooltipMin) tooltipMin.open = true;
+ if (tooltipMax) tooltipMax.open = false;
+ } else if (this.activeThumb === "max") {
+ if (tooltipMax) tooltipMax.open = true;
+ if (tooltipMin) tooltipMin.open = false;
+ }
+ }
+
+ private hideRangeTooltips() {
+ if (!this.withTooltip) return;
+
+ const tooltipMin = this.shadowRoot?.getElementById(
+ "tooltip-thumb-min",
+ ) as WaTooltip;
+ const tooltipMax = this.shadowRoot?.getElementById(
+ "tooltip-thumb-max",
+ ) as WaTooltip;
+
+ if (tooltipMin) tooltipMin.open = false;
+ if (tooltipMax) tooltipMax.open = false;
+ }
+
+ /** Updates the form value submission for range sliders */
+ private updateFormValue() {
+ if (this.isRange) {
+ // Submit both values using FormData for range sliders
+ const formData = new FormData();
+ formData.append(this.name || "", String(this.minValue));
+ formData.append(this.name || "", String(this.maxValue));
+ this.setValue(formData);
+ }
+ }
+
+ /** Sets focus to the slider. */
+ public focus() {
+ if (this.isRange) {
+ this.thumbMin?.focus();
+ } else {
+ this.slider.focus();
+ }
+ }
+
+ /** Removes focus from the slider. */
+ public blur() {
+ if (this.isRange) {
+ if (document.activeElement === this.thumbMin) {
+ this.thumbMin.blur();
+ } else if (document.activeElement === this.thumbMax) {
+ this.thumbMax.blur();
+ }
+ } else {
+ this.slider.blur();
+ }
+ }
+
+ /**
+ * Decreases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be
+ * emitted when this is called.
+ */
+ public stepDown() {
+ if (this.isRange) {
+ // If in range mode, default to stepping down the min value
+ const newValue = this.clampAndRoundToStep(this.minValue - this.step);
+ this.minValue = clamp(newValue, this.min, this.maxValue);
+ this.updateFormValue();
+ } else {
+ const newValue = this.clampAndRoundToStep(this.value - this.step);
+ this.value = newValue;
+ }
+ }
+
+ /**
+ * Increases the slider's value by `step`. This is a programmatic change, so `input` and `change` events will not be
+ * emitted when this is called.
+ */
+ public stepUp() {
+ if (this.isRange) {
+ // If in range mode, default to stepping up the max value
+ const newValue = this.clampAndRoundToStep(this.maxValue + this.step);
+ this.maxValue = clamp(newValue, this.minValue, this.max);
+ this.updateFormValue();
+ } else {
+ const newValue = this.clampAndRoundToStep(this.value + this.step);
+ this.value = newValue;
+ }
+ }
+
+ render() {
+ const hasLabelSlot = this.hasSlotController.test("label");
+ const hasHintSlot = this.hasSlotController.test("hint");
+ const hasLabel = this.label ? true : !!hasLabelSlot;
+ const hasHint = this.hint ? true : !!hasHintSlot;
+ const hasReference = this.hasSlotController.test("reference");
+
+ const sliderClasses = classMap({
+ small: this.size === "small",
+ medium: this.size === "medium",
+ large: this.size === "large",
+ horizontal: this.orientation === "horizontal",
+ vertical: this.orientation === "vertical",
+ disabled: this.disabled,
+ });
+
+ // Calculate marker positions
+ const markers = [];
+ if (this.withMarkers) {
+ for (let i = this.min; i <= this.max; i += this.step) {
+ markers.push(this.getPercentageFromValue(i));
+ }
+ }
+
+ // Common UI fragments
+ const label = html`
+
+ ${this.label}
+
+ `;
+
+ const hint = html`
+
+ ${this.hint}
+
+ `;
+
+ const markersTemplate = this.withMarkers
+ ? html`
+
+ ${markers.map(
+ (marker) =>
+ html` `,
+ )}
+
+ `
+ : "";
+
+ const referencesTemplate = hasReference
+ ? html`
+
+
+
+ `
+ : "";
+
+ // Create tooltip template function
+ const createTooltip = (thumbId: string, value: number) =>
+ this.withTooltip
+ ? html`
+
+
+ ${typeof this.valueFormatter === "function"
+ ? this.valueFormatter(value)
+ : this.localize.number(value)}
+
+
+ `
+ : "";
+
+ // Render based on mode
+ if (this.isRange) {
+ // Range slider mode
+ const minThumbPosition = clamp(
+ this.getPercentageFromValue(this.minValue),
+ 0,
+ 100,
+ );
+ const maxThumbPosition = clamp(
+ this.getPercentageFromValue(this.maxValue),
+ 0,
+ 100,
+ );
+
+ return html`
+ ${label}
+
+
+
+
+
+ ${markersTemplate}
+
+
+
+
+
+
+ ${referencesTemplate} ${hint}
+
+
+ ${createTooltip("thumb-min", this.minValue)}
+ ${createTooltip("thumb-max", this.maxValue)}
+ `;
+ } else {
+ // Single thumb mode
+ const thumbPosition = clamp(
+ this.getPercentageFromValue(this.value),
+ 0,
+ 100,
+ );
+ const indicatorOffsetPosition = clamp(
+ this.getPercentageFromValue(
+ typeof this.indicatorOffset === "number"
+ ? this.indicatorOffset
+ : this.min,
+ ),
+ 0,
+ 100,
+ );
+
+ return html`
+ ${label}
+
+
+
+
+
+ ${markersTemplate}
+
+
+
+ ${referencesTemplate} ${hint}
+
+
+ ${createTooltip("thumb", this.value)}
+ `;
+ }
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "wa-slider": WaSlider;
+ }
+}
+
+`````
diff --git a/apps/oxfmt/conformance/snapshots/diffs/xxx-in-js-comment/comment-inside.js.md b/apps/oxfmt/conformance/snapshots/diffs/xxx-in-js-comment/comment-inside.js.md
deleted file mode 100644
index 96b466aebaeb6..0000000000000
--- a/apps/oxfmt/conformance/snapshots/diffs/xxx-in-js-comment/comment-inside.js.md
+++ /dev/null
@@ -1,189 +0,0 @@
-# comment-inside.js
-
-> html embed expressions not yet implemented
-
-## Option 1
-
-`````json
-{"printWidth":80}
-`````
-
-### Diff
-
-`````diff
-===================================================================
---- prettier
-+++ oxfmt
-@@ -12,12 +12,12 @@
- foo
- /* comment */
- }`;
- html`
-- ${
-- foo
-- /* comment */
-- }
-+${
-+ foo
-+ /* comment */
-+}
- `;
-
- graphql`
- ${
-@@ -61,7 +61,6 @@
-
- ${x(
- foo, // fg
- bar,
-- )}
--
-+ )}
- `;
-
-`````
-
-### Actual (oxfmt)
-
-`````js
-// #9274
-html`
-
- ${
- this.set && this.set.artist
- /* avoid console errors if `this.set` is undefined */
- }
-
-`;
-
-html`${
- foo
- /* comment */
-}`;
-html`
-${
- foo
- /* comment */
-}
-`;
-
-graphql`
- ${
- foo
- /* comment */
- }
-`;
-graphql`
- ${
- foo
- /* comment */
- }
-`;
-
-css`
- ${
- foo
- /* comment */
- }
-`;
-css`
- ${
- foo
- /* comment */
- }
-`;
-
-markdown`${
- foo
- /* comment */
-}`;
-markdown`
-${
- foo
- /* comment */
-}
-`;
-
-// https://github.com/prettier/prettier/pull/9278#issuecomment-700589195
-expr1 = html`
-
- ${x(
- foo, // fg
- bar,
- )}
-`;
-
-`````
-
-### Expected (prettier)
-
-`````js
-// #9274
-html`
-
- ${
- this.set && this.set.artist
- /* avoid console errors if `this.set` is undefined */
- }
-
-`;
-
-html`${
- foo
- /* comment */
-}`;
-html`
- ${
- foo
- /* comment */
- }
-`;
-
-graphql`
- ${
- foo
- /* comment */
- }
-`;
-graphql`
- ${
- foo
- /* comment */
- }
-`;
-
-css`
- ${
- foo
- /* comment */
- }
-`;
-css`
- ${
- foo
- /* comment */
- }
-`;
-
-markdown`${
- foo
- /* comment */
-}`;
-markdown`
-${
- foo
- /* comment */
-}
-`;
-
-// https://github.com/prettier/prettier/pull/9278#issuecomment-700589195
-expr1 = html`
-
- ${x(
- foo, // fg
- bar,
- )}
-
-`;
-
-`````
diff --git a/apps/oxfmt/src-js/libs/apis.ts b/apps/oxfmt/src-js/libs/apis.ts
index 080c05449f5c4..e96627b16b81f 100644
--- a/apps/oxfmt/src-js/libs/apis.ts
+++ b/apps/oxfmt/src-js/libs/apis.ts
@@ -25,6 +25,34 @@ async function loadPrettier(): Promise {
if (prettierCache) return prettierCache;
prettierCache = await import("prettier");
+
+ // NOTE: This is needed for html-in-js formatting to work correctly.
+ //
+ // Prettier internally extends `options` with hidden fields for embedded-formatters during printing.
+ // However, `__debug.printToDoc()` runs `normalizeFormatOptions()` which strips unknown keys.
+ // Only keys registered in `formatOptionsHiddenDefaults` survive (via `passThrough` option).
+ // Since `__debug.printToDoc()` does NOT use `passThrough: true` (unlike internal `textToDoc()`!),
+ // our custom fields would be dropped without this registration.
+ //
+ // The default values MUST be falsy, truthy default would affect all Prettier calls, not just ours.
+ // In call sites, Prettier checks `if (!options.parentParser)`, so as long as the default is falsy,
+ // there should be no side effects on other calls that don't set these fields.
+ // @ts-expect-error: Use internal API
+ const { formatOptionsHiddenDefaults } = prettierCache.__internal;
+ // For html-in-js: Prevent attribute level formatting from running.
+ // (e.g., CSS in `style="..."` attributes, JS in `onclick="..."` event handlers)
+ // This does NOT affect `