mirror of
https://github.com/home-assistant/frontend.git
synced 2026-02-27 03:47:41 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f88c707c32 | ||
|
|
753de9e58f | ||
|
|
1035df8733 | ||
|
|
e341c68035 | ||
|
|
f5b8d4e372 |
20
.agents/skills/component-alert/SKILL.md
Normal file
20
.agents/skills/component-alert/SKILL.md
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: component-alert
|
||||
description: Show user feedback with ha-alert. Use when choosing alert types, properties, and accessible dynamic status messaging.
|
||||
---
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
<ha-alert alert-type="error">Error message</ha-alert>
|
||||
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-alert.markdown`
|
||||
27
.agents/skills/component-form/SKILL.md
Normal file
27
.agents/skills/component-form/SKILL.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: component-form
|
||||
description: Build schema-driven ha-form UIs. Use when defining HaFormSchema, wiring data/error/schema, and localized labels/helpers in forms.
|
||||
---
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
|
||||
- Built-in validation with error display
|
||||
- Use `autofocus` attribute to automatically focus the first focusable element. If using the legacy `ha-dialog` dialogs, use `dialogInitialFocus`
|
||||
- Use `computeLabel`, `computeError`, `computeHelper` for translations
|
||||
|
||||
```typescript
|
||||
<ha-form
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${this._schema}
|
||||
.error=${this._errors}
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-form.markdown`
|
||||
14
.agents/skills/component-tooltip/SKILL.md
Normal file
14
.agents/skills/component-tooltip/SKILL.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: component-tooltip
|
||||
description: Add contextual hover help with ha-tooltip. Use when integrating Home Assistant themed tooltips and checking canonical usage references.
|
||||
---
|
||||
|
||||
### Tooltip Component (ha-tooltip)
|
||||
|
||||
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
|
||||
50
.agents/skills/create-card/SKILL.md
Normal file
50
.agents/skills/create-card/SKILL.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: create-card
|
||||
description: Create Lovelace card implementations. Use when implementing LovelaceCard methods, config validation, card size behavior, and optional editor hooks.
|
||||
---
|
||||
|
||||
#### Creating a Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
|
||||
|
||||
```typescript
|
||||
@customElement("hui-my-card")
|
||||
export class HuiMyCard extends LitElement implements LovelaceCard {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyCardConfig;
|
||||
|
||||
public setConfig(config: MyCardConfig): void {
|
||||
if (!config.entity) {
|
||||
throw new Error("Entity required");
|
||||
}
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
public getCardSize(): number {
|
||||
return 3; // Height in grid units
|
||||
}
|
||||
|
||||
// Optional: Editor for card configuration
|
||||
public static getConfigElement(): LovelaceCardEditor {
|
||||
return document.createElement("hui-my-card-editor");
|
||||
}
|
||||
|
||||
// Optional: Stub config for card picker
|
||||
public static getStubConfig(): object {
|
||||
return { entity: "" };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Card Guidelines:**
|
||||
|
||||
- Cards are highly customizable for different households
|
||||
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
|
||||
- Use proper error handling in `setConfig()`
|
||||
- Consider all possible states (loading, error, unavailable)
|
||||
- Support different entity types and states
|
||||
- Follow responsive design principles
|
||||
- Add configuration editor when needed
|
||||
121
.agents/skills/dialogs/SKILL.md
Normal file
121
.agents/skills/dialogs/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: dialogs
|
||||
description: Build and review Home Assistant dialogs. Use when opening dialogs with the show-dialog event, implementing HassDialog lifecycle, and configuring dialog sizing and footer actions.
|
||||
---
|
||||
|
||||
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||
|
||||
```typescript
|
||||
fireEvent(this, "show-dialog", {
|
||||
dialogTag: "dialog-example",
|
||||
dialogImport: () => import("./dialog-example"),
|
||||
dialogParams: { title: "Example", data: someData },
|
||||
});
|
||||
```
|
||||
|
||||
**Dialog Implementation Requirements:**
|
||||
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
- Return `nothing` when no params (loading state)
|
||||
- Fire `dialog-closed` event in `_dialogClosed()` handler
|
||||
- Use `header-title` attribute for simple titles
|
||||
- Use `header-subtitle` attribute for simple subtitles
|
||||
- Use slots for custom content where the standard attributes are not enough
|
||||
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
|
||||
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
|
||||
|
||||
### Creating a Dialog
|
||||
|
||||
```typescript
|
||||
@customElement("dialog-my-feature")
|
||||
export class DialogMyFeature
|
||||
extends LitElement
|
||||
implements HassDialog<MyDialogParams>
|
||||
{
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _params?: MyDialogParams;
|
||||
|
||||
@state()
|
||||
private _open = false;
|
||||
|
||||
public async showDialog(params: MyDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _submit(): void {
|
||||
// Example submit handler: perform save logic, then close the dialog
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
header-subtitle=${this._params.subtitle}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p>Dialog content</p>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._submit}>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [haStyleDialog, css``];
|
||||
}
|
||||
```
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
|
||||
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
|
||||
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
### Dialog Design Guidelines
|
||||
|
||||
- Max width: 560px (Alert/confirmation: 320px fixed width)
|
||||
- Close X-icon on top left (all screen sizes)
|
||||
- Submit button grouped with cancel at bottom right
|
||||
- Keep button labels short: "Save", "Delete", "Enable"
|
||||
- Destructive actions use red warning button
|
||||
- Always use a title (best practice)
|
||||
- Strive for minimalism
|
||||
19
.agents/skills/keyboard-shortcuts/SKILL.md
Normal file
19
.agents/skills/keyboard-shortcuts/SKILL.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: keyboard-shortcuts
|
||||
description: Register safe keyboard shortcuts with ShortcutManager. Use when handling focused inputs, text selection, and non-latin keyboard fallback behavior.
|
||||
---
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically blocks shortcuts when input fields are focused
|
||||
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
|
||||
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Class definition**: `src/common/keyboard/shortcuts.ts`
|
||||
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
|
||||
28
.agents/skills/mixin-subscribe-panel/SKILL.md
Normal file
28
.agents/skills/mixin-subscribe-panel/SKILL.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: mixin-subscribe-panel
|
||||
description: Use when implementing panel classes with SubscribeMixin and hassSubscribe() entity subscriptions.
|
||||
---
|
||||
|
||||
### Creating a Panel
|
||||
|
||||
```typescript
|
||||
@customElement("ha-panel-myfeature")
|
||||
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
narrow!: boolean;
|
||||
|
||||
@property()
|
||||
route!: Route;
|
||||
|
||||
hassSubscribe() {
|
||||
return [
|
||||
subscribeEntityRegistry(this.hass.connection, (entities) => {
|
||||
this._entities = entities;
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
36
.agents/skills/style-tokens/SKILL.md
Normal file
36
.agents/skills/style-tokens/SKILL.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: style-tokens
|
||||
description: Apply Home Assistant CSS token styling. Use when choosing --ha-space-* spacing tokens, theme variables, responsive behavior, and RTL-safe component CSS.
|
||||
---
|
||||
|
||||
### Styling Guidelines
|
||||
|
||||
- **Use CSS custom properties**: Leverage the theme system
|
||||
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
|
||||
- Spacing scale: `--ha-space-1` (4px) through `--ha-space-20` (80px) in 4px increments
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
48
.agents/skills/view-transitions/SKILL.md
Normal file
48
.agents/skills/view-transitions/SKILL.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: view-transitions
|
||||
description: Implement View Transitions API patterns. Use when adding withViewTransition(), view-transition-name, fallback behavior, and transition constraints.
|
||||
---
|
||||
|
||||
### View Transitions
|
||||
|
||||
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
|
||||
|
||||
**Core Resources:**
|
||||
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
**Default Root Transition:**
|
||||
|
||||
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
|
||||
|
||||
**Important Constraints:**
|
||||
|
||||
- Each `view-transition-name` must be unique at any given time
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Current Usage & Planned Applications:**
|
||||
|
||||
- Launch screen fade out (implemented)
|
||||
- Automation sidebar transitions (planned - #27238)
|
||||
- More info dialog content changes (planned - #27672)
|
||||
- Toolbar navigation, ha-spinner transitions (planned)
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
|
||||
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
|
||||
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
|
||||
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
|
||||
381
AGENTS.md
Normal file
381
AGENTS.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Home Assistant frontend project guide
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
|
||||
|
||||
Specialized implementation guidance has been moved to `.agents/skills/`, including dialogs, styling tokens, view transitions, form, alert, tooltip, keyboard shortcuts, SubscribeMixin panel patterns, and card creation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Common Patterns](#common-patterns)
|
||||
- [Text and Copy Guidelines](#text-and-copy-guidelines)
|
||||
- [Development Workflow](#development-workflow)
|
||||
- [Review Guidelines](#review-guidelines)
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Essential Commands
|
||||
|
||||
```bash
|
||||
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
script/develop # Development server
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
|
||||
### Component Prefixes
|
||||
|
||||
- `ha-` - Home Assistant components
|
||||
- `hui-` - Lovelace UI components
|
||||
- `dialog-` - Dialog components
|
||||
|
||||
### Import Patterns
|
||||
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
|
||||
The Home Assistant frontend is a modern web application that:
|
||||
|
||||
- Uses Web Components (custom elements) built with Lit framework
|
||||
- Is written entirely in TypeScript with strict type checking
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
|
||||
**Naming Conventions**
|
||||
|
||||
- PascalCase for types and classes
|
||||
- camelCase for variables, methods
|
||||
- Private methods require leading underscore
|
||||
- Public methods forbid leading underscore
|
||||
|
||||
### TypeScript Usage
|
||||
|
||||
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
|
||||
- **Proper type imports**: Use `import type` for type-only imports
|
||||
- **Define interfaces**: Create proper interfaces for data structures
|
||||
- **Type component properties**: All Lit properties must be properly typed
|
||||
- **No unused variables**: Prefix with `_` if intentionally unused
|
||||
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface EntityConfig {
|
||||
entity: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
@property({ type: Object })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
// Bad
|
||||
@property()
|
||||
hass: any;
|
||||
```
|
||||
|
||||
### Web Components with Lit
|
||||
|
||||
- **Use Lit 3.x patterns**: Follow modern Lit practices
|
||||
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
|
||||
- **Define custom element names**: Use `ha-` prefix for components
|
||||
|
||||
```typescript
|
||||
@customElement("ha-my-component")
|
||||
export class HaMyComponent extends LitElement {
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _config?: MyComponentConfig;
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<div>Content</div>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Guidelines
|
||||
|
||||
- **Use composition**: Prefer composition over inheritance
|
||||
- **Lazy load panels**: Heavy panels should be dynamically imported
|
||||
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
|
||||
- **Handle loading states**: Always show appropriate loading indicators
|
||||
- **Support themes**: Use CSS custom properties from theme
|
||||
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Cache appropriately**: Use collections and caching for frequently accessed data
|
||||
- **Handle errors gracefully**: All API calls should have error handling
|
||||
- **Update real-time**: Subscribe to state changes for live updates
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
try {
|
||||
const result = await fetchEntityRegistry(this.hass.connection);
|
||||
this._processResult(result);
|
||||
} catch (err) {
|
||||
showAlertDialog(this, {
|
||||
text: `Failed to load: ${err.message}`,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
- **Code split**: Split code at the panel/dialog level
|
||||
- **Lazy load**: Use dynamic imports for heavy components
|
||||
- **Optimize bundle**: Keep initial bundle size minimal
|
||||
- **Use virtual scrolling**: For long lists, implement virtual scrolling
|
||||
- **Memoize computations**: Cache expensive calculations
|
||||
|
||||
### Testing Requirements
|
||||
|
||||
- **Write tests**: Add tests for data processing and utilities
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Internationalization
|
||||
|
||||
- **Use localize**: Always use the localization system
|
||||
- **Add translation keys**: Add keys to src/translations/en.json
|
||||
- **Support placeholders**: Use proper placeholder syntax
|
||||
|
||||
```typescript
|
||||
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
count: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
- **ARIA labels**: Add appropriate ARIA labels
|
||||
- **Keyboard navigation**: Ensure all interactions work with keyboard
|
||||
- **Screen reader support**: Test with screen readers
|
||||
- **Color contrast**: Meet WCAG AA standards
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup and Commands
|
||||
|
||||
1. **Setup**: `script/setup` - Install dependencies
|
||||
2. **Develop**: `script/develop` - Development server
|
||||
3. **Lint**: `yarn lint` - Run all linting before committing
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't use `querySelector` - Use refs or component properties
|
||||
- Don't manipulate DOM directly - Let Lit handle rendering
|
||||
- Don't use global styles - Scope styles to components
|
||||
- Don't block the main thread - Use web workers for heavy computation
|
||||
- Don't ignore TypeScript errors - Fix all type issues
|
||||
|
||||
### Security Best Practices
|
||||
|
||||
- Sanitize HTML - Never use `unsafeHTML` with user content
|
||||
- Validate inputs - Always validate user inputs
|
||||
- Use HTTPS - All external resources must use HTTPS
|
||||
- CSP compliance - Ensure code works with Content Security Policy
|
||||
|
||||
### Text and Copy Guidelines
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
|
||||
|
||||
- **Use "Remove"** for actions that can be restored or reapplied:
|
||||
- Removing a user's permission
|
||||
- Removing a user from a group
|
||||
- Removing links between items
|
||||
- Removing a widget from dashboard
|
||||
- Removing an item from a cart
|
||||
- **Use "Delete"** for permanent, non-recoverable actions:
|
||||
- Deleting a field
|
||||
- Deleting a value in a field
|
||||
- Deleting a task
|
||||
- Deleting a group
|
||||
- Deleting a permission
|
||||
- Deleting a calendar event
|
||||
|
||||
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
|
||||
|
||||
- **Use "Add"** for already-existing items:
|
||||
- Adding a permission to a user
|
||||
- Adding a user to a group
|
||||
- Adding links between items
|
||||
- Adding a widget to dashboard
|
||||
- Adding an item to a cart
|
||||
- **Use "Create"** for something made from scratch:
|
||||
- Creating a new field
|
||||
- Creating a new task
|
||||
- Creating a new group
|
||||
- Creating a new permission
|
||||
- Creating a new calendar event
|
||||
|
||||
#### Writing Style (Consistent with Home Assistant Documentation)
|
||||
|
||||
- **Use American English**: Standard spelling and terminology
|
||||
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
|
||||
- **Address users directly**: Use "you" and "your"
|
||||
- **Be inclusive**: Objective, non-discriminatory language
|
||||
- **Be concise**: Use clear, direct language
|
||||
- **Be consistent**: Follow established terminology patterns
|
||||
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
|
||||
- **Avoid jargon**: Use terms familiar to home automation users
|
||||
|
||||
#### Language Standards
|
||||
|
||||
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
|
||||
- **Avoid abbreviations**: Spell out terms when possible
|
||||
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
|
||||
- ✅ "Create new automation"
|
||||
- ❌ "Create New Automation"
|
||||
- ✅ "Device settings"
|
||||
- ❌ "Device Settings"
|
||||
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
|
||||
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
|
||||
- **Avoid CAPS for emphasis**: Use bold or italics instead
|
||||
- **Write for all skill levels**: Both technical and non-technical users
|
||||
|
||||
#### Key Terminology
|
||||
|
||||
- **"integration"** (preferred over "component")
|
||||
- **Technical terms**: Use lowercase (automation, entity, device, service)
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
- **Add translation keys**: All user-facing text must be translatable
|
||||
- **Use placeholders**: Support dynamic content in translations
|
||||
- **Keep context**: Provide enough context for translators
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
name: automation.alias,
|
||||
});
|
||||
|
||||
// Bad - hardcoded text
|
||||
("Are you sure you want to delete this automation?");
|
||||
```
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Loading states**: Show clear progress indicators during async operations
|
||||
- **Error handling**: Display meaningful error messages when operations fail
|
||||
- **Mobile responsiveness**: Ensure components work well on small screens
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **Dialog width constraints**: Respect minimum and maximum width requirements
|
||||
- **Interview progress**: Show clear progress for multi-step operations
|
||||
- **State persistence**: Handle dialog state properly during background operations
|
||||
- **Cancel behavior**: Ensure cancel/close buttons work consistently
|
||||
- **Form prefilling**: Use smart defaults but allow user override
|
||||
|
||||
#### Component Design Patterns
|
||||
|
||||
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
|
||||
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
|
||||
- **Grid alignment**: Components should align to the design grid system
|
||||
- **Badge placement**: Position badges and indicators consistently
|
||||
- **Color theming**: Respect theme variables and design system colors
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Import organization**: Remove unused imports and use proper type imports
|
||||
- **Event handling**: Properly subscribe and unsubscribe from events
|
||||
- **Memory leaks**: Clean up subscriptions and event listeners
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
- **Optional parameters**: Make configuration fields optional when sensible
|
||||
- **Smart defaults**: Provide reasonable default values
|
||||
- **Future extensibility**: Design APIs that can be extended later
|
||||
- **Validation**: Validate configuration before applying changes
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### Core Requirements Checklist
|
||||
|
||||
- [ ] TypeScript strict mode passes (`yarn lint:types`)
|
||||
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
|
||||
- [ ] Prettier formatting applied (`yarn lint:prettier`)
|
||||
- [ ] Lit analyzer passes (`yarn lint:lit`)
|
||||
- [ ] Component follows Lit best practices
|
||||
- [ ] Proper error handling implemented
|
||||
- [ ] Loading states handled
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Theme variables used
|
||||
- [ ] Translations added
|
||||
- [ ] Accessible to screen readers
|
||||
- [ ] Tests added (where applicable)
|
||||
- [ ] No console statements (use proper logging)
|
||||
- [ ] Unused imports removed
|
||||
- [ ] Proper naming conventions
|
||||
|
||||
### Text and Copy Checklist
|
||||
|
||||
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
|
||||
- [ ] Localization keys added for all user-facing text
|
||||
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
|
||||
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
|
||||
- [ ] American English spelling
|
||||
- [ ] Friendly, informational tone
|
||||
- [ ] Avoids abbreviations and jargon
|
||||
- [ ] Correct terminology (integration not component)
|
||||
|
||||
### Component-Specific Checks
|
||||
|
||||
- [ ] Dialogs implement HassDialog interface
|
||||
- [ ] Dialog styling uses haStyleDialog
|
||||
- [ ] Dialog accessibility
|
||||
- [ ] ha-alert used correctly for messages
|
||||
- [ ] ha-form uses proper schema structure
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event subscriptions properly cleaned up
|
||||
@@ -31,7 +31,4 @@ module.exports = {
|
||||
isDevContainer() {
|
||||
return isTrue(process.env.DEV_CONTAINER);
|
||||
},
|
||||
jsMinifier() {
|
||||
return (process.env.JS_MINIFIER || "swc").toLowerCase();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -80,13 +80,7 @@ const doneHandler = (done) => (err, stats) => {
|
||||
console.log(stats.toString("minimal"));
|
||||
}
|
||||
|
||||
const durationMs =
|
||||
stats?.startTime && stats?.endTime ? stats.endTime - stats.startTime : 0;
|
||||
const durationLabel = durationMs
|
||||
? ` (${(durationMs / 1000).toFixed(1)}s, minifier: ${env.jsMinifier()})`
|
||||
: ` (minifier: ${env.jsMinifier()})`;
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}${durationLabel}`);
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
|
||||
@@ -13,7 +13,6 @@ const { WebpackManifestPlugin } = require("rspack-manifest-plugin");
|
||||
const log = require("fancy-log");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const WebpackBar = require("webpackbar/rspack");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
@@ -101,20 +100,11 @@ const createRspackConfig = ({
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
env.jsMinifier() === "terser"
|
||||
? new TerserPlugin({
|
||||
parallel: true,
|
||||
extractComments: true,
|
||||
terserOptions: bundle.terserOptions({ latestBuild, isTestBuild }),
|
||||
})
|
||||
: new rspack.SwcJsMinimizerRspackPlugin({
|
||||
extractComments: true,
|
||||
minimizerOptions: {
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
},
|
||||
}),
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
extractComments: true,
|
||||
terserOptions: bundle.terserOptions({ latestBuild, isTestBuild }),
|
||||
}),
|
||||
],
|
||||
moduleIds: isProdBuild && !isStatsBuild ? "deterministic" : "named",
|
||||
chunkIds: isProdBuild && !isStatsBuild ? "deterministic" : "named",
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_ROOT="$ROOT_DIR/hass_frontend"
|
||||
OUT_LATEST="$OUT_ROOT/frontend_latest"
|
||||
OUT_ES5="$OUT_ROOT/frontend_es5"
|
||||
|
||||
bytes_dir() {
|
||||
if [ -d "$1" ]; then
|
||||
du -sb "$1" | cut -f1
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
}
|
||||
|
||||
run_build() {
|
||||
minifier="$1"
|
||||
printf "\n==> Building with %s\n" "$minifier"
|
||||
start_time=$(date +%s)
|
||||
JS_MINIFIER="$minifier" "$ROOT_DIR/script/build_frontend"
|
||||
end_time=$(date +%s)
|
||||
duration=$((end_time - start_time))
|
||||
|
||||
latest_size=$(bytes_dir "$OUT_LATEST")
|
||||
es5_size=$(bytes_dir "$OUT_ES5")
|
||||
total_size=$(bytes_dir "$OUT_ROOT")
|
||||
|
||||
printf "%s|%s|%s|%s\n" "$minifier" "$duration" "$latest_size" "$es5_size" >> "$ROOT_DIR/temp/minifier_benchmark.tsv"
|
||||
printf " duration: %ss\n" "$duration"
|
||||
printf " frontend_latest: %s bytes\n" "$latest_size"
|
||||
printf " frontend_es5: %s bytes\n" "$es5_size"
|
||||
printf " hass_frontend: %s bytes\n" "$total_size"
|
||||
}
|
||||
|
||||
mkdir -p "$ROOT_DIR/temp"
|
||||
rm -f "$ROOT_DIR/temp/minifier_benchmark.tsv"
|
||||
|
||||
run_build swc
|
||||
run_build terser
|
||||
|
||||
printf "\n==> Summary (minifier | seconds | latest bytes | es5 bytes)\n"
|
||||
cat "$ROOT_DIR/temp/minifier_benchmark.tsv"
|
||||
@@ -1,28 +0,0 @@
|
||||
const SI_PREFIX_MULTIPLIERS: Record<string, number> = {
|
||||
T: 1e12,
|
||||
G: 1e9,
|
||||
M: 1e6,
|
||||
k: 1e3,
|
||||
m: 1e-3,
|
||||
"\u00B5": 1e-6, // µ (micro sign)
|
||||
"\u03BC": 1e-6, // μ (greek small letter mu)
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize a numeric value by detecting SI unit prefixes (T, G, M, k, m, µ).
|
||||
* Only applies when the unit is longer than 1 character and starts with a
|
||||
* recognized prefix, avoiding false positives on standalone units like "m" (meters).
|
||||
*/
|
||||
export const normalizeValueBySIPrefix = (
|
||||
value: number,
|
||||
unit: string | undefined
|
||||
): number => {
|
||||
if (!unit || unit.length <= 1) {
|
||||
return value;
|
||||
}
|
||||
const prefix = unit[0];
|
||||
if (prefix in SI_PREFIX_MULTIPLIERS) {
|
||||
return value * SI_PREFIX_MULTIPLIERS[prefix];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -89,7 +89,7 @@ export class HaControlSelectMenu extends LitElement {
|
||||
private _renderOption = (option: SelectOption) =>
|
||||
html`<ha-dropdown-item
|
||||
.value=${option.value}
|
||||
.selected=${this.value === option.value}
|
||||
class=${this.value === option.value ? "selected" : ""}
|
||||
>${option.iconPath
|
||||
? html`<ha-svg-icon slot="icon" .path=${option.iconPath}></ha-svg-icon>`
|
||||
: option.icon
|
||||
@@ -263,6 +263,15 @@ export class HaControlSelectMenu extends LitElement {
|
||||
cursor: not-allowed;
|
||||
color: var(--disabled-color);
|
||||
}
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
ha-dropdown-item.selected:hover {
|
||||
background-color: var(--ha-color-fill-primary-quiet-hover);
|
||||
}
|
||||
|
||||
ha-dropdown::part(menu) {
|
||||
min-width: var(--control-select-menu-width);
|
||||
|
||||
@@ -64,8 +64,7 @@ export class HaDialogDatePicker extends LitElement {
|
||||
@datepicker-value-updated=${this._valueChanged}
|
||||
.firstDayOfWeek=${this._params.firstWeekday}
|
||||
></app-datepicker>
|
||||
|
||||
<div class="bottom-actions">
|
||||
<ha-dialog-footer slot="footer">
|
||||
${this._params.canClear
|
||||
? html`<ha-button
|
||||
slot="secondaryAction"
|
||||
@@ -83,9 +82,6 @@ export class HaDialogDatePicker extends LitElement {
|
||||
>
|
||||
${this.hass.localize("ui.dialogs.date-picker.today")}
|
||||
</ha-button>
|
||||
</div>
|
||||
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@@ -130,14 +126,6 @@ export class HaDialogDatePicker extends LitElement {
|
||||
ha-wa-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
.bottom-actions {
|
||||
display: flex;
|
||||
gap: var(--ha-space-4);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
app-datepicker {
|
||||
display: block;
|
||||
margin-inline: auto;
|
||||
|
||||
@@ -2,7 +2,7 @@ import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-it
|
||||
import "@home-assistant/webawesome/dist/components/icon/icon";
|
||||
import { mdiCheckboxBlankOutline, mdiCheckboxMarked } from "@mdi/js";
|
||||
import { css, type CSSResultGroup, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
@@ -17,8 +17,6 @@ import "./ha-svg-icon";
|
||||
*/
|
||||
@customElement("ha-dropdown-item")
|
||||
export class HaDropdownItem extends DropdownItem {
|
||||
@property({ type: Boolean, reflect: true }) selected = false;
|
||||
|
||||
protected renderCheckboxIcon() {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
@@ -49,13 +47,6 @@ export class HaDropdownItem extends DropdownItem {
|
||||
:host([variant="danger"]) #icon ::slotted(*) {
|
||||
color: var(--ha-color-on-danger-quiet);
|
||||
}
|
||||
|
||||
:host([selected]) {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -135,7 +135,9 @@ class HaQrScanner extends LitElement {
|
||||
(camera) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${camera.id}
|
||||
.selected=${this._selectedCamera === camera.id}
|
||||
class=${this._selectedCamera === camera.id
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${camera.label}
|
||||
</ha-dropdown-item>
|
||||
@@ -378,6 +380,9 @@ class HaQrScanner extends LitElement {
|
||||
color: white;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
}
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -94,8 +94,10 @@ export class HaSelect extends LitElement {
|
||||
.disabled=${typeof option === "string"
|
||||
? false
|
||||
: (option.disabled ?? false)}
|
||||
.selected=${this.value ===
|
||||
(typeof option === "string" ? option : option.value)}
|
||||
class=${this.value ===
|
||||
(typeof option === "string" ? option : option.value)
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${option.iconPath
|
||||
? html`<ha-svg-icon
|
||||
@@ -180,6 +182,10 @@ export class HaSelect extends LitElement {
|
||||
ha-picker-field.opened {
|
||||
--mdc-text-field-idle-line-color: var(--primary-color);
|
||||
}
|
||||
ha-dropdown-item.selected:hover {
|
||||
background-color: var(--ha-color-fill-primary-quiet-hover);
|
||||
}
|
||||
|
||||
ha-dropdown-item .content {
|
||||
display: flex;
|
||||
gap: var(--ha-space-1);
|
||||
@@ -194,6 +200,14 @@ export class HaSelect extends LitElement {
|
||||
ha-dropdown::part(menu) {
|
||||
min-width: var(--select-menu-width);
|
||||
}
|
||||
|
||||
:host ::slotted(ha-dropdown-item.selected),
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
declare global {
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
import type { Collection, HassEntity } from "home-assistant-js-websocket";
|
||||
import { getCollection } from "home-assistant-js-websocket";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import {
|
||||
calcDate,
|
||||
calcDateProperty,
|
||||
@@ -1432,10 +1431,26 @@ export const getPowerFromState = (stateObj: HassEntity): number | undefined => {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalizeValueBySIPrefix(
|
||||
value,
|
||||
stateObj.attributes.unit_of_measurement
|
||||
);
|
||||
// Normalize to watts (W) based on unit of measurement (case-sensitive)
|
||||
// Supported units: GW, kW, MW, mW, TW, W
|
||||
const unit = stateObj.attributes.unit_of_measurement;
|
||||
switch (unit) {
|
||||
case "W":
|
||||
return value;
|
||||
case "kW":
|
||||
return value * 1000;
|
||||
case "mW":
|
||||
return value / 1000;
|
||||
case "MW":
|
||||
return value * 1_000_000;
|
||||
case "GW":
|
||||
return value * 1_000_000_000;
|
||||
case "TW":
|
||||
return value * 1_000_000_000_000;
|
||||
default:
|
||||
// Assume value is in watts (W) if no unit or an unsupported unit is provided
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,16 +41,12 @@ export const enum TodoListEntityFeature {
|
||||
SET_DESCRIPTION_ON_ITEM = 64,
|
||||
}
|
||||
|
||||
export const getTodoLists = (
|
||||
hass: HomeAssistant,
|
||||
includeHidden = true
|
||||
): TodoList[] =>
|
||||
export const getTodoLists = (hass: HomeAssistant): TodoList[] =>
|
||||
Object.keys(hass.states)
|
||||
.filter(
|
||||
(entityId) =>
|
||||
computeDomain(entityId) === "todo" &&
|
||||
!isUnavailableState(hass.states[entityId].state) &&
|
||||
(includeHidden || hass.entities[entityId]?.hidden !== true)
|
||||
!isUnavailableState(hass.states[entityId].state)
|
||||
)
|
||||
.map((entityId) => ({
|
||||
...hass.states[entityId],
|
||||
|
||||
@@ -213,7 +213,9 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
(source) =>
|
||||
html`<ha-dropdown-item
|
||||
.value=${source}
|
||||
.selected=${source === this.stateObj?.attributes.source}
|
||||
class=${source === this.stateObj?.attributes.source
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${this.hass.formatEntityAttributeValue(
|
||||
this.stateObj!,
|
||||
@@ -248,7 +250,9 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
(soundMode) =>
|
||||
html`<ha-dropdown-item
|
||||
.value=${soundMode}
|
||||
.selected=${soundMode === this.stateObj?.attributes.sound_mode}
|
||||
class=${soundMode === this.stateObj?.attributes.sound_mode
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${this.hass.formatEntityAttributeValue(
|
||||
this.stateObj!,
|
||||
@@ -674,6 +678,13 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
align-self: center;
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
private _handleClick(e: MouseEvent): void {
|
||||
|
||||
@@ -196,7 +196,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
(lang) =>
|
||||
html`<ha-dropdown-item
|
||||
.value=${lang.id}
|
||||
.selected=${this._language === lang.id}
|
||||
class=${this._language === lang.id ? "selected" : ""}
|
||||
>
|
||||
${lang.primary}
|
||||
</ha-dropdown-item>`
|
||||
@@ -407,6 +407,13 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
|
||||
margin-inline-end: 12px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
ha-dropdown-item.selected {
|
||||
border: 1px solid var(--primary-color);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export class HaConditionAction
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dropdown-item .value=${opt} .selected=${selected}>
|
||||
<ha-dropdown-item .value=${opt} class=${selected ? "selected" : ""}>
|
||||
<ha-condition-icon
|
||||
.hass=${this.hass}
|
||||
slot="icon"
|
||||
|
||||
@@ -4,8 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-wa-dialog";
|
||||
import { createCloseHeading } from "../../../../components/ha-dialog";
|
||||
import "../../../../components/ha-form/ha-form";
|
||||
import type {
|
||||
HaFormSchema,
|
||||
@@ -37,20 +36,13 @@ class LocalBackupLocationDialog extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public async showDialog(
|
||||
dialogParams: LocalBackupLocationDialogParams
|
||||
): Promise<void> {
|
||||
this._dialogParams = dialogParams;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._data = undefined;
|
||||
this._error = undefined;
|
||||
this._waiting = undefined;
|
||||
@@ -63,13 +55,17 @@ class LocalBackupLocationDialog extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
`ui.panel.config.backup.dialogs.local_backup_location.title`
|
||||
<ha-dialog
|
||||
open
|
||||
scrimClickAction
|
||||
escapeKeyAction
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize(
|
||||
`ui.panel.config.backup.dialogs.local_backup_location.title`
|
||||
)
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
@closed=${this.closeDialog}
|
||||
>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
@@ -81,35 +77,34 @@ class LocalBackupLocationDialog extends LitElement {
|
||||
)}
|
||||
</p>
|
||||
<ha-form
|
||||
autofocus
|
||||
.hass=${this.hass}
|
||||
.data=${this._data}
|
||||
.schema=${SCHEMA}
|
||||
.computeLabel=${this._computeLabelCallback}
|
||||
@value-changed=${this._valueChanged}
|
||||
dialogInitialFocus
|
||||
></ha-form>
|
||||
<ha-alert alert-type="info">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.backup.dialogs.local_backup_location.note`
|
||||
)}
|
||||
</ha-alert>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
.disabled=${this._waiting || !this._data}
|
||||
slot="primaryAction"
|
||||
@click=${this._changeMount}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
dialogInitialFocus
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
.disabled=${this._waiting || !this._data}
|
||||
slot="primaryAction"
|
||||
@click=${this._changeMount}
|
||||
>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -148,6 +143,9 @@ class LocalBackupLocationDialog extends LitElement {
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 500px;
|
||||
}
|
||||
ha-form {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -6,19 +6,17 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-code-editor";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-header";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-expansion-panel";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-textfield";
|
||||
import "../../../components/ha-wa-dialog";
|
||||
import type { HaTextField } from "../../../components/ha-textfield";
|
||||
import type { BlueprintImportResult } from "../../../data/blueprint";
|
||||
import { importBlueprint, saveBlueprint } from "../../../data/blueprint";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { withViewTransition } from "../../../common/util/view-transition";
|
||||
|
||||
@customElement("ha-dialog-import-blueprint")
|
||||
class DialogImportBlueprint extends LitElement {
|
||||
@@ -28,8 +26,6 @@ class DialogImportBlueprint extends LitElement {
|
||||
|
||||
@state() private _params?;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _importing = false;
|
||||
|
||||
@state() private _saving = false;
|
||||
@@ -47,14 +43,9 @@ class DialogImportBlueprint extends LitElement {
|
||||
this._error = undefined;
|
||||
this._url = this._params.url;
|
||||
this.large = false;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._error = undefined;
|
||||
this._result = undefined;
|
||||
this._params = undefined;
|
||||
@@ -68,16 +59,11 @@ class DialogImportBlueprint extends LitElement {
|
||||
}
|
||||
const heading = this.hass.localize("ui.panel.config.blueprint.add.header");
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
width=${this.large ? "full" : "medium"}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<ha-dialog-header slot="header">
|
||||
<ha-dialog open .heading=${heading} @closed=${this.closeDialog}>
|
||||
<ha-dialog-header slot="heading">
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this.closeDialog}
|
||||
dialogAction="cancel"
|
||||
.label=${this.hass.localize("ui.common.close")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
@@ -118,7 +104,6 @@ class DialogImportBlueprint extends LitElement {
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.file_name"
|
||||
)}
|
||||
autofocus
|
||||
></ha-textfield>
|
||||
`}
|
||||
<ha-expansion-panel
|
||||
@@ -172,63 +157,59 @@ class DialogImportBlueprint extends LitElement {
|
||||
"ui.panel.config.blueprint.add.url"
|
||||
)}
|
||||
.value=${this._url || ""}
|
||||
autofocus
|
||||
dialogInitialFocus
|
||||
></ha-textfield>
|
||||
`}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._saving}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${!this._result
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._import}
|
||||
.disabled=${this._importing}
|
||||
.loading=${this._importing}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._importing ? "importing" : "import_btn"}`
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.import_btn"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._saving || !!this._result.validation_errors}
|
||||
.loading=${this._saving}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._saving ? "saving" : this._result.exists ? "save_btn_override" : "save_btn"}`
|
||||
)}
|
||||
>
|
||||
${this._result.exists
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.save_btn_override"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.save_btn"
|
||||
)}
|
||||
</ha-button>
|
||||
`}
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
slot="secondaryAction"
|
||||
@click=${this.closeDialog}
|
||||
.disabled=${this._saving}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
${!this._result
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._import}
|
||||
.disabled=${this._importing}
|
||||
.loading=${this._importing}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._importing ? "importing" : "import_btn"}`
|
||||
)}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.import_btn"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._save}
|
||||
.disabled=${this._saving || !!this._result.validation_errors}
|
||||
.loading=${this._saving}
|
||||
.ariaLabel=${this.hass.localize(
|
||||
`ui.panel.config.blueprint.add.${this._saving ? "saving" : this._result.exists ? "save_btn_override" : "save_btn"}`
|
||||
)}
|
||||
>
|
||||
${this._result.exists
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.save_btn_override"
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.blueprint.add.save_btn"
|
||||
)}
|
||||
</ha-button>
|
||||
`}
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private _enlarge() {
|
||||
withViewTransition(() => {
|
||||
this.large = !this.large;
|
||||
});
|
||||
this.large = !this.large;
|
||||
}
|
||||
|
||||
private async _import() {
|
||||
@@ -292,6 +273,10 @@ class DialogImportBlueprint extends LitElement {
|
||||
a ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([large]) ha-dialog {
|
||||
--mdc-dialog-min-width: 90vw;
|
||||
--mdc-dialog-max-width: 90vw;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--expansion-panel-content-padding: 0px;
|
||||
}
|
||||
|
||||
@@ -87,9 +87,7 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
description = this._storageInfo
|
||||
? this.hass.localize("ui.panel.config.storage.description", {
|
||||
percent_used: `${Math.round(
|
||||
((this._storageInfo.total - this._storageInfo.free) /
|
||||
this._storageInfo.total) *
|
||||
100
|
||||
(this._storageInfo.used / this._storageInfo.total) * 100
|
||||
)}${blankBeforePercent(this.hass.locale)}%`,
|
||||
free_space: `${this._storageInfo.free} GB`,
|
||||
})
|
||||
|
||||
@@ -282,7 +282,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
? html`
|
||||
<ha-dropdown-item
|
||||
.value=${id}
|
||||
.selected=${id === this._sortColumn}
|
||||
class=${id === this._sortColumn ? "selected" : ""}
|
||||
>
|
||||
${this._sortColumn === id
|
||||
? html`
|
||||
@@ -324,7 +324,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
? html`
|
||||
<ha-dropdown-item
|
||||
.value=${id}
|
||||
.selected=${id === this._groupColumn}
|
||||
class=${id === this._groupColumn ? "selected" : ""}
|
||||
>
|
||||
${column.title || column.label}
|
||||
</ha-dropdown-item>
|
||||
@@ -333,7 +333,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
)}
|
||||
<ha-dropdown-item
|
||||
value="none"
|
||||
.selected=${this._groupColumn === undefined}
|
||||
class=${this._groupColumn === undefined ? "selected" : ""}
|
||||
>
|
||||
${localize("ui.components.subpage-data-table.dont_group_by")}
|
||||
</ha-dropdown-item>
|
||||
@@ -805,6 +805,16 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
|
||||
ha-dropdown ha-assist-chip {
|
||||
--md-assist-chip-trailing-space: 8px;
|
||||
}
|
||||
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
ha-dropdown-item.selected:hover {
|
||||
background-color: var(--ha-color-fill-primary-quiet-hover);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ import "../../../components/ha-labels-picker";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-radio";
|
||||
import "../../../components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../components/ha-select";
|
||||
import "../../../components/ha-settings-row";
|
||||
import "../../../components/ha-state-icon";
|
||||
import "../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import "../../../components/ha-textfield";
|
||||
import type { HaSelectSelectEvent } from "../../../components/ha-select";
|
||||
import {
|
||||
CAMERA_ORIENTATIONS,
|
||||
CAMERA_SUPPORT_STREAM,
|
||||
@@ -434,15 +434,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
>
|
||||
<ha-dropdown-item
|
||||
value="switch"
|
||||
.selected=${this._switchAsDomain === "switch" &&
|
||||
(!this._deviceClass || this._deviceClass === "switch")}
|
||||
class=${this._switchAsDomain === "switch" &&
|
||||
(!this._deviceClass || this._deviceClass === "switch")
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${domainToName(this.hass.localize, "switch")}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item
|
||||
value="outlet"
|
||||
.selected=${this._switchAsDomain === "switch" &&
|
||||
this._deviceClass === "outlet"}
|
||||
class=${this._switchAsDomain === "switch" &&
|
||||
this._deviceClass === "outlet"
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.entity_registry.editor.device_classes.switch.outlet"
|
||||
@@ -456,7 +460,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
(entry) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${entry.domain}
|
||||
.selected=${this._switchAsDomain === entry.domain}
|
||||
class=${this._switchAsDomain === entry.domain
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${entry.label}
|
||||
</ha-dropdown-item>
|
||||
@@ -473,13 +479,13 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
>
|
||||
<ha-dropdown-item
|
||||
value="switch"
|
||||
.selected=${this._switchAsDomain === "switch"}
|
||||
class=${this._switchAsDomain === "switch" ? "selected" : ""}
|
||||
>
|
||||
${domainToName(this.hass.localize, "switch")}
|
||||
</ha-dropdown-item>
|
||||
<ha-dropdown-item
|
||||
.value=${domain}
|
||||
.selected=${this._switchAsDomain === domain}
|
||||
class=${this._switchAsDomain === domain ? "selected" : ""}
|
||||
>
|
||||
${domainToName(this.hass.localize, domain)}
|
||||
</ha-dropdown-item>
|
||||
@@ -493,7 +499,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
: html`
|
||||
<ha-dropdown-item
|
||||
.value=${entry.domain}
|
||||
.selected=${this._switchAsDomain === entry.domain}
|
||||
class=${this._switchAsDomain === entry.domain
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${entry.label}
|
||||
</ha-dropdown-item>
|
||||
@@ -543,7 +551,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
(entry) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${entry.deviceClass}
|
||||
.selected=${entry.deviceClass === this._deviceClass}
|
||||
class=${entry.deviceClass === this._deviceClass
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${entry.label}
|
||||
</ha-dropdown-item>
|
||||
@@ -561,7 +571,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
|
||||
(entry) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${entry.deviceClass}
|
||||
.selected=${entry.deviceClass === this._deviceClass}
|
||||
class=${entry.deviceClass === this._deviceClass
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
${entry.label}
|
||||
</ha-dropdown-item>
|
||||
|
||||
@@ -169,7 +169,7 @@ class ErrorLogCard extends LitElement {
|
||||
(boot) => html`
|
||||
<ha-dropdown-item
|
||||
.value=${`boot_${boot}`}
|
||||
.selected=${boot === this._boot}
|
||||
class=${boot === this._boot ? "selected" : ""}
|
||||
>
|
||||
${boot === 0
|
||||
? localize("ui.panel.config.logs.current")
|
||||
@@ -846,6 +846,12 @@ class ErrorLogCard extends LitElement {
|
||||
.download-link {
|
||||
color: var(--text-color);
|
||||
}
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -105,11 +105,9 @@ export class StorageBreakdownChart extends LitElement {
|
||||
storageInfo: HostDisksUsage | null | undefined
|
||||
) => {
|
||||
let totalSpaceGB = hostInfo.disk_total;
|
||||
let usedSpaceGB = hostInfo.disk_used;
|
||||
let freeSpaceGB =
|
||||
hostInfo.disk_free || hostInfo.disk_total - hostInfo.disk_used;
|
||||
// hostInfo.disk_used doesn't include system reserved space,
|
||||
// so we calculate used space based on total and free space
|
||||
let usedSpaceGB = totalSpaceGB - freeSpaceGB;
|
||||
|
||||
if (storageInfo) {
|
||||
const totalSpace =
|
||||
|
||||
@@ -9,7 +9,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { getGraphColorByIndex } from "../../../common/color/colors";
|
||||
import { computeCssColor } from "../../../common/color/compute-color";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { normalizeValueBySIPrefix } from "../../../common/number/normalize-by-si-prefix";
|
||||
import { MobileAwareMixin } from "../../../mixins/mobile-aware-mixin";
|
||||
import type { EntityNameItem } from "../../../common/entity/compute_entity_name_display";
|
||||
import { computeLovelaceEntityName } from "../common/entity/compute-lovelace-entity-name";
|
||||
@@ -231,12 +230,8 @@ export class HuiDistributionCard
|
||||
const stateObj = this.hass!.states[entity.entity];
|
||||
if (!stateObj) return;
|
||||
|
||||
const rawValue = Number(stateObj.state);
|
||||
if (rawValue <= 0 || isNaN(rawValue)) return;
|
||||
const value = normalizeValueBySIPrefix(
|
||||
rawValue,
|
||||
stateObj.attributes.unit_of_measurement
|
||||
);
|
||||
const value = Number(stateObj.state);
|
||||
if (value <= 0 || isNaN(value)) return;
|
||||
|
||||
const color = entity.color
|
||||
? computeCssColor(entity.color)
|
||||
|
||||
@@ -246,7 +246,6 @@ export class HuiEntityEditor extends LitElement {
|
||||
}
|
||||
ha-md-list {
|
||||
gap: 8px;
|
||||
padding-top: 0;
|
||||
}
|
||||
ha-md-list-item {
|
||||
border: 1px solid var(--divider-color);
|
||||
|
||||
@@ -635,12 +635,8 @@ class HUIRoot extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleContainerScroll = () => {
|
||||
const viewRoot = this._viewRoot;
|
||||
this.toggleAttribute(
|
||||
"scrolled",
|
||||
viewRoot ? viewRoot.scrollTop !== 0 : false
|
||||
);
|
||||
private _handleWindowScroll = () => {
|
||||
this.toggleAttribute("scrolled", window.scrollY !== 0);
|
||||
};
|
||||
|
||||
private _locationChanged = () => {
|
||||
@@ -671,7 +667,7 @@ class HUIRoot extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._viewRoot?.addEventListener("scroll", this._handleContainerScroll, {
|
||||
window.addEventListener("scroll", this._handleWindowScroll, {
|
||||
passive: true,
|
||||
});
|
||||
this._handleUrlChanged();
|
||||
@@ -682,7 +678,7 @@ class HUIRoot extends LitElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._viewRoot?.addEventListener("scroll", this._handleContainerScroll, {
|
||||
window.addEventListener("scroll", this._handleWindowScroll, {
|
||||
passive: true,
|
||||
});
|
||||
window.addEventListener("popstate", this._handlePopState);
|
||||
@@ -693,14 +689,10 @@ class HUIRoot extends LitElement {
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._viewRoot?.removeEventListener("scroll", this._handleContainerScroll);
|
||||
window.removeEventListener("scroll", this._handleWindowScroll);
|
||||
window.removeEventListener("popstate", this._handlePopState);
|
||||
window.removeEventListener("location-changed", this._locationChanged);
|
||||
const viewRoot = this._viewRoot;
|
||||
this.toggleAttribute(
|
||||
"scrolled",
|
||||
viewRoot ? viewRoot.scrollTop !== 0 : false
|
||||
);
|
||||
this.toggleAttribute("scrolled", window.scrollY !== 0);
|
||||
// Re-enable history scroll restoration when leaving the page
|
||||
window.history.scrollRestoration = "auto";
|
||||
}
|
||||
@@ -833,12 +825,9 @@ class HUIRoot extends LitElement {
|
||||
(this._restoreScroll && this._viewScrollPositions[newSelectView]) ||
|
||||
0;
|
||||
this._restoreScroll = false;
|
||||
requestAnimationFrame(() => {
|
||||
const viewRoot = this._viewRoot;
|
||||
if (viewRoot) {
|
||||
viewRoot.scrollTo({ behavior: "auto", top: position });
|
||||
}
|
||||
});
|
||||
requestAnimationFrame(() =>
|
||||
scrollTo({ behavior: "auto", top: position })
|
||||
);
|
||||
}
|
||||
this._selectView(newSelectView, force);
|
||||
});
|
||||
@@ -1163,7 +1152,7 @@ class HUIRoot extends LitElement {
|
||||
const path = this.config.views[viewIndex].path || viewIndex;
|
||||
this._navigateToView(path);
|
||||
} else if (!this._editMode) {
|
||||
this._viewRoot?.scrollTo({ behavior: "smooth", top: 0 });
|
||||
scrollTo({ behavior: "smooth", top: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1174,8 +1163,7 @@ class HUIRoot extends LitElement {
|
||||
|
||||
// Save scroll position of current view
|
||||
if (this._curView != null) {
|
||||
const viewRoot = this._viewRoot;
|
||||
this._viewScrollPositions[this._curView] = viewRoot?.scrollTop ?? 0;
|
||||
this._viewScrollPositions[this._curView] = window.scrollY;
|
||||
}
|
||||
|
||||
viewIndex = viewIndex === undefined ? 0 : viewIndex;
|
||||
@@ -1481,14 +1469,9 @@ class HUIRoot extends LitElement {
|
||||
hui-view-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
height: calc(
|
||||
100vh - var(--header-height) - var(--safe-area-inset-top) - var(
|
||||
--view-container-padding-top,
|
||||
0px
|
||||
)
|
||||
);
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
margin-top: calc(
|
||||
padding-top: calc(
|
||||
var(--header-height) + var(--safe-area-inset-top) +
|
||||
var(--view-container-padding-top, 0px)
|
||||
);
|
||||
@@ -1511,12 +1494,7 @@ class HUIRoot extends LitElement {
|
||||
* In edit mode we have the tab bar on a new line *
|
||||
*/
|
||||
hui-view-container.has-tab-bar {
|
||||
height: calc(
|
||||
100vh - var(--header-height, 56px) - calc(
|
||||
var(--tab-bar-height, 56px) - 2px
|
||||
) - var(--safe-area-inset-top, 0px)
|
||||
);
|
||||
margin-top: calc(
|
||||
padding-top: calc(
|
||||
var(--header-height, 56px) +
|
||||
calc(var(--tab-bar-height, 56px) - 2px) +
|
||||
var(--safe-area-inset-top, 0px)
|
||||
|
||||
@@ -407,20 +407,12 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _getScrollContainer(): Element | null {
|
||||
// The scroll container is the hui-view-container parent
|
||||
return this.closest("hui-view-container");
|
||||
}
|
||||
|
||||
private _toggleView() {
|
||||
const scrollContainer = this._getScrollContainer();
|
||||
const scrollTop = scrollContainer?.scrollTop ?? 0;
|
||||
|
||||
// Save current scroll position
|
||||
if (this._sidebarTabActive) {
|
||||
this._sidebarScrollTop = scrollTop;
|
||||
this._sidebarScrollTop = window.scrollY;
|
||||
} else {
|
||||
this._contentScrollTop = scrollTop;
|
||||
this._contentScrollTop = window.scrollY;
|
||||
}
|
||||
|
||||
this._sidebarTabActive = !this._sidebarTabActive;
|
||||
@@ -436,7 +428,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
|
||||
const scrollY = this._sidebarTabActive
|
||||
? this._sidebarScrollTop
|
||||
: this._contentScrollTop;
|
||||
scrollContainer?.scrollTo(0, scrollY);
|
||||
window.scrollTo(0, scrollY);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import { listenMediaQuery } from "../../../common/dom/media_query";
|
||||
import type { LovelaceViewConfig } from "../../../data/lovelace/config/view";
|
||||
import { haStyleScrollbar } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
type BackgroundConfig = LovelaceViewConfig["background"];
|
||||
@@ -23,7 +22,6 @@ class HuiViewContainer extends LitElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.classList.add("ha-scrollbar");
|
||||
this._setUpMediaQuery();
|
||||
this._applyTheme();
|
||||
}
|
||||
@@ -76,16 +74,11 @@ class HuiViewContainer extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = css`
|
||||
:host {
|
||||
display: relative;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -413,7 +413,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
`
|
||||
}
|
||||
<ha-dropdown-item
|
||||
.selected=${isBrowser}
|
||||
class=${isBrowser ? "selected" : ""}
|
||||
.value=${BROWSER_PLAYER}
|
||||
>
|
||||
${this.hass.localize("ui.components.media-browser.web-browser")}
|
||||
@@ -421,7 +421,7 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
${this._mediaPlayerEntities.map(
|
||||
(source) => html`
|
||||
<ha-dropdown-item
|
||||
.selected=${source.entity_id === this.entityId}
|
||||
class=${source.entity_id === this.entityId ? "selected" : ""}
|
||||
.disabled=${source.state === UNAVAILABLE}
|
||||
.value=${source.entity_id}
|
||||
>
|
||||
@@ -840,6 +840,10 @@ export class BarMediaPlayer extends SubscribeMixin(LitElement) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class HaPickDashboardRow extends LitElement {
|
||||
>
|
||||
<ha-dropdown-item
|
||||
.value=${USE_SYSTEM_VALUE}
|
||||
.selected=${value === USE_SYSTEM_VALUE}
|
||||
class=${value === USE_SYSTEM_VALUE ? "selected" : ""}
|
||||
>
|
||||
${this.hass.localize("ui.panel.profile.dashboard.system")}
|
||||
</ha-dropdown-item>
|
||||
@@ -68,7 +68,7 @@ class HaPickDashboardRow extends LitElement {
|
||||
return html`
|
||||
<ha-dropdown-item
|
||||
value=${panelInfo.url_path}
|
||||
.selected=${value === panelInfo.url_path}
|
||||
class=${value === panelInfo.url_path ? "selected" : ""}
|
||||
>
|
||||
<ha-icon
|
||||
slot="icon"
|
||||
@@ -91,7 +91,9 @@ class HaPickDashboardRow extends LitElement {
|
||||
return html`
|
||||
<ha-dropdown-item
|
||||
.value=${dashboard.url_path}
|
||||
.selected=${value === dashboard.url_path}
|
||||
class=${value === dashboard.url_path
|
||||
? "selected"
|
||||
: ""}
|
||||
>
|
||||
<ha-icon
|
||||
slot="icon"
|
||||
|
||||
@@ -10,11 +10,10 @@ import "../../components/ha-alert";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-checkbox";
|
||||
import "../../components/ha-date-input";
|
||||
import "../../components/ha-dialog-footer";
|
||||
import { createCloseHeading } from "../../components/ha-dialog";
|
||||
import "../../components/ha-textarea";
|
||||
import "../../components/ha-textfield";
|
||||
import "../../components/ha-time-input";
|
||||
import "../../components/ha-wa-dialog";
|
||||
import {
|
||||
TodoItemStatus,
|
||||
TodoListEntityFeature,
|
||||
@@ -51,8 +50,6 @@ class DialogTodoItemEditor extends LitElement {
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
// Dates are manipulated and displayed in the browser timezone
|
||||
// which may be different from the Home Assistant timezone. When
|
||||
// events are persisted, they are relative to the Home Assistant
|
||||
@@ -62,7 +59,6 @@ class DialogTodoItemEditor extends LitElement {
|
||||
public showDialog(params: TodoItemEditDialogParams): void {
|
||||
this._error = undefined;
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
this._timeZone = resolveTimeZone(
|
||||
this.hass.locale.time_zone,
|
||||
this.hass.config.time_zone
|
||||
@@ -90,11 +86,6 @@ class DialogTodoItemEditor extends LitElement {
|
||||
if (!this._params) {
|
||||
return;
|
||||
}
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._error = undefined;
|
||||
this._params = undefined;
|
||||
this._due = undefined;
|
||||
@@ -117,14 +108,16 @@ class DialogTodoItemEditor extends LitElement {
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this.hass.localize(
|
||||
`ui.components.todo.item.${isCreate ? "add" : "edit"}`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
scrimClickAction
|
||||
.heading=${createCloseHeading(
|
||||
this.hass,
|
||||
this.hass.localize(
|
||||
`ui.components.todo.item.${isCreate ? "add" : "edit"}`
|
||||
)
|
||||
)}
|
||||
width="medium"
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<div class="content">
|
||||
${this._error
|
||||
@@ -143,11 +136,11 @@ class DialogTodoItemEditor extends LitElement {
|
||||
.label=${this.hass.localize("ui.components.todo.item.summary")}
|
||||
.value=${this._summary}
|
||||
required
|
||||
autofocus
|
||||
@input=${this._handleSummaryChanged}
|
||||
.validationMessage=${this.hass.localize(
|
||||
"ui.common.error_required"
|
||||
)}
|
||||
dialogInitialFocus
|
||||
.disabled=${!canUpdate}
|
||||
></ha-textfield>
|
||||
</div>
|
||||
@@ -210,43 +203,41 @@ class DialogTodoItemEditor extends LitElement {
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
${isCreate
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._createItem}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.add")}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._saveItem}
|
||||
.disabled=${!canUpdate || this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.save")}
|
||||
</ha-button>
|
||||
${this._todoListSupportsFeature(
|
||||
TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
)
|
||||
? html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
@click=${this._deleteItem}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.delete")}
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
`}
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
${isCreate
|
||||
? html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._createItem}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.add")}
|
||||
</ha-button>
|
||||
`
|
||||
: html`
|
||||
<ha-button
|
||||
slot="primaryAction"
|
||||
@click=${this._saveItem}
|
||||
.disabled=${!canUpdate || this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.save")}
|
||||
</ha-button>
|
||||
${this._todoListSupportsFeature(
|
||||
TodoListEntityFeature.DELETE_TODO_ITEM
|
||||
)
|
||||
? html`
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
variant="danger"
|
||||
appearance="plain"
|
||||
@click=${this._deleteItem}
|
||||
.disabled=${this._submitting}
|
||||
>
|
||||
${this.hass.localize("ui.components.todo.item.delete")}
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
`}
|
||||
</ha-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -424,6 +415,12 @@ class DialogTodoItemEditor extends LitElement {
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
@media all and (min-width: 450px) and (min-height: 500px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-min-width: min(600px, 95vw);
|
||||
--mdc-dialog-max-width: min(600px, 95vw);
|
||||
}
|
||||
}
|
||||
ha-alert {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -110,7 +110,7 @@ class PanelTodo extends LitElement {
|
||||
this._entityId = undefined;
|
||||
}
|
||||
if (!this._entityId) {
|
||||
this._entityId = getTodoLists(this.hass, false)[0]?.entity_id;
|
||||
this._entityId = getTodoLists(this.hass)[0]?.entity_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -147,12 +147,12 @@ class PanelTodo extends LitElement {
|
||||
? this.hass.states[this._entityId]
|
||||
: undefined;
|
||||
const showPane = this._showPaneController.value ?? !this.narrow;
|
||||
const listItems = getTodoLists(this.hass, false).map(
|
||||
const listItems = getTodoLists(this.hass).map(
|
||||
(list) =>
|
||||
html`<ha-dropdown-item
|
||||
@click=${this._setEntityId}
|
||||
value=${list.entity_id}
|
||||
.selected=${list.entity_id === this._entityId}
|
||||
class=${list.entity_id === this._entityId ? "selected" : ""}
|
||||
>
|
||||
<ha-state-icon
|
||||
.stateObj=${list}
|
||||
@@ -322,7 +322,7 @@ class PanelTodo extends LitElement {
|
||||
}
|
||||
const result = await deleteConfigEntry(this.hass, entryId);
|
||||
|
||||
this._entityId = getTodoLists(this.hass, false)[0]?.entity_id;
|
||||
this._entityId = getTodoLists(this.hass)[0]?.entity_id;
|
||||
|
||||
if (result.require_restart) {
|
||||
showAlertDialog(this, {
|
||||
@@ -409,6 +409,13 @@ class PanelTodo extends LitElement {
|
||||
ha-dropdown.lists ha-dropdown-item {
|
||||
max-width: 80vw;
|
||||
}
|
||||
|
||||
ha-dropdown-item.selected {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-color);
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
--icon-primary-color: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -224,20 +224,17 @@ export const haStyleDialogFixedTop = css`
|
||||
`;
|
||||
|
||||
export const haStyleScrollbar = css`
|
||||
.ha-scrollbar::-webkit-scrollbar,
|
||||
:host(.ha-scrollbar)::-webkit-scrollbar {
|
||||
.ha-scrollbar::-webkit-scrollbar {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
}
|
||||
|
||||
.ha-scrollbar::-webkit-scrollbar-thumb,
|
||||
:host(.ha-scrollbar)::-webkit-scrollbar-thumb {
|
||||
.ha-scrollbar::-webkit-scrollbar-thumb {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
background: var(--scrollbar-thumb-color);
|
||||
}
|
||||
|
||||
.ha-scrollbar,
|
||||
:host(.ha-scrollbar) {
|
||||
.ha-scrollbar {
|
||||
overflow-y: auto;
|
||||
scrollbar-color: var(--scrollbar-thumb-color) transparent;
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -38,21 +38,7 @@ export function getState(): Partial<StoredHomeAssistant> {
|
||||
STORED_STATE.forEach((key) => {
|
||||
const storageItem = window.localStorage.getItem(key);
|
||||
if (storageItem !== null) {
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(storageItem);
|
||||
} catch (_err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Failed to json parse localStorage key: ${key}. Key value: ${storageItem}`,
|
||||
_err
|
||||
);
|
||||
window.localStorage.removeItem(key);
|
||||
if (key === "selectedTheme") {
|
||||
state[key] = { theme: "" };
|
||||
}
|
||||
return;
|
||||
}
|
||||
let value = JSON.parse(storageItem);
|
||||
// selectedTheme went from string to object on 20200718
|
||||
if (key === "selectedTheme" && typeof value === "string") {
|
||||
value = { theme: value };
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { assert, describe, it } from "vitest";
|
||||
|
||||
import { normalizeValueBySIPrefix } from "../../../src/common/number/normalize-by-si-prefix";
|
||||
|
||||
describe("normalizeValueBySIPrefix", () => {
|
||||
it("Applies kilo prefix (k)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(11, "kW"), 11000);
|
||||
assert.equal(normalizeValueBySIPrefix(2.5, "kWh"), 2500);
|
||||
});
|
||||
|
||||
it("Applies mega prefix (M)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(3, "MW"), 3_000_000);
|
||||
});
|
||||
|
||||
it("Applies giga prefix (G)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(1, "GW"), 1_000_000_000);
|
||||
});
|
||||
|
||||
it("Applies tera prefix (T)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(2, "TW"), 2_000_000_000_000);
|
||||
});
|
||||
|
||||
it("Applies milli prefix (m)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(500, "mW"), 0.5);
|
||||
});
|
||||
|
||||
it("Applies micro prefix (µ micro sign U+00B5)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(1000, "\u00B5W"), 0.001);
|
||||
});
|
||||
|
||||
it("Applies micro prefix (μ greek mu U+03BC)", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(1000, "\u03BCW"), 0.001);
|
||||
});
|
||||
|
||||
it("Returns value unchanged for single-char units", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(100, "W"), 100);
|
||||
assert.equal(normalizeValueBySIPrefix(5, "m"), 5);
|
||||
assert.equal(normalizeValueBySIPrefix(22, "K"), 22);
|
||||
});
|
||||
|
||||
it("Returns value unchanged for undefined unit", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(42, undefined), 42);
|
||||
});
|
||||
|
||||
it("Returns value unchanged for unrecognized prefixes", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(20, "°C"), 20);
|
||||
assert.equal(normalizeValueBySIPrefix(50, "dB"), 50);
|
||||
assert.equal(normalizeValueBySIPrefix(1013, "hPa"), 1013);
|
||||
});
|
||||
|
||||
it("Returns value unchanged for empty string", () => {
|
||||
assert.equal(normalizeValueBySIPrefix(10, ""), 10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user