Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 91549f554e Add the color temp brightness picker gallery page to the e2e smoke list
The gallery smoke test only covers pages registered here, so the new
picker page was not exercised in CI.
2026-10-12 21:36:32 +02:00
Maarten Lakerveld 247d560aa0 Show dual-axis view for color lights that support color temperature 2026-10-12 21:36:32 +02:00
Maarten Lakerveld e15861a8fb Add dual-axis temperature/brightness view to light more-info dialog 2026-10-12 21:36:32 +02:00
Maarten Lakerveld 67376887b9 Add ha-color-temp-brightness-picker 2D pad component with gallery page 2026-10-12 14:17:17 +02:00
8 changed files with 629 additions and 1 deletions

No files matched your search

+1
View File
@@ -59,6 +59,7 @@ export default [
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
"ha-color-temp-brightness-picker",
],
},
{
@@ -0,0 +1,4 @@
---
title: Color temperature and brightness picker
subtitle: Dual-axis pad controlling color temperature and brightness together
---
@@ -0,0 +1,102 @@
import "../../../../src/components/ha-color-temp-brightness-picker";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-slider";
const MIN_KELVIN = 2000;
const MAX_KELVIN = 6500;
@customElement("demo-components-ha-color-temp-brightness-picker")
export class DemoHaColorTempBrightnessPicker extends LitElement {
@state()
value: [number, number] = [3000, 50];
@state()
liveValue?: [number, number];
private _valueCursor(ev) {
this.liveValue = ev.detail.value;
}
private _valueChanged(ev) {
this.value = ev.detail.value;
}
private _kelvinChanged(ev) {
this.value = [Number(ev.target.value), this.value[1]];
}
private _brightnessChanged(ev) {
this.value = [this.value[0], Number(ev.target.value)];
}
protected render(): TemplateResult {
const [kelvin, brightness] = this.liveValue ?? this.value;
return html`
<ha-card>
<div class="card-content">
<p class="value">${kelvin} K - ${brightness}%</p>
<ha-color-temp-brightness-picker
.value=${this.value}
.minKelvin=${MIN_KELVIN}
.maxKelvin=${MAX_KELVIN}
@value-changed=${this._valueChanged}
@cursor-moved=${this._valueCursor}
></ha-color-temp-brightness-picker>
<p>Color temperature: ${this.value[0]} K</p>
<ha-slider
labeled
step="1"
.min=${MIN_KELVIN}
.max=${MAX_KELVIN}
.value=${this.value[0]}
@change=${this._kelvinChanged}
>
</ha-slider>
<p>Brightness: ${this.value[1]}%</p>
<ha-slider
labeled
step="1"
min="1"
max="100"
.value=${this.value[1]}
@change=${this._brightnessChanged}
>
</ha-slider>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
align-items: center;
flex-direction: column;
}
ha-color-temp-brightness-picker {
width: 320px;
height: 320px;
}
.value {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
}
}
@@ -0,0 +1,315 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { rgb2hex } from "../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
// Dark end of the vertical brightness gradient; not fully black so the
// temperature hue stays visible at the bottom of the pad
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
// The marker color's luminance scales with brightness down to this floor,
// so the marker never renders fully black at minimum brightness
const MARKER_LUMINANCE_FLOOR = 0.2;
@customElement("ha-color-temp-brightness-picker")
export class HaColorTempBrightnessPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
/** [color temperature in kelvin, brightness percentage] */
@property({ attribute: false })
public value?: [number, number];
@property({ attribute: false })
public minKelvin?: number;
@property({ attribute: false })
public maxKelvin?: number;
@query("#pad") private _pad?: HTMLElement;
private _mc?: HammerManager;
@state()
private _pressed?: string;
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
@state()
private _cursorPosition?: [number, number];
@state()
private _localValue?: [number, number];
private get _minKelvin() {
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
}
private get _maxKelvin() {
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._setupListeners();
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._destroyListeners();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
const rangeChanged =
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
if (
rangeChanged ||
(changedProps.has("value") &&
(this._localValue?.[0] !== this.value?.[0] ||
this._localValue?.[1] !== this.value?.[1]))
) {
this._resetPosition();
}
}
private _setupListeners() {
if (this._pad && !this._mc) {
this._mc = new Manager(this._pad);
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: this._localValue });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this._localValue });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
}
}
private _destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
private _resetPosition() {
if (this.value === undefined) {
this._cursorPosition = undefined;
this._localValue = undefined;
return;
}
this._cursorPosition = this._getCoordsFromValue(this.value);
this._localValue = this.value;
}
private get _rtl() {
return mainWindow.document.dir === "rtl";
}
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
const xValue =
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
(this._maxKelvin - this._minKelvin);
const x = this._rtl ? 1 - xValue : xValue;
const y = 1 - clamp(value[1], 1, 100) / 100;
return [x, y];
};
private _getValueFromCoord = (x: number, y: number): [number, number] => {
const xValue = this._rtl ? 1 - x : x;
const kelvin = Math.round(
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
);
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
return [kelvin, brightness];
};
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
const boundingRect = this._pad!.getBoundingClientRect();
const x = clamp(
(e.center.x - boundingRect.left) / boundingRect.width,
0,
1
);
const y = clamp(
(e.center.y - boundingRect.top) / boundingRect.height,
0,
1
);
return [x, y];
};
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
render() {
const gradient = this._generateTemperatureGradient(
this._minKelvin,
this._maxKelvin
);
const [x, y] = this._cursorPosition ?? [0, 0];
const markerScale = this._pressed
? this._pressed === "touch"
? "2.5"
: "1.5"
: "1";
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
let markerColor = "#ffffff";
if (this._localValue) {
const rgb = temperature2rgb(this._localValue[0]);
const factor =
MARKER_LUMINANCE_FLOOR +
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
markerColor = rgb2hex([
Math.round(rgb[0] * factor),
Math.round(rgb[1] * factor),
Math.round(rgb[2] * factor),
]);
}
return html`
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
<div
id="pad"
style=${styleMap({
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
})}
></div>
<div
class="cursor"
style=${styleMap({
left: `${x * 100}%`,
top: `${y * 100}%`,
visibility: this._cursorPosition ? undefined : "hidden",
})}
>
<div
class="marker"
style=${styleMap({
"background-color": markerColor,
transform: `translate(${markerOffset}) scale(${markerScale})`,
})}
></div>
</div>
</div>
`;
}
static styles = css`
:host {
display: block;
outline: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
#pad {
width: 100%;
height: 100%;
clip-path: inset(0 round var(--ha-border-radius-6xl));
background-blend-mode: multiply;
background-repeat: no-repeat;
cursor: pointer;
touch-action: none;
}
:host([disabled]) #pad {
cursor: initial;
filter: grayscale(1) opacity(0.5);
}
.cursor {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.marker {
position: absolute;
top: -16px;
left: -16px;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
border: 2px solid white;
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.3),
0 1px 6px rgba(0, 0, 0, 0.15);
}
.container:not(.pressed) .marker {
transition:
transform var(--ha-animation-duration-instant) ease-in-out,
background-color var(--ha-animation-duration-instant) ease-in-out;
}
.container:not(.pressed) .cursor {
transition:
left var(--ha-animation-duration-fast) ease-in-out,
top var(--ha-animation-duration-fast) ease-in-out;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
}
}
@@ -0,0 +1,138 @@
import type { ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../../../common/decorators/consume";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-color-temp-brightness-picker";
import { apiContext } from "../../../../data/context";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import type { LightEntity } from "../../../../data/light";
import { LightColorMode } from "../../../../data/light";
@customElement("light-color-temp-brightness-picker")
class LightColorTempBrightnessPicker extends LitElement {
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: LightEntity;
@state() private _value?: [number, number];
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
}
return html`
<ha-color-temp-brightness-picker
.value=${this._value}
.minKelvin=${this.stateObj.attributes.min_color_temp_kelvin}
.maxKelvin=${this.stateObj.attributes.max_color_temp_kelvin}
.disabled=${this.stateObj.state === UNAVAILABLE}
@cursor-moved=${this._cursorMoved}
@value-changed=${this._valueChanged}
>
</ha-color-temp-brightness-picker>
`;
}
private _updateValues() {
const stateObj = this.stateObj;
if (stateObj.state !== "on") {
this._value = undefined;
return;
}
const kelvin =
stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP
? stateObj.attributes.color_temp_kelvin
: undefined;
const brightness = stateObj.attributes.brightness;
this._value =
kelvin != null && brightness != null
? [kelvin, Math.max(Math.round((brightness * 100) / 255), 1)]
: undefined;
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
this._updateValues();
}
private _cursorMoved(ev: CustomEvent) {
const value = ev.detail.value;
this._isInteracting = value !== undefined;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight();
}
private _throttleUpdateLight = throttle(() => {
this._updateLight();
}, 500);
private _valueChanged(ev: CustomEvent) {
const value = ev.detail.value;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight.cancel();
this._updateLight();
}
private _updateLight() {
const [color_temp_kelvin, brightness_pct] = this._value!;
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
color_temp_kelvin,
brightness_pct,
});
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: flex;
flex-direction: column;
align-items: center;
}
ha-color-temp-brightness-picker {
height: 45vh;
max-height: 320px;
min-height: 200px;
aspect-ratio: 1;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"light-color-temp-brightness-picker": LightColorTempBrightnessPicker;
}
}
@@ -42,11 +42,13 @@ import "../components/ha-more-info-control-select-container";
import "../components/ha-more-info-state-header";
import "../components/lights/ha-more-info-light-favorite-colors";
import "../components/lights/light-color-rgb-picker";
import "../components/lights/light-color-temp-brightness-picker";
import "../components/lights/light-color-temp-picker";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
type MainControl = "brightness" | "color_temp" | "color";
type MainControl =
"brightness" | "color_temp" | "color" | "color_temp_brightness";
@customElement("more-info-light")
class MoreInfoLight extends LitElement {
@@ -79,12 +81,31 @@ class MoreInfoLight extends LitElement {
.attributeValue=${value}
></ha-attribute-icon>`;
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (
changedProps.has("stateObj") &&
this.stateObj &&
this._mainControl === "color_temp_brightness" &&
!this._supportsDualAxis(this.stateObj)
) {
this._mainControl = "brightness";
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (changedProps.has("stateObj")) {
this._effect = this.stateObj?.attributes.effect;
}
}
private _supportsDualAxis(stateObj: LightEntity): boolean {
return (
lightSupportsBrightness(stateObj) &&
lightSupportsColorMode(stateObj, LightColorMode.COLOR_TEMP)
);
}
private _setMainControl(ev: any) {
ev.stopPropagation();
this._changeMainControl(ev.currentTarget.control);
@@ -143,6 +164,8 @@ class MoreInfoLight extends LitElement {
LightEntityFeature.EFFECT
);
const supportsDualAxis = this._supportsDualAxis(this.stateObj);
const showFavoriteColors = Boolean(
this.entry &&
(this.editMode ||
@@ -198,6 +221,17 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis &&
this._mainControl === "color_temp_brightness"
? html`
<light-color-temp-brightness-picker
.stateObj=${this.stateObj}
>
</light-color-temp-brightness-picker>
`
: nothing
}
<ha-icon-button-group>
${
supportsBrightness
@@ -269,6 +303,26 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis
? html`
<ha-icon-button-toggle
border-only
.selected=${
this._mainControl === "color_temp_brightness"
}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.label=${this._localize(
"ui.dialogs.more_info_control.light.color_temp_brightness"
)}
.control=${"color_temp_brightness"}
@click=${this._setMainControl}
>
<span class="wheel color-temp-brightness"></span>
</ha-icon-button-toggle>
`
: nothing
}
${
supportsWhite
? html`
@@ -399,6 +453,15 @@ class MoreInfoLight extends LitElement {
rgb(255, 160, 0) 100%
);
}
.wheel.color-temp-brightness {
background-image:
linear-gradient(to top, rgb(102, 102, 102), white),
linear-gradient(90deg, rgb(255, 160, 0), white, rgb(166, 209, 255));
background-blend-mode: multiply;
/* Mask the blended result as one layer; radius clipping bleeds
per background layer at the edge in Gecko */
clip-path: inset(0 round var(--ha-border-radius-circle));
}
*[disabled] .wheel {
filter: grayscale(1) opacity(0.5);
}
+1
View File
@@ -1937,6 +1937,7 @@
"toggle": "Toggle",
"color": "Color",
"color_temp": "Temperature",
"color_temp_brightness": "Temperature and brightness",
"set_white": "Set white",
"select_effect": "Select effect",
"color_picker": {
+4
View File
@@ -6,6 +6,10 @@ export const componentPages: GalleryPageSmokeCase[] = [
{ name: "ha-bar", selector: "demo-components-ha-bar" },
{ name: "ha-button", selector: "demo-components-ha-button" },
{ name: "ha-chips", selector: "demo-components-ha-chips" },
{
name: "ha-color-temp-brightness-picker",
selector: "demo-components-ha-color-temp-brightness-picker",
},
{ name: "ha-control-button", selector: "demo-components-ha-control-button" },
{
name: "ha-control-circular-slider",