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 */ ` +
+

foo

+

foo

+
+` 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`

bar

foo
`); +foo(html`

bar

foo
`); +const a = b => html`

bar

foo
`; +const c = b => html`

bar

foo
`; + +// Comment: /* HTML */ `...` +foo(/* HTML */ `

bar

foo
`); +foo(/* HTML */ `

bar

foo
`); +const e = b => /* HTML */ `

bar

foo
`; +const g = b => /* HTML */ `

bar

foo
`; 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` + + `; + + 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` + + + + ` + : ""; + + // 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` + + `; + + 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` + + + + ` + : ""; + + // 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 `