Compare commits

..

5 Commits

Author SHA1 Message Date
Aidan Timson
f88c707c32 Cleanup 2026-02-10 16:40:23 +00:00
Aidan Timson
753de9e58f New AGENTS.md 2026-02-10 16:32:53 +00:00
Aidan Timson
1035df8733 New AGENTS.md 2026-02-10 16:32:41 +00:00
Aidan Timson
e341c68035 Add missing submit method in example 2026-02-10 16:29:39 +00:00
Aidan Timson
f5b8d4e372 Create skills 2026-02-10 16:27:07 +00:00
44 changed files with 1060 additions and 468 deletions

View 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`

View 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`

View 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`

View 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

View 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

View 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

View 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;
}),
];
}
}
```

View 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);
}
}
`;
}
```

View 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)

View File

@@ -1 +0,0 @@
.github/copilot-instructions.md

381
AGENTS.md Normal file
View 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

View File

@@ -31,7 +31,4 @@ module.exports = {
isDevContainer() {
return isTrue(process.env.DEV_CONTAINER);
},
jsMinifier() {
return (process.env.JS_MINIFIER || "swc").toLowerCase();
},
};

View File

@@ -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();

View File

@@ -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",

View File

@@ -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"

View File

@@ -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;
};

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
`,
];
}

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
}
};
/**

View File

@@ -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],

View File

@@ -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 {

View File

@@ -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);
}
`,
];
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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`,
})

View File

@@ -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);
}
`,
];
}

View File

@@ -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>

View File

@@ -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);
}
`;
}

View File

@@ -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 =

View File

@@ -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)

View File

@@ -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);

View File

@@ -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)

View File

@@ -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);
});
}

View File

@@ -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 {

View File

@@ -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);
}
`;
}

View File

@@ -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"

View File

@@ -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;

View File

@@ -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);
}
`,
];
}

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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);
});
});