Compare commits

...
17 changed files with 961 additions and 0 deletions
+1
View File
@@ -52,6 +52,7 @@ export default [
"ha-control-switch",
"ha-slider",
"ha-control-slider",
"ha-control-scrubber",
"ha-control-circular-slider",
"ha-control-number-buttons",
"ha-control-select",
@@ -0,0 +1,7 @@
---
title: Control scrubber
---
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds.
@@ -0,0 +1,95 @@
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-control-scrubber";
@customElement("demo-components-ha-control-scrubber")
export class DemoHaControlScrubber extends LitElement {
@state() private hue = 200;
@state() private position?: number;
handleHueChanged(e: CustomEvent) {
this.hue = e.detail.value as number;
}
handleMoved(e: CustomEvent) {
this.position = e.detail.value as number;
}
protected render(): TemplateResult {
return html`
<ha-card>
<div class="card-content">
<p><b>Scrubber values</b></p>
<table>
<tbody>
<tr>
<td>position</td>
<td>${this.position ?? "-"}</td>
</tr>
<tr>
<td>value</td>
<td>${this.hue ?? "-"}</td>
</tr>
</tbody>
</table>
</div>
</ha-card>
<ha-card>
<div class="card-content">
<label id="scrubber-hue">Hue (wrap)</label>
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
<ha-control-scrubber
wrap
min="0"
max="360"
unit="°"
.value=${this.hue}
@value-changed=${this.handleHueChanged}
@slider-moved=${this.handleMoved}
label="Hue"
>
</ha-control-scrubber>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
pre {
margin-top: 0;
margin-bottom: 8px;
}
p {
margin: 0;
}
label {
font-weight: var(--ha-font-weight-bold);
}
ha-control-scrubber {
--control-scrubber-track-width: 200%;
--control-scrubber-background: linear-gradient(
to right,
hsl(0 100% 50%),
hsl(60 100% 50%),
hsl(120 100% 50%),
hsl(180 100% 50%),
hsl(240 100% 50%),
hsl(300 100% 50%),
hsl(360 100% 50%)
);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
}
}
+18
View File
@@ -32,6 +32,8 @@ const ENTITIES = [
attributes: {
friendly_name: "Bed Light",
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
color_mode: LightColorMode.HS,
hs_color: [210, 60],
},
},
{
@@ -275,6 +277,22 @@ const CONFIGS = [
features: [{ type: "light-brightness" }],
},
},
{
heading: "Light color feature",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color" }],
},
},
{
heading: "Light color feature with hue and saturation",
config: {
type: "tile",
entity: "light.bed_light",
features: [{ type: "light-color", controls: "hue_saturation" }],
},
},
{
heading: "Light color temperature feature",
config: {
+374
View File
@@ -0,0 +1,374 @@
import { DIRECTION_HORIZONTAL, Manager, Pan, Press, Tap } from "@egjs/hammerjs";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import type { FrontendLocaleData } from "../data/translation";
declare global {
interface HASSDomEvents {
"slider-moved": { value?: number };
}
}
const A11Y_KEY_CODES = new Set([
"ArrowRight",
"ArrowUp",
"ArrowLeft",
"ArrowDown",
"PageUp",
"PageDown",
"Home",
"End",
]);
@customElement("ha-control-scrubber")
export class HaControlScrubber extends LitElement {
@property({ attribute: false }) public locale?: FrontendLocaleData;
@property({ type: Boolean, reflect: true })
public disabled = false;
@property({ type: Boolean, reflect: true })
public wrap = false;
@property({ attribute: "touch-action" })
public touchAction?: string;
@property({ type: Number })
public value?: number;
@property({ type: Number })
public step = 1;
@property({ type: Boolean, attribute: "round-value" })
public roundValue = false;
@property({ type: Number })
public min = 0;
@property({ type: Number })
public max = 100;
@property({ type: String })
public label?: string;
@property({ type: String })
public unit?: string;
@state()
public pressed = false;
private _mc?: HammerManager;
private get _range() {
return this.max - this.min;
}
valueToPercentage(value: number) {
return (this.normalizedValue(value) - this.min) / this._range;
}
normalizedValue(value: number) {
if (!this.wrap) {
return Math.min(Math.max(value, this.min), this.max);
}
const offset = (value - this.min) % this._range;
return (offset < 0 ? offset + this._range : offset) + this.min;
}
steppedValue(value: number) {
return this.normalizedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
const stepped = this.steppedValue(value);
return this.roundValue ? Math.round(stepped) : stepped;
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.setupListeners();
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("value") || changedProps.has("roundValue")) {
const valuenow = this._displayedValue(this.value ?? this.min);
this.setAttribute("aria-valuenow", valuenow.toString());
this.setAttribute("aria-valuetext", this._formatValue(valuenow));
}
if (changedProps.has("min")) {
this.setAttribute("aria-valuemin", this.min.toString());
}
if (changedProps.has("max")) {
this.setAttribute("aria-valuemax", this.max.toString());
}
}
connectedCallback(): void {
super.connectedCallback();
this.setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.destroyListeners();
}
@query("#scrubber")
private _scrubber?: HTMLElement;
@query(".track")
private _track?: HTMLElement;
setupListeners() {
if (this._scrubber && !this._mc) {
this._mc = new Manager(this._scrubber, {
touchAction: this.touchAction ?? "pan-y",
});
this._mc.add(
new Pan({
threshold: 10,
direction: DIRECTION_HORIZONTAL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
this._mc.add(new Press());
let savedValue: number | undefined;
this._mc.on("panstart", () => {
if (this.disabled) return;
this.pressed = true;
savedValue = this.value ?? this.min;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this.pressed = false;
this.value = savedValue;
fireEvent(this, "slider-moved", { value: undefined });
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this.value = this.normalizedValue(
savedValue! + this._deltaToValue(e.deltaX)
);
fireEvent(this, "slider-moved", {
value: this.steppedValue(this.value),
});
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this.pressed = false;
this.value = this.steppedValue(
savedValue! + this._deltaToValue(e.deltaX)
);
fireEvent(this, "slider-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this.value });
});
this._mc.on("singletap pressup", (e) => {
if (this.disabled) return;
const rect = this._scrubber!.getBoundingClientRect();
const offset = e.center.x - (rect.left + rect.width / 2);
this.value = this.steppedValue(
(this.value ?? this.min) - this._deltaToValue(offset)
);
fireEvent(this, "value-changed", { value: this.value });
});
}
}
destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
private _deltaToValue(deltaX: number) {
const trackWidth = this._track!.clientWidth / (this.wrap ? 3 : 1);
return (-deltaX * this._range) / trackWidth;
}
private get _tenPercentStep() {
return Math.max(this.step, this._range / 10);
}
private _handleKeyDown(e: KeyboardEvent) {
if (this.disabled || !A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
const current = this.value ?? this.min;
if (e.code === "Home") {
this.value = this.min;
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(current + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(current - this._tenPercentStep);
} else {
const multiplier =
e.code === "ArrowLeft" || e.code === "ArrowDown" ? -1 : 1;
this.value = this.normalizedValue(current + this.step * multiplier);
}
fireEvent(this, "slider-moved", { value: this.value });
}
private _handleKeyUp(e: KeyboardEvent) {
if (this.disabled || !A11Y_KEY_CODES.has(e.code)) return;
e.preventDefault();
fireEvent(this, "value-changed", { value: this.value });
}
private _formatValue(value: number) {
const formattedValue = formatNumber(value, this.locale);
const formattedUnit = this.unit
? `${blankBeforeUnit(this.unit, this.locale)}${this.unit}`
: "";
return `${formattedValue}${formattedUnit}`;
}
protected render(): TemplateResult {
const valuenow = this._displayedValue(this.value ?? this.min);
return html`
<div
class="container ${classMap({ pressed: this.pressed, wrap: this.wrap })}"
style=${styleMap({
"--value": `${this.valueToPercentage(this.value ?? this.min)}`,
})}
>
<div
id="scrubber"
class="scrubber"
role="slider"
tabindex="0"
aria-disabled=${this.disabled}
aria-label=${ifDefined(this.label)}
aria-valuenow=${valuenow.toString()}
aria-valuetext=${this._formatValue(valuenow)}
aria-valuemin=${this.min.toString()}
aria-valuemax=${this.max.toString()}
aria-orientation="horizontal"
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
>
<div class="rail"></div>
<div class="track"></div>
<div class="window"></div>
</div>
</div>
`;
}
static styles = css`
:host {
display: block;
--control-scrubber-color: var(--primary-color);
--control-scrubber-background: var(--disabled-color);
--control-scrubber-rail-color: var(--disabled-color);
--control-scrubber-rail-opacity: 0.2;
--control-scrubber-thickness: 40px;
--control-scrubber-border-radius: var(--ha-border-radius-md);
--control-scrubber-track-width: 100%;
--control-scrubber-inset-shadow: none;
height: var(--control-scrubber-thickness);
width: 100%;
}
.container {
position: relative;
height: 100%;
width: 100%;
--window-width: calc(var(--control-scrubber-thickness) / 2.5);
--track-width: max(var(--control-scrubber-track-width), 100%);
}
.scrubber {
position: relative;
height: 100%;
width: 100%;
border-radius: var(--control-scrubber-border-radius);
transform: translateZ(0);
transition: box-shadow 180ms ease-in-out;
outline: none;
overflow: hidden;
cursor: grab;
}
.pressed .scrubber {
cursor: grabbing;
}
.scrubber:focus-visible {
box-shadow: 0 0 0 2px var(--control-scrubber-color);
}
.scrubber * {
pointer-events: none;
}
.scrubber::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--control-scrubber-inset-shadow);
pointer-events: none;
}
.rail {
position: absolute;
inset: 0;
background: var(--control-scrubber-rail-color);
opacity: var(--control-scrubber-rail-opacity);
}
.track {
position: absolute;
top: 0;
height: 100%;
left: 50%;
width: var(--track-width);
background: var(--control-scrubber-background);
transform: translate3d(calc(var(--value, 0) * -100%), 0, 0);
transition: transform 180ms ease-in-out;
}
.wrap .track {
width: calc(3 * var(--track-width));
background-size: calc(100% / 3) 100%;
background-repeat: repeat-x;
transform: translate3d(calc((1 + var(--value, 0)) * -100% / 3), 0, 0);
}
.pressed .track {
transition: none;
}
.window {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: var(--window-width);
transform: translateX(-50%);
box-sizing: border-box;
border: 2px solid white;
border-radius: min(
var(--control-scrubber-border-radius),
var(--ha-border-radius-md)
);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.3),
0 1px 3px rgba(0, 0, 0, 0.15);
}
:host([disabled]) .scrubber {
cursor: not-allowed;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-control-scrubber": HaControlScrubber;
}
}
+9
View File
@@ -422,6 +422,7 @@ export class HaControlSlider extends LitElement {
--control-slider-background-opacity: 0.2;
--control-slider-thickness: 40px;
--control-slider-border-radius: var(--ha-border-radius-md);
--control-slider-inset-shadow: none;
--control-slider-tooltip-font-size: var(--ha-font-size-m);
height: var(--control-slider-thickness);
width: 100%;
@@ -532,6 +533,14 @@ export class HaControlSlider extends LitElement {
.slider * {
pointer-events: none;
}
.slider::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
box-shadow: var(--control-slider-inset-shadow);
pointer-events: none;
}
.slider .slider-track-background {
position: absolute;
top: 0;
+4
View File
@@ -48,6 +48,10 @@ export const lightSupportsColor = (entity: LightEntity) =>
modesSupportingColor.includes(mode)
) || false;
export const lightIsInColorMode = (entity: LightEntity) =>
entity.attributes.color_mode != null &&
modesSupportingColor.includes(entity.attributes.color_mode);
export const lightSupportsBrightness = (entity: LightEntity) =>
entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingBrightness.includes(mode)
@@ -39,6 +39,11 @@ export const cardFeatureStyles = css`
--control-slider-thickness: var(--feature-height);
--control-slider-border-radius: var(--feature-border-radius);
}
ha-control-scrubber {
--control-scrubber-color: var(--feature-color);
--control-scrubber-thickness: var(--feature-height);
--control-scrubber-border-radius: var(--feature-border-radius);
}
ha-control-switch {
--control-switch-on-color: var(--feature-color);
--control-switch-off-color: var(--feature-color);
@@ -0,0 +1,322 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-control-button";
import "../../../components/ha-control-scrubber";
import "../../../components/ha-control-slider";
import { apiContext, internationalizationContext } from "../../../data/context";
import { UNAVAILABLE } from "../../../data/entity/entity";
import {
lightIsInColorMode,
lightSupportsColor,
type LightEntity,
} from "../../../data/light";
import type { FrontendLocaleData } from "../../../data/translation";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantInternationalization,
} from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LightColorCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
type ColorAxis = "hue" | "saturation";
const HUE_GRADIENT = Array.from(
{ length: 7 },
(_, i) => `hsl(${i * 60} 100% 50%)`
).join(", ");
const supportsLightColorCardFeatureFromState = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "light" && lightSupportsColor(stateObj);
};
export const supportsLightColorCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
return supportsLightColorCardFeatureFromState(stateObj);
};
@customElement("hui-light-color-card-feature")
class HuiLightColorCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state()
@consumeEntityState({ entityIdPath: ["context", "entity_id"] })
private _stateObj?: LightEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state() private _config?: LightColorCardFeatureConfig;
@state() private _expanded: ColorAxis = "hue";
static getStubConfig(): LightColorCardFeatureConfig {
return {
type: "light-color",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-light-color-card-feature-editor");
return document.createElement("hui-light-color-card-feature-editor");
}
public setConfig(config: LightColorCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.context ||
!this._stateObj ||
!supportsLightColorCardFeatureFromState(this._stateObj)
) {
return nothing;
}
const hsColor = this._stateObj.attributes.hs_color;
const hue = hsColor?.[0];
const saturation = hsColor?.[1];
const saturationGradient = `hsl(${hue ?? 0} 0% 100%), hsl(${hue ?? 0} 100% 50%)`;
const controls = this._config.controls ?? "hue";
const showHue = controls !== "saturation";
const showSaturation = controls !== "hue";
const single = !(showHue && showSaturation);
const disabled = this._stateObj.state === UNAVAILABLE;
const hueLabel = this._localize("ui.card.light.hue");
const saturationLabel = this._localize("ui.card.light.saturation");
return html`
<div class="container">
${
showHue
? this._renderAxis({
axis: "hue",
expanded: single || this._expanded === "hue",
gradient: HUE_GRADIENT,
label: hueLabel,
disabled,
onExpand: this._expandHue,
control: html`
<ha-control-scrubber
.value=${hue}
round-value
wrap
.disabled=${disabled}
@value-changed=${this._hueChanged}
.label=${hueLabel}
min="0"
max="360"
unit="°"
.locale=${this._locale}
></ha-control-scrubber>
`,
})
: nothing
}
${
showSaturation
? this._renderAxis({
axis: "saturation",
expanded: single || this._expanded === "saturation",
gradient: saturationGradient,
label: saturationLabel,
disabled,
onExpand: this._expandSaturation,
control: html`
<ha-control-slider
.value=${saturation}
mode="cursor"
round-value
.showHandle=${stateActive(this._stateObj)}
.disabled=${disabled}
@value-changed=${this._saturationChanged}
.label=${saturationLabel}
min="0"
max="100"
unit="%"
.locale=${this._locale}
></ha-control-slider>
`,
})
: nothing
}
</div>
`;
}
private _renderAxis(options: {
axis: ColorAxis;
expanded: boolean;
gradient: string;
label: string;
disabled: boolean;
onExpand: (ev: Event) => void;
control: TemplateResult;
}) {
return html`
<div
class=${classMap({
axis: true,
[options.axis]: true,
expanded: options.expanded,
})}
style=${styleMap({ "--gradient": options.gradient })}
>
${
options.expanded
? options.control
: html`
<ha-control-button
.label=${options.label}
.disabled=${options.disabled}
@click=${options.onExpand}
>
<div class="preview"></div>
</ha-control-button>
`
}
</div>
`;
}
private _expandHue = (ev: Event) => {
ev.stopPropagation();
this._expanded = "hue";
};
private _expandSaturation = (ev: Event) => {
ev.stopPropagation();
this._expanded = "saturation";
};
private _hueChanged = (ev: CustomEvent) => {
ev.stopPropagation();
const current = this._stateObj!.attributes.hs_color?.[1];
const visible = current && lightIsInColorMode(this._stateObj!);
this._setColor([ev.detail.value, visible ? current : 100]);
};
private _saturationChanged = (ev: CustomEvent) => {
ev.stopPropagation();
const hue = this._stateObj!.attributes.hs_color?.[0] ?? 0;
this._setColor([hue, ev.detail.value]);
};
private _setColor(hsColor: [number, number]) {
this._api.callService("light", "turn_on", {
entity_id: this._stateObj!.entity_id,
hs_color: hsColor,
});
}
static get styles() {
return [
cardFeatureStyles,
css`
.container {
display: flex;
align-items: stretch;
gap: var(--feature-button-spacing);
height: var(--feature-height);
}
.axis {
position: relative;
min-width: 0;
flex: 0 0 var(--feature-height);
transition: flex var(--ha-animation-duration-normal) ease-in-out;
}
.axis.expanded {
flex: 1 1 0;
}
ha-control-scrubber {
--control-scrubber-background: linear-gradient(
to right,
var(--gradient)
);
--control-scrubber-track-width: max(
100% * 12 / var(--column-size, 12),
320px
);
}
ha-control-slider {
--control-slider-background: linear-gradient(
to right,
var(--gradient)
);
--control-slider-background-opacity: 1;
--control-slider-inset-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
}
ha-control-button {
width: 100%;
height: 100%;
--control-button-padding: 0;
}
.preview {
width: 100%;
height: 100%;
border-radius: inherit;
background: linear-gradient(to right, var(--gradient));
}
.saturation .preview {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
}
ha-control-button[disabled] .preview {
opacity: 0.2;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-light-color-card-feature": HuiLightColorCardFeature;
}
}
@@ -164,6 +164,7 @@ class HuiLightColorTempCardFeature
var(--gradient)
);
--control-slider-background-opacity: 1;
--control-slider-inset-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.08);
}
`,
];
@@ -25,6 +25,7 @@ import { supportsHumidifierToggleCardFeature } from "./hui-humidifier-toggle-car
import { supportsLawnMowerCommandCardFeature } from "./hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "./hui-light-brightness-card-feature";
import { supportsLightColorFavoritesCardFeature } from "./hui-light-color-favorites-card-feature";
import { supportsLightColorCardFeature } from "./hui-light-color-card-feature";
import { supportsLightColorTempCardFeature } from "./hui-light-color-temp-card-feature";
import { supportsLightEffectCardFeature } from "./hui-light-effect-card-feature";
import { supportsLockCommandsCardFeature } from "./hui-lock-commands-card-feature";
@@ -89,6 +90,7 @@ export const UI_FEATURE_TYPES = [
"humidifier-toggle",
"lawn-mower-commands",
"light-brightness",
"light-color",
"light-color-temp",
"light-color-favorites",
"light-effect",
@@ -147,6 +149,7 @@ export const SUPPORTS_FEATURE_TYPES: Record<UiFeatureType, SupportsFeature> = {
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
"light-brightness": supportsLightBrightnessCardFeature,
"light-color": supportsLightColorCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"light-effect": supportsLightEffectCardFeature,
@@ -39,6 +39,14 @@ export interface LightBrightnessCardFeatureConfig {
type: "light-brightness";
}
export type LightColorCardFeatureControls =
"hue" | "saturation" | "hue_saturation";
export interface LightColorCardFeatureConfig {
type: "light-color";
controls?: LightColorCardFeatureControls;
}
export interface LightColorTempCardFeatureConfig {
type: "light-color-temp";
}
@@ -366,6 +374,7 @@ export type LovelaceCardFeatureConfig =
| HumidifierModesCardFeatureConfig
| LawnMowerCommandsCardFeatureConfig
| LightBrightnessCardFeatureConfig
| LightColorCardFeatureConfig
| LightColorTempCardFeatureConfig
| LightColorFavoritesCardFeatureConfig
| LightEffectCardFeatureConfig
@@ -26,6 +26,7 @@ const DOMAIN_VARIANTS: Record<string, TileVariant[]> = {
TILE_VARIANT,
["light-brightness"],
TILE_TOGGLE_VARIANT,
["light-color"],
["light-color-temp"],
["light-color-favorites"],
["light-effect"],
@@ -21,6 +21,7 @@ import "../card-features/hui-humidifier-modes-card-feature";
import "../card-features/hui-humidifier-toggle-card-feature";
import "../card-features/hui-lawn-mower-commands-card-feature";
import "../card-features/hui-light-brightness-card-feature";
import "../card-features/hui-light-color-card-feature";
import "../card-features/hui-light-color-temp-card-feature";
import "../card-features/hui-light-color-favorites-card-feature";
import "../card-features/hui-light-effect-card-feature";
@@ -83,6 +84,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"humidifier-toggle",
"lawn-mower-commands",
"light-brightness",
"light-color",
"light-color-temp",
"light-color-favorites",
"light-effect",
@@ -51,6 +51,7 @@ import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humi
import { supportsHumidifierToggleCardFeature } from "../../card-features/hui-humidifier-toggle-card-feature";
import { supportsLawnMowerCommandCardFeature } from "../../card-features/hui-lawn-mower-commands-card-feature";
import { supportsLightBrightnessCardFeature } from "../../card-features/hui-light-brightness-card-feature";
import { supportsLightColorCardFeature } from "../../card-features/hui-light-color-card-feature";
import { supportsLightColorTempCardFeature } from "../../card-features/hui-light-color-temp-card-feature";
import { supportsLightEffectCardFeature } from "../../card-features/hui-light-effect-card-feature";
import { supportsLockCommandsCardFeature } from "../../card-features/hui-lock-commands-card-feature";
@@ -115,6 +116,7 @@ const UI_FEATURE_TYPES = [
"humidifier-toggle",
"lawn-mower-commands",
"light-brightness",
"light-color",
"light-color-temp",
"light-color-favorites",
"light-effect",
@@ -165,6 +167,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"temperature-forecast",
"lawn-mower-commands",
"media-player-playback",
"light-color",
"light-color-favorites",
"light-effect",
"media-player-sound-mode",
@@ -214,6 +217,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"humidifier-toggle": supportsHumidifierToggleCardFeature,
"lawn-mower-commands": supportsLawnMowerCommandCardFeature,
"light-brightness": supportsLightBrightnessCardFeature,
"light-color": supportsLightColorCardFeature,
"light-color-temp": supportsLightColorTempCardFeature,
"light-color-favorites": supportsLightColorFavoritesCardFeature,
"light-effect": supportsLightEffectCardFeature,
@@ -0,0 +1,95 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types";
import type {
LightColorCardFeatureConfig,
LightColorCardFeatureControls,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
const CONTROLS: LightColorCardFeatureControls[] = [
"hue",
"saturation",
"hue_saturation",
];
@customElement("hui-light-color-card-feature-editor")
export class HuiLightColorCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: LightColorCardFeatureConfig;
public setConfig(config: LightColorCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "controls",
selector: {
select: {
multiple: false,
mode: "list",
options: CONTROLS.map((controls) => ({
value: controls,
label: localize(
`ui.panel.lovelace.editor.features.types.light-color.controls_list.${controls}`
),
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const data: LightColorCardFeatureConfig = {
controls: "hue",
...this._config,
};
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${this._schema(this.hass.localize)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.light-color.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"hui-light-color-card-feature-editor": HuiLightColorCardFeatureEditor;
}
}
+11
View File
@@ -208,6 +208,8 @@
"light": {
"brightness": "Brightness",
"color_temperature": "Color temperature",
"hue": "Hue",
"saturation": "Saturation",
"white_value": "White brightness",
"color_brightness": "Color brightness",
"cold_white_value": "Cold white brightness",
@@ -10905,6 +10907,15 @@
"light-brightness": {
"label": "Light brightness"
},
"light-color": {
"label": "Light color",
"controls": "Controls",
"controls_list": {
"hue": "Hue",
"saturation": "Saturation",
"hue_saturation": "Hue and saturation"
}
},
"light-color-favorites": {
"label": "Light color favorites",
"description": "This feature uses the light's favorite colors. To edit them, open the light's more info dialog and press and hold a favorite."