Compare commits

...

1 Commits

Author SHA1 Message Date
Petar Petrov 4ed507d415 Round fan speed slider display to whole percentages 2026-07-05 14:49:31 +03:00
4 changed files with 87 additions and 4 deletions
+20 -4
View File
@@ -71,6 +71,17 @@ export class HaControlSlider extends LitElement {
@property({ type: Number })
public step = 1;
/**
* Round the value shown in the tooltip and announced to assistive
* technologies to the nearest integer. The handle still snaps to `step`, so
* the number of steps is unchanged. Useful when `step` is fractional but the
* value is conceptually a whole number — e.g. a fan whose `percentage_step`
* is 100 / speed_count (like ~1.0989 for 91 speeds), which would otherwise
* display fractional percentages such as "28.57%".
*/
@property({ type: Boolean, attribute: "round-value" })
public roundValue = false;
@property({ type: Number })
public min = 0;
@@ -107,6 +118,11 @@ export class HaControlSlider extends LitElement {
return Math.round(value / this.step) * this.step;
}
private _displayedValue(value: number) {
const stepped = this.steppedValue(value);
return this.roundValue ? Math.round(stepped) : stepped;
}
boundedValue(value: number) {
return Math.min(Math.max(value, this.min), this.max);
}
@@ -118,8 +134,8 @@ export class HaControlSlider extends LitElement {
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("value")) {
const valuenow = this.steppedValue(this.value ?? 0);
if (changedProps.has("value") || changedProps.has("roundValue")) {
const valuenow = this._displayedValue(this.value ?? 0);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
}
@@ -312,7 +328,7 @@ export class HaControlSlider extends LitElement {
this.tooltipMode === "always" ||
(this.tooltipVisible && this.tooltipMode === "interaction");
const value = this.steppedValue(this.value ?? 0);
const value = this._displayedValue(this.value ?? 0);
return html`
<span
@@ -330,7 +346,7 @@ export class HaControlSlider extends LitElement {
}
protected render(): TemplateResult {
const valuenow = this.steppedValue(this.value ?? 0);
const valuenow = this._displayedValue(this.value ?? 0);
return html`
<div
class="container${classMap({
@@ -172,6 +172,7 @@ class HuiFanSpeedCardFeature extends LitElement implements LovelaceCardFeature {
min="0"
max="100"
.step=${this._stateObj.attributes.percentage_step ?? 1}
round-value
@value-changed=${this._valueChanged}
.label=${computeAttributeNameDisplay(
this._localize,
@@ -138,6 +138,7 @@ export class HaStateControlFanSpeed extends LitElement {
max="100"
.value=${this.sliderValue}
.step=${this.stateObj.attributes.percentage_step ?? 1}
round-value
@value-changed=${this._valueChanged}
.label=${computeAttributeNameDisplay(
this._i18n.localize,
+65
View File
@@ -48,3 +48,68 @@ describe("ha-control-slider value mapping", () => {
expect(el.valueToPercentage(100)).toBe(0);
});
});
describe("ha-control-slider display rounding", () => {
// A fan with 91 speeds reports percentage_step = 100 / 91 ≈ 1.0989, so a
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow");
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
expect(ariaValueNow(el)).toBe(el.steppedValue(29).toString());
});
it("rounds the displayed value to an integer when round-value is set", async () => {
const el = await mountSlider({
step: FAN_STEP,
value: 29,
roundValue: true,
});
expect(tooltipText(el)).toBe("29");
expect(ariaValueNow(el)).toBe("29");
});
it("still snaps to the real step grid when rounding the display", async () => {
const el = await mountSlider({
step: FAN_STEP,
value: 29,
roundValue: true,
});
// Only the shown value is rounded; the handle keeps the fractional step, so
// the number of speed steps (and keyboard granularity) is preserved.
expect(el.steppedValue(29)).toBeCloseTo(28.5714, 3);
});
it("keeps decimal steps intact unless round-value is set", async () => {
// A temperature-style slider must keep showing halves.
const el = await mountSlider({ step: 0.5, value: 21.5 });
expect(tooltipText(el)).toBe("21.5");
expect(ariaValueNow(el)).toBe("21.5");
});
});