Compare commits

...

20 Commits

Author SHA1 Message Date
Aidan Timson
0ce17e3dea Keep number 2025-09-04 14:04:07 +01:00
Aidan Timson
ef9029829a Stop propogation of event 2025-09-04 13:54:05 +01:00
Aidan Timson
8bc53c61f2 Use type 2025-09-04 13:49:49 +01:00
Aidan Timson
711dcdbff0 Don't try to flip 2025-09-04 13:46:58 +01:00
Aidan Timson
6834e458d7 Fix 2025-09-04 13:29:03 +01:00
Aidan Timson
d597239925 Move to helper 2025-09-04 13:15:50 +01:00
Aidan Timson
004b3ce025 Simplify example 2025-09-04 12:06:11 +01:00
Aidan Timson
43e7b55e99 Focus 2025-09-04 11:11:24 +01:00
Aidan Timson
ea5fe14a64 Test panel, revert this when done 2025-09-04 11:04:24 +01:00
Aidan Timson
807fbf8bb6 Work with clamps 2025-09-04 11:04:24 +01:00
Aidan Timson
44cd425ce8 Remove duplicate event 2025-09-04 11:04:24 +01:00
Aidan Timson
af01f66329 Fix 2025-09-04 11:04:24 +01:00
Aidan Timson
d5892b372c icons, labels 2025-09-04 11:04:24 +01:00
Aidan Timson
8656df6129 Add padStart 2025-09-04 11:04:24 +01:00
Aidan Timson
8c543ee67c Setup 2025-09-04 11:04:24 +01:00
Aidan Timson
4789d8c793 Setup 2025-09-04 11:04:24 +01:00
Aidan Timson
f08bbe7c1e Setup 2025-09-04 11:04:24 +01:00
Aidan Timson
9f1ee988bc Setup 2025-09-04 11:04:24 +01:00
Aidan Timson
eba0fa35d3 Setup 2025-09-04 11:04:23 +01:00
Aidan Timson
5b8c5375b4 Setup 2025-09-04 11:04:23 +01:00
8 changed files with 633 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mdiMinus, mdiPlus } from "@mdi/js";
import { fireEvent } from "../common/dom/fire_event";
import type { HaIconButton } from "./ha-icon-button";
import "./ha-textfield";
import "./ha-icon-button";
import { clampValue } from "../data/number";
@customElement("ha-numeric-arrow-input")
export class HaNumericArrowInput extends LitElement {
@property({ attribute: false }) public disabled = false;
@property({ attribute: false }) public required = false;
@property({ attribute: false }) public min?: number;
@property({ attribute: false }) public max?: number;
@property({ attribute: false }) public step?: number;
@property({ attribute: false }) public padStart?: number;
@property({ attribute: false }) public labelUp = "Increase";
@property({ attribute: false }) public labelDown = "Decrease";
@property({ attribute: false }) public value = 0;
@query("ha-icon-button[data-direction='up']")
private _upButton!: HaIconButton;
@query("ha-icon-button[data-direction='down']")
private _downButton!: HaIconButton;
private _paddedValue = memoizeOne((value: number, padStart?: number) =>
value.toString().padStart(padStart ?? 0, "0")
);
render() {
return html`<div
class="numeric-arrow-input-container"
@keydown=${this._keyDown}
>
<ha-icon-button
data-direction="up"
.disabled=${this.disabled}
.label=${this.labelUp}
.path=${mdiPlus}
@click=${this._up}
></ha-icon-button>
<span class="numeric-arrow-input-value"
>${this._paddedValue(this.value, this.padStart)}</span
>
<ha-icon-button
data-direction="down"
.disabled=${this.disabled}
.label=${this.labelDown}
.path=${mdiMinus}
@click=${this._down}
></ha-icon-button>
</div>`;
}
private _keyDown(ev: KeyboardEvent) {
if (ev.key === "ArrowUp") {
this._upButton.focus();
this._up();
}
if (ev.key === "ArrowDown") {
this._downButton.focus();
this._down();
}
}
private _up() {
const newValue = this.value + (this.step ?? 1);
fireEvent(
this,
"value-changed",
clampValue({ value: newValue, min: this.min, max: this.max })
);
}
private _down() {
const newValue = this.value - (this.step ?? 1);
fireEvent(
this,
"value-changed",
clampValue({ value: newValue, min: this.min, max: this.max })
);
}
static styles = css`
.numeric-arrow-input-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
}
.numeric-arrow-input-container ha-icon-button {
--mdc-icon-button-size: 24px;
color: var(--secondary-text-color);
}
.numeric-arrow-input-value {
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-numeric-arrow-input": HaNumericArrowInput;
}
}

View File

@@ -0,0 +1,281 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { clampValue } from "../data/number";
import { useAmPm } from "../common/datetime/use_am_pm";
import { fireEvent } from "../common/dom/fire_event";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistant } from "../types";
import type { ClampedValue } from "../data/number";
import "./ha-base-time-input";
import "./ha-button";
import "./ha-numeric-arrow-input";
@customElement("ha-time-picker")
export class HaTimePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public locale!: FrontendLocaleData;
@property({ attribute: false }) public value?: string;
@property({ attribute: false }) public disabled = false;
@property({ attribute: false }) public required = false;
@property({ attribute: false }) public enableSeconds = false;
@state() private _hours = 0;
@state() private _minutes = 0;
@state() private _seconds = 0;
@state() private _useAmPm = false;
@state() private _isPm = false;
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
this._useAmPm = useAmPm(this.locale);
let hours = NaN;
let minutes = NaN;
let seconds = NaN;
let isPm = false;
if (this.value) {
const parts = this.value?.split(":") || [];
minutes = parts[1] ? Number(parts[1]) : 0;
seconds = parts[2] ? Number(parts[2]) : 0;
const hour24 = parts[0] ? Number(parts[0]) : 0;
if (this._useAmPm) {
if (hour24 === 0) {
hours = 12;
isPm = false;
} else if (hour24 < 12) {
hours = hour24;
isPm = false;
} else if (hour24 === 12) {
hours = 12;
isPm = true;
} else {
hours = hour24 - 12;
isPm = true;
}
} else {
hours = hour24;
}
}
this._hours = hours;
this._minutes = minutes;
this._seconds = seconds;
this._isPm = isPm;
}
protected render() {
return html`<div class="time-picker-container">
<ha-numeric-arrow-input
.disabled=${this.disabled}
.required=${this.required}
.min=${this._useAmPm ? 1 : 0}
.max=${this._useAmPm ? 12 : 23}
.step=${1}
.padStart=${2}
.value=${this._hours}
@value-changed=${this._hoursChanged}
.labelUp=${
// TODO: Localize
"Increase hours"
}
.labelDown=${
// TODO: Localize
"Decrease hours"
}
></ha-numeric-arrow-input>
<span class="time-picker-separator">:</span>
<ha-numeric-arrow-input
.disabled=${this.disabled}
.required=${this.required}
.min=${0}
.max=${59}
.step=${1}
.padStart=${2}
.labelUp=${
// TODO: Localize
"Increase minutes"
}
.labelDown=${
// TODO: Localize
"Decrease minutes"
}
.value=${this._minutes}
@value-changed=${this._minutesChanged}
></ha-numeric-arrow-input>
${this.enableSeconds
? html`
<span class="time-picker-separator">:</span>
<ha-numeric-arrow-input
.disabled=${this.disabled}
.required=${this.required}
.min=${0}
.max=${59}
.step=${1}
.padStart=${2}
.labelUp=${
// TODO: Localize
"Increase seconds"
}
.labelDown=${
// TODO: Localize
"Decrease seconds"
}
.value=${this._seconds}
@value-changed=${this._secondsChanged}
></ha-numeric-arrow-input>
`
: nothing}
${this._useAmPm
? html`
<ha-button @click=${this._toggleAmPm}>
${this._isPm ? "PM" : "AM"}
</ha-button>
`
: nothing}
</div>`;
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
if (changedProperties.has("_hours")) {
this._timeUpdated();
}
if (changedProperties.has("_minutes")) {
this._timeUpdated();
}
if (changedProperties.has("_seconds")) {
this._timeUpdated();
}
if (changedProperties.has("_useAmPm")) {
this._timeUpdated();
}
if (changedProperties.has("_isPm")) {
this._timeUpdated();
}
}
private _hoursChanged(ev: CustomEvent<ClampedValue>) {
ev.stopPropagation?.();
this._hours = ev.detail.value;
}
private _minutesChanged(ev: CustomEvent<ClampedValue>) {
ev.stopPropagation?.();
this._minutes = ev.detail.value;
if (ev.detail.clamped) {
if (ev.detail.value === 0) {
this._hoursChanged({
detail: clampValue({
value: this._hours - 1,
min: this._useAmPm ? 1 : 0,
max: this._useAmPm ? 12 : 23,
}),
} as CustomEvent<ClampedValue>);
this._minutes = 59;
}
if (ev.detail.value === 59) {
this._hoursChanged({
detail: clampValue({
value: this._hours + 1,
min: this._useAmPm ? 1 : 0,
max: this._useAmPm ? 12 : 23,
}),
} as CustomEvent<ClampedValue>);
const hourMax = this._useAmPm ? 12 : 23;
if (this._hours < hourMax) {
this._minutes = 0;
}
}
}
}
private _secondsChanged(ev: CustomEvent<ClampedValue>) {
ev.stopPropagation?.();
this._seconds = ev.detail.value;
if (ev.detail.clamped) {
if (ev.detail.value === 0) {
this._minutesChanged({
detail: clampValue({ value: this._minutes - 1, min: 0, max: 59 }),
} as CustomEvent<ClampedValue>);
this._seconds = 59;
}
if (ev.detail.value === 59) {
this._minutesChanged({
detail: clampValue({ value: this._minutes + 1, min: 0, max: 59 }),
} as CustomEvent<ClampedValue>);
const hourMax = this._useAmPm ? 12 : 23;
if (!(this._hours === hourMax && this._minutes === 59)) {
this._seconds = 0;
}
}
}
}
private _toggleAmPm() {
this._isPm = !this._isPm;
}
private _timeUpdated() {
let hour24 = this._hours;
if (this._useAmPm) {
if (this._hours === 12) {
hour24 = this._isPm ? 12 : 0;
} else {
hour24 = this._isPm ? this._hours + 12 : this._hours;
}
}
const timeParts = [
hour24.toString().padStart(2, "0"),
this._minutes.toString().padStart(2, "0"),
this._seconds.toString().padStart(2, "0"),
];
const time = timeParts.join(":");
if (time === this.value) {
return;
}
this.value = time;
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: time });
}
static styles = css`
.time-picker-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
}
.time-picker-separator {
color: var(--primary-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-time-picker": HaTimePicker;
}
}

View File

@@ -12,3 +12,35 @@ export const getNumberDeviceClassConvertibleUnits = (
type: "number/device_class_convertible_units",
device_class: deviceClass,
});
export interface ClampedValue {
clamped: boolean;
value: number;
}
/**
* Clamp a value between a minimum and maximum value
* @param value - The value to clamp
* @param min - The minimum value
* @param max - The maximum value
* @returns The clamped value
*/
export const clampValue = ({
value,
min,
max,
}: {
value: number;
min?: number;
max?: number;
}): ClampedValue => {
if (max !== undefined && value > max) {
return { clamped: true, value: max };
}
if (min !== undefined && value < min) {
return { clamped: true, value: min };
}
return { clamped: false, value };
};

View File

@@ -79,6 +79,13 @@ export const demoPanels: Panels = {
config: null,
url_path: "energy",
},
"time-picker": {
component_name: "time-picker",
icon: "hass:clock-outline",
title: "time_picker",
config: null,
url_path: "time-picker",
},
// config: {
// component_name: "config",
// icon: "hass:cog",

View File

@@ -30,6 +30,7 @@ const COMPONENTS = {
my: () => import("../panels/my/ha-panel-my"),
profile: () => import("../panels/profile/ha-panel-profile"),
todo: () => import("../panels/todo/ha-panel-todo"),
"time-picker": () => import("../panels/time-picker/ha-panel-time-picker"),
"media-browser": () =>
import("../panels/media-browser/ha-panel-media-browser"),
};

View File

@@ -54,6 +54,10 @@ class DeveloperToolsRouter extends HassRouterPage {
tag: "developer-tools-debug",
load: () => import("./debug/developer-tools-debug"),
},
"time-picker": {
tag: "developer-tools-time-picker",
load: () => import("../time-picker/ha-panel-time-picker"),
},
},
};

View File

@@ -80,6 +80,13 @@ class PanelDeveloperTools extends LitElement {
<sl-tab slot="nav" panel="assist" .active=${page === "assist"}
>Assist</sl-tab
>
<sl-tab
slot="nav"
panel="time-picker"
.active=${page === "time-picker"}
>
Time Picker
</sl-tab>
</sl-tab-group>
</div>
<developer-tools-router

View File

@@ -0,0 +1,180 @@
import { html, LitElement, css } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import "../../components/ha-time-picker";
import "../../components/ha-card";
import "../../components/ha-button";
import "../../components/ha-alert";
import "../../components/ha-selector/ha-selector";
@customElement("developer-tools-time-picker")
export class DeveloperToolsTimePicker extends LitElement {
@property({ attribute: false })
public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true })
public narrow = false;
@state()
private _timeValue = "14:15:00";
@state()
private _timeValue2 = "09:05:05";
static get styles() {
return css`
:host {
display: block;
padding: 16px;
max-width: 800px;
margin: 0 auto;
}
.header {
margin-bottom: 24px;
}
.header h1 {
margin: 0 0 8px 0;
color: var(--primary-text-color);
font-size: 28px;
font-weight: 400;
}
.header p {
margin: 0;
color: var(--secondary-text-color);
font-size: 16px;
line-height: 1.5;
}
.section {
margin-bottom: 32px;
}
.section h2 {
margin: 0 0 16px 0;
color: var(--primary-text-color);
font-size: 20px;
font-weight: 500;
}
.example {
background: var(--card-background-color);
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
border: 1px solid var(--divider-color);
}
.example h3 {
margin: 0 0 16px 0;
color: var(--primary-text-color);
font-size: 16px;
font-weight: 500;
}
.time-picker-container {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.value-display {
background: var(--secondary-background-color);
padding: 8px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 14px;
color: var(--primary-text-color);
min-width: 100px;
text-align: center;
}
.controls {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.form-toggle {
margin-top: 16px;
}
@media (max-width: 600px) {
:host {
padding: 12px;
}
.time-picker-container {
flex-direction: column;
align-items: stretch;
}
.controls {
flex-direction: column;
align-items: stretch;
}
}
`;
}
protected render() {
return html`
<div class="header">
<h1>Time picker demo</h1>
<p>
This page demonstrates the ha-time-picker component with various
configurations and use cases.
</p>
</div>
<div class="section">
<h2>Time picker</h2>
<div class="example">
<div class="time-picker-container">
<ha-time-picker
.locale=${this.hass.locale}
.value=${this._timeValue}
@value-changed=${this._onTimeChanged}
></ha-time-picker>
<div class="value-display">${this._timeValue}</div>
</div>
<p>Current value: ${this._timeValue}</p>
</div>
</div>
<div class="section">
<h2>Time picker with seconds</h2>
<div class="example">
<div class="time-picker-container">
<ha-time-picker
.locale=${this.hass.locale}
.value=${this._timeValue2}
.enableSeconds=${true}
@value-changed=${this._onTime2Changed}
></ha-time-picker>
<div class="value-display">${this._timeValue2}</div>
</div>
<p>Current value: ${this._timeValue2}</p>
</div>
</div>
`;
}
private _onTimeChanged(ev: CustomEvent) {
this._timeValue = ev.detail.value;
}
private _onTime2Changed(ev: CustomEvent) {
this._timeValue2 = ev.detail.value;
}
}
declare global {
interface HTMLElementTagNameMap {
"developer-tools-time-picker": DeveloperToolsTimePicker;
}
}