mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-04 23:41:46 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a973291d1a | |||
| ee755ff58a | |||
| 635a6442b4 | |||
| 3608156e83 | |||
| 8529980bdd | |||
| 9ff508259f | |||
| 1ec4dc6c79 | |||
| 39c88e573d | |||
| 99eb752a68 | |||
| 9cc63c1f53 | |||
| d2188f600f | |||
| d69d4c592d | |||
| a97df0409c | |||
| 468756bd2f | |||
| cc43caa87b | |||
| 1fb3efadfa | |||
| 69599352a3 | |||
| 5c0f2feac1 |
@@ -8,6 +8,7 @@ You are an assistant helping with development of the Home Assistant frontend. Th
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Component Library](#component-library)
|
||||
- [Common Patterns](#common-patterns)
|
||||
@@ -40,7 +41,7 @@ script/develop # Development server
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
@@ -52,13 +53,64 @@ The Home Assistant frontend is a modern web application that:
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
@@ -136,6 +188,7 @@ export class HaMyComponent extends LitElement {
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **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
|
||||
@@ -160,7 +213,7 @@ try {
|
||||
- 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
|
||||
- **Follow Material Design**: Use Material Web Components where appropriate
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
@@ -267,15 +320,22 @@ fireEvent(this, "show-dialog", {
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
|
||||
**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)
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
**Gallery Documentation:**
|
||||
@@ -308,7 +368,8 @@ fireEvent(this, "show-dialog", {
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
@@ -449,7 +510,7 @@ this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't use `querySelector` - Use refs or component properties
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators 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
|
||||
@@ -540,35 +601,24 @@ When creating a pull request, you **must** use the PR template located at `.gith
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
- **Add translation keys**: All user-facing text must be translatable
|
||||
- **Use placeholders**: Support dynamic content in translations
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **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?");
|
||||
```
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### 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
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
|
||||
#### 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
|
||||
@@ -580,15 +630,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
- **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
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
@@ -599,39 +646,12 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### Core Requirements Checklist
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
|
||||
- [ ] 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
|
||||
|
||||
- [ ] ha-alert used correctly for messages
|
||||
- [ ] ha-form uses proper schema structure
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event subscriptions properly cleaned up
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -78,13 +78,13 @@ const alerts: {
|
||||
title: "Error with action",
|
||||
description: "This is a test error alert with action",
|
||||
type: "error",
|
||||
actionSlot: html`<ha-button size="small" slot="action">restart</ha-button>`,
|
||||
actionSlot: html`<ha-button size="s" slot="action">restart</ha-button>`,
|
||||
},
|
||||
{
|
||||
title: "Unsaved data",
|
||||
description: "You have unsaved data",
|
||||
type: "warning",
|
||||
actionSlot: html`<ha-button size="small" slot="action">save</ha-button>`,
|
||||
actionSlot: html`<ha-button size="s" slot="action">save</ha-button>`,
|
||||
},
|
||||
{
|
||||
title: "Slotted icon",
|
||||
|
||||
@@ -26,7 +26,7 @@ title: Button
|
||||
filled button
|
||||
</ha-button>
|
||||
|
||||
<ha-button size="small">
|
||||
<ha-button size="s">
|
||||
small
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@ title: Button
|
||||
```html
|
||||
<ha-button> simple button </ha-button>
|
||||
|
||||
<ha-button size="small"> small </ha-button>
|
||||
<ha-button size="s"> small </ha-button>
|
||||
```
|
||||
|
||||
### API
|
||||
@@ -57,7 +57,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
|
||||
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
|
||||
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
|
||||
| size | "small"/"medium"/"large" | "medium" | Sets the button size. |
|
||||
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
|
||||
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
|
||||
| disabled | Boolean | false | Disables the button and prevents user interaction. |
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ export class DemoHaButton extends LitElement {
|
||||
<ha-button
|
||||
.appearance=${appearance}
|
||||
.variant=${variant}
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${titleCase(`${variant} ${appearance}`)}
|
||||
</ha-button>
|
||||
@@ -100,7 +100,7 @@ export class DemoHaButton extends LitElement {
|
||||
<ha-button
|
||||
.variant=${variant}
|
||||
.appearance=${appearance}
|
||||
size="small"
|
||||
size="s"
|
||||
disabled
|
||||
>
|
||||
${titleCase(`${appearance}`)}
|
||||
|
||||
@@ -62,10 +62,11 @@ host reflects `aria-multiselectable`.
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-selected` — selection changed. Detail
|
||||
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
|
||||
`index` is a `number` in single mode (`-1` when nothing selected) and a
|
||||
`Set<number>` in multi mode.
|
||||
- `ha-list-item-selected` — an option was selected. Detail is the option's
|
||||
index (`number`). In single mode this is the only selection event; in multi
|
||||
mode it fires for each option added to the selection.
|
||||
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
|
||||
is the option's index (`number`).
|
||||
|
||||
**Methods / getters**
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-base";
|
||||
import "../../../../src/components/list/ha-list-nav";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
|
||||
|
||||
type Appearance = "line" | "checkbox";
|
||||
type Position = "start" | "end";
|
||||
@@ -185,7 +184,7 @@ export class DemoHaList extends LitElement {
|
||||
<ha-card header="Single select, appearance=line">
|
||||
<ha-list-selectable
|
||||
aria-label="Single select"
|
||||
@ha-list-selected=${this._onSingle}
|
||||
@ha-list-item-selected=${this._onSingle}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -205,7 +204,8 @@ export class DemoHaList extends LitElement {
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi select line"
|
||||
@ha-list-selected=${this._onMultiLine}
|
||||
@ha-list-item-selected=${this._onMultiLineSelected}
|
||||
@ha-list-item-deselected=${this._onMultiLineDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -227,7 +227,8 @@ export class DemoHaList extends LitElement {
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox start"
|
||||
@ha-list-selected=${this._onMultiCheckStart}
|
||||
@ha-list-item-selected=${this._onMultiCheckStartSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -253,7 +254,8 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox end"
|
||||
@ha-list-selected=${this._onMultiCheckEnd}
|
||||
@ha-list-item-selected=${this._onMultiCheckEndSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -347,20 +349,58 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
|
||||
this._buttonClicks++;
|
||||
};
|
||||
|
||||
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._single = ev.detail.index;
|
||||
private _withIndex(
|
||||
value: number | Set<number>,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): Set<number> {
|
||||
const next = new Set(value instanceof Set ? value : []);
|
||||
if (selected) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
};
|
||||
|
||||
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiLine = ev.detail.index;
|
||||
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
|
||||
};
|
||||
|
||||
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckStart = ev.detail.index;
|
||||
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
|
||||
};
|
||||
|
||||
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckEnd = ev.detail.index;
|
||||
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(
|
||||
this._multiCheckEnd,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
|
||||
@@ -17,13 +17,13 @@ subtitle: A slider component for selecting a value from a range.
|
||||
### Example Usage
|
||||
|
||||
<div class="wrapper">
|
||||
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="medium"></ha-slider>
|
||||
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="m"></ha-slider>
|
||||
</div>
|
||||
|
||||
```html
|
||||
<ha-slider size="small" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="medium"></ha-slider>
|
||||
<ha-slider size="s" with-markers min="0" max="8" value="4"></ha-slider>
|
||||
<ha-slider size="m"></ha-slider>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
@@ -29,7 +29,7 @@ export class DemoHaSlider extends LitElement {
|
||||
></ha-slider>
|
||||
<span>Small</span>
|
||||
<ha-slider
|
||||
size="small"
|
||||
size="s"
|
||||
min="0"
|
||||
max="8"
|
||||
value="4"
|
||||
@@ -37,7 +37,7 @@ export class DemoHaSlider extends LitElement {
|
||||
></ha-slider>
|
||||
<span>Medium</span>
|
||||
<ha-slider
|
||||
size="medium"
|
||||
size="m"
|
||||
min="0"
|
||||
max="8"
|
||||
value="4"
|
||||
|
||||
+3
-3
@@ -95,7 +95,7 @@
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.4",
|
||||
"intl-messageformat": "11.2.7",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "4.2.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -153,7 +153,7 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
@@ -196,7 +196,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.7",
|
||||
"vitest": "4.1.8",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
export interface EntityLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
gpsAccuracy?: number;
|
||||
}
|
||||
|
||||
const findFirstActiveZone = (
|
||||
inZones: readonly string[],
|
||||
states: HassEntities
|
||||
): HassEntity | undefined => {
|
||||
for (const zoneId of inZones) {
|
||||
const zone = states[zoneId];
|
||||
if (
|
||||
zone &&
|
||||
!zone.attributes.passive &&
|
||||
typeof zone.attributes.latitude === "number" &&
|
||||
typeof zone.attributes.longitude === "number"
|
||||
) {
|
||||
return zone;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getEntityLocation = (
|
||||
stateObj: HassEntity,
|
||||
states: HassEntities
|
||||
): EntityLocation | undefined => {
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
gps_accuracy: gpsAccuracy,
|
||||
} = stateObj.attributes;
|
||||
if (typeof latitude === "number" && typeof longitude === "number") {
|
||||
return { latitude, longitude, gpsAccuracy };
|
||||
}
|
||||
|
||||
if (computeStateDomain(stateObj) !== "person") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inZones = stateObj.attributes.in_zones;
|
||||
if (!Array.isArray(inZones) || inZones.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const zone = findFirstActiveZone(inZones, states);
|
||||
if (!zone) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: zone.attributes.latitude,
|
||||
longitude: zone.attributes.longitude,
|
||||
};
|
||||
};
|
||||
@@ -167,7 +167,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
)}`}
|
||||
${this.syncCharts && this._hasZoomedCharts
|
||||
? html`<ha-button
|
||||
size="large"
|
||||
size="l"
|
||||
class="reset-button"
|
||||
@click=${this._handleGlobalZoomReset}
|
||||
>
|
||||
|
||||
@@ -117,7 +117,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
<div class="header">
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
size="s"
|
||||
.buttons=${modeButtons}
|
||||
.active=${this._mode}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
@@ -10,10 +11,19 @@ import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { getAreaContext } from "../common/entity/context/get_area_context";
|
||||
import { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
|
||||
import { createAreaRegistryEntry } from "../data/area/area_registry";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
devicesContext,
|
||||
entitiesContext,
|
||||
floorsContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
@@ -27,8 +37,6 @@ const ADD_NEW_ID = "___ADD_NEW___";
|
||||
|
||||
@customElement("ha-area-picker")
|
||||
export class HaAreaPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
@@ -84,16 +92,37 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities!: ContextType<typeof entitiesContext>;
|
||||
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@consume({ context: areasContext, subscribe: true })
|
||||
private _areas!: ContextType<typeof areasContext>;
|
||||
|
||||
@consume({ context: floorsContext, subscribe: true })
|
||||
private _floors!: ContextType<typeof floorsContext>;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingAreaId?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
if (
|
||||
this._pendingAreaId &&
|
||||
changedProperties.has("hass") &&
|
||||
this.hass.areas !== changedProperties.get("hass")?.areas &&
|
||||
this.hass.areas[this._pendingAreaId]
|
||||
changedProperties.has("_areas") &&
|
||||
this._areas[this._pendingAreaId]
|
||||
) {
|
||||
this._setValue(this._pendingAreaId);
|
||||
this._pendingAreaId = undefined;
|
||||
@@ -107,11 +136,11 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
private _getAreasMemoized = memoizeOne(
|
||||
(
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
haStates: HomeAssistant["states"],
|
||||
haAreas: ContextType<typeof areasContext>,
|
||||
haFloors: ContextType<typeof floorsContext>,
|
||||
haDevices: ContextType<typeof devicesContext>,
|
||||
haEntities: ContextType<typeof entitiesContext>,
|
||||
haStates: ContextType<typeof statesContext>,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
@@ -131,9 +160,9 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
// Recompute value renderer when the areas change
|
||||
private _computeValueRenderer = memoizeOne(
|
||||
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
|
||||
(haAreas: ContextType<typeof areasContext>): PickerValueRenderer =>
|
||||
(value) => {
|
||||
const area = this.hass.areas[value];
|
||||
const area = haAreas[value];
|
||||
|
||||
if (!area) {
|
||||
return html`
|
||||
@@ -142,7 +171,7 @@ export class HaAreaPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
const { floor } = getAreaContext(area, this._floors);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
@@ -166,11 +195,11 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
private _getItems = () =>
|
||||
this._getAreasMemoized(
|
||||
this.hass.areas,
|
||||
this.hass.floors,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.hass.states,
|
||||
this._areas,
|
||||
this._floors,
|
||||
this._devices,
|
||||
this._entities,
|
||||
this._states,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
@@ -180,7 +209,7 @@ export class HaAreaPicker extends LitElement {
|
||||
);
|
||||
|
||||
private _allAreaNames = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]) =>
|
||||
(areas: ContextType<typeof areasContext>) =>
|
||||
Object.values(areas)
|
||||
.map((area) => computeAreaName(area)?.toLowerCase())
|
||||
.filter(Boolean) as string[]
|
||||
@@ -193,13 +222,13 @@ export class HaAreaPicker extends LitElement {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allAreas = this._allAreaNames(this.hass.areas);
|
||||
const allAreas = this._allAreaNames(this._areas);
|
||||
|
||||
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID + searchString,
|
||||
primary: this.hass.localize(
|
||||
primary: this._i18n.localize(
|
||||
"ui.components.area-picker.add_new_suggestion",
|
||||
{
|
||||
name: searchString,
|
||||
@@ -213,7 +242,7 @@ export class HaAreaPicker extends LitElement {
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID,
|
||||
primary: this.hass.localize("ui.components.area-picker.add_new"),
|
||||
primary: this._i18n.localize("ui.components.area-picker.add_new"),
|
||||
icon_path: mdiPlus,
|
||||
},
|
||||
];
|
||||
@@ -221,15 +250,17 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const baseLabel =
|
||||
this.label ?? this.hass.localize("ui.components.area-picker.area");
|
||||
const valueRenderer = this._computeValueRenderer(this.hass.areas);
|
||||
this.label ?? this._i18n.localize("ui.components.area-picker.area");
|
||||
const areas = this._areas;
|
||||
const floors = this._floors;
|
||||
const valueRenderer = this._computeValueRenderer(areas);
|
||||
|
||||
// Only show label if there's no floor
|
||||
let label: string | undefined = baseLabel;
|
||||
if (this.value && baseLabel) {
|
||||
const area = this.hass.areas[this.value];
|
||||
const area = areas[this.value];
|
||||
if (area) {
|
||||
const { floor } = getAreaContext(area, this.hass.floors);
|
||||
const { floor } = getAreaContext(area, floors);
|
||||
if (floor) {
|
||||
label = undefined;
|
||||
}
|
||||
@@ -238,12 +269,11 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${label}
|
||||
.helper=${this.helper}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
|
||||
.emptyLabel=${this._i18n.localize("ui.components.area-picker.no_areas")}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.value=${this.value}
|
||||
@@ -252,7 +282,7 @@ export class HaAreaPicker extends LitElement {
|
||||
.valueRenderer=${valueRenderer}
|
||||
.addButtonLabel=${this.addButtonLabel}
|
||||
.searchKeys=${areaComboBoxKeys}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
.unknownItemText=${this._i18n.localize(
|
||||
"ui.components.area-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
@@ -271,7 +301,7 @@ export class HaAreaPicker extends LitElement {
|
||||
}
|
||||
|
||||
if (value.startsWith(ADD_NEW_ID)) {
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
this._i18n.loadFragmentTranslation("config");
|
||||
|
||||
const suggestedName = value.substring(ADD_NEW_ID.length);
|
||||
|
||||
@@ -279,15 +309,15 @@ export class HaAreaPicker extends LitElement {
|
||||
suggestedName: suggestedName,
|
||||
createEntry: async (values) => {
|
||||
try {
|
||||
const area = await createAreaRegistryEntry(this.hass, values);
|
||||
if (this.hass.areas[area.area_id]) {
|
||||
const area = await createAreaRegistryEntry(this._api, values);
|
||||
if (this._areas[area.area_id]) {
|
||||
this._setValue(area.area_id);
|
||||
} else {
|
||||
this._pendingAreaId = area.area_id;
|
||||
}
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
title: this._i18n.localize(
|
||||
"ui.components.area-picker.failed_create_area"
|
||||
),
|
||||
text: err.message,
|
||||
@@ -308,7 +338,7 @@ export class HaAreaPicker extends LitElement {
|
||||
}
|
||||
|
||||
private _notFoundLabel = (search: string) =>
|
||||
this.hass.localize("ui.components.area-picker.no_match", {
|
||||
this._i18n.localize("ui.components.area-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ export class HaAreasPicker extends SubscribeMixin(LitElement) {
|
||||
<ha-area-picker
|
||||
.curValue=${area}
|
||||
.noAdd=${this.noAdd}
|
||||
.hass=${this.hass}
|
||||
.value=${area}
|
||||
.label=${this.pickedAreaLabel}
|
||||
.includeDomains=${this.includeDomains}
|
||||
@@ -112,7 +111,6 @@ export class HaAreasPicker extends SubscribeMixin(LitElement) {
|
||||
<div>
|
||||
<ha-area-picker
|
||||
.noAdd=${this.noAdd}
|
||||
.hass=${this.hass}
|
||||
.label=${this.pickAreaLabel}
|
||||
.helper=${this.helper}
|
||||
.includeDomains=${this.includeDomains}
|
||||
|
||||
@@ -15,7 +15,7 @@ import "./ha-svg-icon";
|
||||
*
|
||||
* @attr {ToggleButton[]} buttons - the button config
|
||||
* @attr {string} active - The value of the currently active button.
|
||||
* @attr {("small"|"medium")} size - The size of the buttons in the group.
|
||||
* @attr {("s"|"m")} size - The size of the buttons in the group.
|
||||
* @attr {("brand"|"neutral"|"success"|"warning"|"danger")} variant - The variant of the buttons in the group.
|
||||
*
|
||||
* @fires value-changed - Dispatched when the active button changes.
|
||||
@@ -26,7 +26,7 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
|
||||
@property() public active?: string;
|
||||
|
||||
@property({ reflect: true }) size: "small" | "medium" = "medium";
|
||||
@property({ reflect: true }) size: "s" | "m" = "m";
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
|
||||
public nowrap = false;
|
||||
|
||||
@@ -27,7 +27,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
|
||||
* @cssprop --ha-button-height - The height of the button.
|
||||
* @cssprop --ha-button-border-radius - The border radius of the button. defaults to `var(--ha-border-radius-pill)`.
|
||||
*
|
||||
* @attr {("small"|"medium"|"large")} size - Sets the button size.
|
||||
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
|
||||
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
|
||||
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
|
||||
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
|
||||
@@ -65,7 +65,7 @@ export class HaButton extends Button {
|
||||
box-shadow: var(--ha-button-box-shadow);
|
||||
}
|
||||
|
||||
:host([size="small"]) .button {
|
||||
:host([size="s"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 32px)
|
||||
@@ -74,7 +74,7 @@ export class HaButton extends Button {
|
||||
--wa-form-control-padding-inline: var(--ha-space-3);
|
||||
}
|
||||
|
||||
:host([size="large"]) .button {
|
||||
:host([size="l"]) .button {
|
||||
--wa-form-control-height: var(
|
||||
--ha-button-height,
|
||||
var(--button-height, 48px)
|
||||
|
||||
@@ -61,7 +61,7 @@ export class HaDurationInput extends LitElement {
|
||||
${this.allowNegative
|
||||
? html`
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
size="s"
|
||||
.buttons=${[
|
||||
{ label: "+", iconPath: mdiPlusThick, value: "+" },
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
|
||||
@@ -107,6 +107,7 @@ export class HaExpansionPanel extends LitElement {
|
||||
}
|
||||
const newExpanded = !this.expanded;
|
||||
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
|
||||
|
||||
this._container.style.overflow = "hidden";
|
||||
|
||||
if (newExpanded) {
|
||||
|
||||
@@ -120,7 +120,7 @@ export class HaFileUpload extends LitElement {
|
||||
@dragend=${this._handleDragEnd}
|
||||
>${!this.value
|
||||
? html`<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@click=${this._openFilePicker}
|
||||
>
|
||||
|
||||
+136
-132
@@ -1,5 +1,5 @@
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -9,14 +9,18 @@ import { stringCompare } from "../common/string/compare";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import type { RelatedResult } from "../data/search";
|
||||
import { findRelated } from "../data/search";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
import "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable-virtualized";
|
||||
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
|
||||
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
|
||||
|
||||
interface HaFilterDevicesItem extends HaListVirtualizedItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-devices")
|
||||
export class HaFilterDevices extends LitElement {
|
||||
@@ -34,15 +38,12 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
@query("ha-list-selectable-virtualized")
|
||||
private _listElement?: HaListSelectableVirtualized;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
|
||||
if (
|
||||
properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))
|
||||
@@ -51,6 +52,20 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded || !this._listElement) {
|
||||
return;
|
||||
}
|
||||
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 38px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -66,6 +81,7 @@ export class HaFilterDevices extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
@keydown=${this._handleClearFilterKeydown}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -74,75 +90,45 @@ export class HaFilterDevices extends LitElement {
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list class="ha-scrollbar" multi>
|
||||
<lit-virtualizer
|
||||
.items=${this._devices(
|
||||
this.hass.devices,
|
||||
this._filter || "",
|
||||
this.value
|
||||
)}
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${this._renderItem}
|
||||
@click=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>`
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(this.hass.devices, this._filter || "")}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>`
|
||||
: nothing}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (device) => device?.id;
|
||||
|
||||
private _renderItem = (device) =>
|
||||
!device
|
||||
private _renderItem = (item?: HaFilterDevicesItem) =>
|
||||
!item
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
tabindex="0"
|
||||
.value=${device.id}
|
||||
.selected=${this.value?.includes(device.id) ?? false}
|
||||
: html`<ha-list-item-option
|
||||
style="width: 100%;"
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${item.id}
|
||||
.selected=${this.value?.includes(item.id) ?? false}
|
||||
>
|
||||
${computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)}
|
||||
</ha-check-list-item>`;
|
||||
<span slot="headline">${item.name}</span>
|
||||
</ha-list-item-option>`;
|
||||
|
||||
private _handleItemKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
private _handleAdded(ev: CustomEvent<number>) {
|
||||
this.value = [
|
||||
...(this.value ?? []),
|
||||
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
|
||||
];
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (this.value?.includes(value)) {
|
||||
this.value = this.value?.filter((val) => val !== value);
|
||||
} else {
|
||||
this.value = [...(this.value || []), value];
|
||||
}
|
||||
listItem.selected = this.value?.includes(value);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
private _handleRemoved(ev: CustomEvent<number>) {
|
||||
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
|
||||
.id;
|
||||
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
@@ -155,30 +141,38 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
this._filter = target.value ?? "";
|
||||
}
|
||||
|
||||
private _handleSearchKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "ArrowDown" && this._listElement) {
|
||||
ev.preventDefault();
|
||||
this._listElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _devices = memoizeOne(
|
||||
(devices: HomeAssistant["devices"], filter: string, _value) => {
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
filter: string
|
||||
): HaFilterDevicesItem[] => {
|
||||
const values = Object.values(devices);
|
||||
return values
|
||||
.map((device) => ({
|
||||
id: device.id,
|
||||
interactive: true,
|
||||
name: computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
(device) =>
|
||||
!filter ||
|
||||
computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
.toLowerCase()
|
||||
.includes(filter)
|
||||
({ name }) =>
|
||||
!filter || name.toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
.sort((a, b) =>
|
||||
stringCompare(
|
||||
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
|
||||
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
|
||||
this.hass.locale.language
|
||||
)
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -217,6 +211,13 @@ export class HaFilterDevices extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleClearFilterKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._clearFilter(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
@@ -224,58 +225,61 @@ export class HaFilterDevices extends LitElement {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
this._listElement?.clearSelection();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
static styles = css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-check-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
:host([expanded]) ha-expansion-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -23,7 +23,6 @@ import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "./list/types";
|
||||
|
||||
@customElement("ha-filter-floor-areas")
|
||||
export class HaFilterFloorAreas extends LitElement {
|
||||
@@ -42,7 +41,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HTMLElement;
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -75,6 +74,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
@keydown=${this._handleClearFilterKeydown}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -83,7 +83,8 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@ha-list-selected=${this._handleListChanged}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.areas.caption"
|
||||
)}
|
||||
@@ -163,46 +164,47 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
|
||||
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
|
||||
private _handleAdded(ev: CustomEvent<number>) {
|
||||
if (!this.value) {
|
||||
this.value = {};
|
||||
}
|
||||
|
||||
const addedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
ev.detail
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
if (!addedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.detail.diff?.added.size) {
|
||||
const addedIndex = ev.detail.diff.added.values().next().value;
|
||||
if (addedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const addedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
addedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
...(this.value[addedItem.type] || []),
|
||||
addedItem.value,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.value) {
|
||||
this.value = {};
|
||||
}
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
...(this.value[addedItem.type] || []),
|
||||
addedItem.value,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const removedIndex = ev.detail.diff?.removed.values().next().value;
|
||||
if (removedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const removedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
removedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
(val) => val !== removedItem.value
|
||||
),
|
||||
};
|
||||
private _handleRemoved(ev: CustomEvent<number>) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const removedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
ev.detail
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
if (!removedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
(val) => val !== removedItem.value
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
@@ -286,6 +288,13 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleClearFilterKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._clearFilter(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
@@ -293,6 +302,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
this._list?.clearSelection();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -129,7 +129,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
|
||||
@wa-select=${this._handleAddAction}
|
||||
@closed=${stopPropagation}
|
||||
>
|
||||
<ha-button slot="trigger" appearance="filled" size="small">
|
||||
<ha-button slot="trigger" appearance="filled" size="s">
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.localize?.("ui.components.form-optional-actions.add") ||
|
||||
"Add interaction"}
|
||||
|
||||
@@ -174,7 +174,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
<slot name="field">
|
||||
${this.addButtonLabel && !this.value
|
||||
? html`<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@click=${this.open}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -171,7 +171,7 @@ export class HaLabelsPicker extends LitElement {
|
||||
: nothing}
|
||||
<ha-button
|
||||
id="picker"
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -53,7 +53,7 @@ class HaLawnMowerActionButton extends LitElement {
|
||||
appearance="plain"
|
||||
@click=${this.callService}
|
||||
.service=${action.service}
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${this.hass.localize(`ui.card.lawn_mower.actions.${action.action}`)}
|
||||
</ha-button>
|
||||
|
||||
@@ -102,7 +102,7 @@ export class HaPictureUpload extends LitElement {
|
||||
<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._handleChangeClick}
|
||||
>
|
||||
|
||||
@@ -91,7 +91,6 @@ export class HaAreaSelector extends LitElement {
|
||||
if (!this.selector.area?.multiple) {
|
||||
return html`
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
|
||||
@@ -63,7 +63,7 @@ export class HaChooseSelector extends LitElement {
|
||||
return html`<div class="multi-header">
|
||||
<span>${this.label}${this.required ? "*" : ""}</span>
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
size="s"
|
||||
.buttons=${this._toggleButtons(
|
||||
this.selector.choose.choices,
|
||||
this.selector.choose.translation_key,
|
||||
|
||||
@@ -190,7 +190,7 @@ export class HaMediaSelector extends LitElement {
|
||||
? html`<div>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
variant="danger"
|
||||
@click=${this._clearValue}
|
||||
>
|
||||
|
||||
@@ -307,7 +307,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
>`
|
||||
: nothing}
|
||||
<ha-button-toggle-group
|
||||
size="small"
|
||||
size="s"
|
||||
.buttons=${choiceToggleButtons}
|
||||
.active=${activeChoice}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { mainWindow } from "../common/dom/get_main_window";
|
||||
|
||||
@customElement("ha-slider")
|
||||
export class HaSlider extends Slider {
|
||||
@property({ reflect: true }) size: "small" | "medium" = "small";
|
||||
@property({ reflect: true }) size: "s" | "m" = "s";
|
||||
|
||||
@property({ type: Boolean, attribute: "with-tooltip" }) withTooltip = true;
|
||||
|
||||
@@ -110,12 +110,12 @@ export class HaSlider extends Slider {
|
||||
);
|
||||
}
|
||||
|
||||
:host([size="medium"]) {
|
||||
:host([size="m"]) {
|
||||
--thumb-width: 20px;
|
||||
--thumb-height: 20px;
|
||||
}
|
||||
|
||||
:host([size="small"]) {
|
||||
:host([size="s"]) {
|
||||
--thumb-width: 16px;
|
||||
--thumb-height: 16px;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ class HaTracePicker extends LitElement {
|
||||
slot="field"
|
||||
appearance="filled"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
${this._renderTracePickerValue(this.value!)}
|
||||
|
||||
@@ -108,7 +108,6 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
<span class="segment-name">${segment.name}</span>
|
||||
<ha-svg-icon class="arrow" .path=${mdiArrowRightThin}></ha-svg-icon>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
.value=${mappedAreas}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.area_label"
|
||||
|
||||
@@ -58,7 +58,7 @@ export class HaVacuumState extends LitElement {
|
||||
return html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._callService}
|
||||
.disabled=${!interceptable}
|
||||
>
|
||||
|
||||
@@ -99,7 +99,7 @@ export class HaInputCopy extends LitElement {
|
||||
: nothing}
|
||||
</ha-input>
|
||||
</div>
|
||||
<ha-button @click=${this._copy} appearance="plain" size="small">
|
||||
<ha-button @click=${this._copy} appearance="plain" size="s">
|
||||
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
|
||||
${this.label || this._i18n.localize("ui.common.copy")}
|
||||
</ha-button>
|
||||
|
||||
@@ -130,7 +130,7 @@ class HaInputMulti extends LitElement {
|
||||
</ha-sortable>
|
||||
<div class="layout horizontal">
|
||||
<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="filled"
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled ||
|
||||
|
||||
@@ -38,6 +38,9 @@ export class HaListItemBase extends HaRowItem {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute("ha-list-item")) {
|
||||
this.setAttribute("ha-list-item", "");
|
||||
}
|
||||
if (!this.hasAttribute("role")) {
|
||||
this.setAttribute("role", this.defaultRole);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { compareNodeOrder } from "../../common/dom/compare-node-order";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import "./types";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
@@ -45,13 +45,13 @@ export class HaListBase extends LitElement {
|
||||
/** Host `role` attribute. Empty string means no role is set. */
|
||||
protected readonly hostRole: string = "list";
|
||||
|
||||
private _activeItemIndex = -1;
|
||||
protected activeItemIndex = -1;
|
||||
|
||||
private _firstFocusableIndex = -1;
|
||||
protected firstFocusableIndex = -1;
|
||||
|
||||
private _lastFocusableIndex = -1;
|
||||
protected lastFocusableIndex = -1;
|
||||
|
||||
private _hasFocusableItem = false;
|
||||
protected hasFocusableItem = false;
|
||||
|
||||
private _unbindKeys?: () => void;
|
||||
|
||||
@@ -63,22 +63,28 @@ export class HaListBase extends LitElement {
|
||||
if (!this.hasAttribute("role") && this.hostRole) {
|
||||
this.setAttribute("role", this.hostRole);
|
||||
}
|
||||
this._unbindKeys = tinykeys(this, {
|
||||
ArrowDown: this._onForward,
|
||||
ArrowUp: this._onBack,
|
||||
Home: this._onHome,
|
||||
End: this._onEnd,
|
||||
Enter: this._onActivate,
|
||||
Space: this._onActivate,
|
||||
});
|
||||
this.addEventListener("focusin", this._onFocusIn);
|
||||
this._unbindKeys = tinykeys(
|
||||
this,
|
||||
{
|
||||
ArrowDown: this._onForward,
|
||||
ArrowUp: this._onBack,
|
||||
Home: this._onHome,
|
||||
End: this._onEnd,
|
||||
PageDown: this._onPageDown,
|
||||
PageUp: this._onPageUp,
|
||||
Enter: this.onActivate,
|
||||
Space: this.onActivate,
|
||||
},
|
||||
{ ignore: this._ignoreKeyEvent }
|
||||
);
|
||||
this.addEventListener("focusin", this.onFocusIn);
|
||||
this.addEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
this.onItemRegister as EventListener
|
||||
);
|
||||
this.addEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
this.onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,25 +92,23 @@ export class HaListBase extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
this._unbindKeys?.();
|
||||
this._unbindKeys = undefined;
|
||||
this.removeEventListener("focusin", this._onFocusIn);
|
||||
this.removeEventListener("focusin", this.onFocusIn);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-register",
|
||||
this._onItemRegister as EventListener
|
||||
this.onItemRegister as EventListener
|
||||
);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this._onItemUnregister as EventListener
|
||||
this.onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public focus(options?: FocusOptions) {
|
||||
if (!this.items.length) {
|
||||
if (!this.itemCount) {
|
||||
super.focus(options);
|
||||
return;
|
||||
}
|
||||
this.focusItemAtIndex(
|
||||
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
|
||||
);
|
||||
this.focusItemAtIndex(this.activeItemIndex >= 0 ? this.activeItemIndex : 0);
|
||||
}
|
||||
|
||||
public focusItemAtIndex(index: number) {
|
||||
@@ -115,19 +119,19 @@ export class HaListBase extends LitElement {
|
||||
}
|
||||
|
||||
public getActiveItemIndex(): number {
|
||||
return this._activeItemIndex;
|
||||
return this.activeItemIndex;
|
||||
}
|
||||
|
||||
public setActiveItemIndex(index: number, focusItem = false) {
|
||||
if (!this._hasFocusableItem) {
|
||||
this._activeItemIndex = -1;
|
||||
if (!this.hasFocusableItem) {
|
||||
this.activeItemIndex = -1;
|
||||
return;
|
||||
}
|
||||
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
this.activeItemIndex = Math.max(0, Math.min(this.itemCount - 1, index));
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
this._applyActive(focusItem);
|
||||
this.applyActive(focusItem);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,18 +139,18 @@ export class HaListBase extends LitElement {
|
||||
* to layer in extra bookkeeping (e.g. selection state sync).
|
||||
*/
|
||||
public updateListItems() {
|
||||
this._recomputeFocusableIndexes();
|
||||
this.recomputeFocusableIndexes();
|
||||
if (
|
||||
this._activeItemIndex >= this.items.length ||
|
||||
!this._hasFocusableItem ||
|
||||
this._activeItemIndex < 0
|
||||
this.activeItemIndex >= this.itemCount ||
|
||||
!this.hasFocusableItem ||
|
||||
this.activeItemIndex < 0
|
||||
) {
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
this._applyActive(false);
|
||||
this.applyActive(false);
|
||||
}
|
||||
|
||||
private _onItemRegister = (
|
||||
protected onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
@@ -160,7 +164,7 @@ export class HaListBase extends LitElement {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _onItemUnregister = (
|
||||
protected onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
@@ -172,136 +176,190 @@ export class HaListBase extends LitElement {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
private _recomputeFocusableIndexes() {
|
||||
protected recomputeFocusableIndexes() {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this._isFocusable(i)) {
|
||||
for (let i = 0; i < this.itemCount; i++) {
|
||||
if (this.isFocusable(i)) {
|
||||
if (first === -1) {
|
||||
first = i;
|
||||
}
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
this._firstFocusableIndex = first;
|
||||
this._lastFocusableIndex = last;
|
||||
this._hasFocusableItem = first !== -1;
|
||||
this.firstFocusableIndex = first;
|
||||
this.lastFocusableIndex = last;
|
||||
this.hasFocusableItem = first !== -1;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<div part="base" class="base">
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
return html`<div part="base" class="base ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _isFocusable(index: number): boolean {
|
||||
protected isFocusable(index: number): boolean {
|
||||
const item = this.items[index];
|
||||
return !!item && item.interactive && !item.disabled;
|
||||
}
|
||||
|
||||
private _applyActive(focusItem: boolean) {
|
||||
protected applyActive(focusItem: boolean) {
|
||||
this.items.forEach((item, i) => {
|
||||
if (!item.interactive || item.disabled) {
|
||||
item.removeAttribute("tabindex");
|
||||
return;
|
||||
}
|
||||
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
|
||||
item.tabIndex = i === this.activeItemIndex ? 0 : -1;
|
||||
});
|
||||
if (focusItem && this._activeItemIndex >= 0) {
|
||||
this.items[this._activeItemIndex]?.focus();
|
||||
if (focusItem && this.activeItemIndex >= 0) {
|
||||
this.items[this.activeItemIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _onFocusIn = (ev: FocusEvent) => {
|
||||
protected onFocusIn = (ev: FocusEvent) => {
|
||||
const path = ev.composedPath();
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (path.includes(this.items[i])) {
|
||||
if (i !== this._activeItemIndex) {
|
||||
this._activeItemIndex = i;
|
||||
this._applyActive(false);
|
||||
if (i !== this.activeItemIndex) {
|
||||
this.activeItemIndex = i;
|
||||
this.applyActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _ignoreKeyEvent = (ev: KeyboardEvent): boolean => {
|
||||
if (ev.repeat && (ev.key === "Enter" || ev.key === " ")) {
|
||||
return true;
|
||||
}
|
||||
if (ev.isComposing) {
|
||||
return true;
|
||||
}
|
||||
const target = ev.target as HTMLElement | null;
|
||||
// Allow held arrow/Home/End to repeat for continuous navigation
|
||||
return (
|
||||
!!target &&
|
||||
target !== ev.currentTarget &&
|
||||
target.matches("[contenteditable],input,select,textarea")
|
||||
);
|
||||
};
|
||||
|
||||
private _onForward = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
|
||||
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, 1));
|
||||
};
|
||||
|
||||
private _onBack = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
|
||||
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, -1));
|
||||
};
|
||||
|
||||
private _onHome = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._firstFocusableIndex);
|
||||
this.moveFocus(ev, this.firstFocusableIndex);
|
||||
};
|
||||
|
||||
private _onEnd = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._lastFocusableIndex);
|
||||
this.moveFocus(ev, this.lastFocusableIndex);
|
||||
};
|
||||
|
||||
private _onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
private _onPageDown = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(
|
||||
ev,
|
||||
this._stepIndex(this.activeItemIndex, 1, this.getPageSize())
|
||||
);
|
||||
};
|
||||
|
||||
private _onPageUp = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(
|
||||
ev,
|
||||
this._stepIndex(this.activeItemIndex, -1, this.getPageSize())
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of items to jump for PageUp/PageDown. Defaults to 10 (per WAI-ARIA
|
||||
* Authoring Practices: "moves focus a manageable number of nodes,
|
||||
* typically 10"). Subclasses with a known viewport (e.g. virtualized lists)
|
||||
* can override to use the visible page size.
|
||||
*/
|
||||
protected getPageSize(): number {
|
||||
return 10;
|
||||
}
|
||||
|
||||
protected onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
const active = this.items[this._activeItemIndex];
|
||||
const active = this.items[this.activeItemIndex];
|
||||
active.activate();
|
||||
fireEvent(this, "ha-list-activated", {
|
||||
index: this._activeItemIndex,
|
||||
index: this.activeItemIndex,
|
||||
item: active,
|
||||
});
|
||||
};
|
||||
|
||||
private _moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
|
||||
protected moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this.hasFocusableItem) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._activeItemIndex = next;
|
||||
this._applyActive(true);
|
||||
if (next < 0 || next === this.activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = next;
|
||||
this.applyActive(true);
|
||||
}
|
||||
|
||||
protected get itemCount(): number {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step from `from` by `delta`, skipping non-interactive and disabled items.
|
||||
* Returns `from` when no other focusable item can be reached (honouring
|
||||
* `wrapFocus`).
|
||||
* Pass `count` > 1 to advance by multiple focusable items (PageUp/Down).
|
||||
* Returns the last focusable index reached, or `from` when none is.
|
||||
*/
|
||||
private _stepIndex(from: number, delta: 1 | -1): number {
|
||||
const n = this.items.length;
|
||||
if (!n || !this._hasFocusableItem) {
|
||||
private _stepIndex(from: number, delta: 1 | -1, count = 1): number {
|
||||
const n = this.itemCount;
|
||||
if (!n || !this.hasFocusableItem) {
|
||||
return from;
|
||||
}
|
||||
let last = from;
|
||||
let i = from;
|
||||
for (let step = 0; step < n; step++) {
|
||||
let landed = 0;
|
||||
for (let step = 0; step < n && landed < count; step++) {
|
||||
i += delta;
|
||||
if (i < 0 || i >= n) {
|
||||
if (!this.wrapFocus) {
|
||||
return from;
|
||||
return last;
|
||||
}
|
||||
i = (i + n) % n;
|
||||
}
|
||||
if (this._isFocusable(i)) {
|
||||
return i;
|
||||
if (this.isFocusable(i)) {
|
||||
last = i;
|
||||
landed++;
|
||||
}
|
||||
}
|
||||
return from;
|
||||
return last;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap, 0);
|
||||
padding: var(--ha-list-padding, 0);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap, 0);
|
||||
padding: var(--ha-list-padding, 0);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HaListNav extends HaListBase {
|
||||
part="nav"
|
||||
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
|
||||
>
|
||||
<div part="base" class="base" role="list">
|
||||
<div part="base" class="base ha-scrollbar" role="list">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</nav>`;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { Constructor } from "../../types";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import type { HaListBase } from "./ha-list-base";
|
||||
|
||||
export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
superClass: T
|
||||
) => {
|
||||
class SelectableClass extends superClass {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._onOptionClick);
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._onOptionClick);
|
||||
}
|
||||
|
||||
public updated(changed: Map<string, unknown>) {
|
||||
super.updated(changed);
|
||||
if (changed.has("multi")) {
|
||||
this.setAttribute(
|
||||
"aria-multiselectable",
|
||||
this.multi ? "true" : "false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook: index of a clicked option element, or `-1` if it's not ours. */
|
||||
protected optionIndexOf(opt: HaListItemOption): number {
|
||||
return this.items.indexOf(opt);
|
||||
}
|
||||
|
||||
public clearSelection() {
|
||||
(this.items as HaListItemOption[]).forEach((opt) => {
|
||||
if (opt.selected) {
|
||||
opt.toggleAttribute("selected", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _onOptionClick = (ev: Event) => {
|
||||
const path = ev.composedPath();
|
||||
for (const el of path) {
|
||||
if (el === this) {
|
||||
return;
|
||||
}
|
||||
if (el instanceof HaListItemOption) {
|
||||
if (el.disabled) {
|
||||
return;
|
||||
}
|
||||
const index = this.optionIndexOf(el);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.multi) {
|
||||
fireEvent(
|
||||
this,
|
||||
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
|
||||
index
|
||||
);
|
||||
el.toggleAttribute("selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.selected) {
|
||||
fireEvent(this, "ha-list-item-selected", index);
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return SelectableClass;
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
import { HaListVirtualized } from "./ha-list-virtualized";
|
||||
|
||||
/**
|
||||
* @element ha-list-selectable-virtualized
|
||||
* @extends {HaListVirtualized}
|
||||
*
|
||||
* @summary
|
||||
* Virtualized selection list (role `listbox`). Rows must render
|
||||
* `<ha-list-item-option>` as their top-level element. Selection is index-based:
|
||||
* clicking a row fires `ha-list-item-selected` / `ha-list-item-deselected` with
|
||||
* the row's index, and the row's `selected` attribute is toggled. Consumers own
|
||||
* the source of truth — set each row's `selected` from their own state (for
|
||||
* example, keyed by the option's `value`) and update it in the event handlers.
|
||||
*
|
||||
* Because selection is tracked per-row by the consumer, filtering the visible
|
||||
* `rows` doesn't affect selections for items outside the current view.
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once. In
|
||||
* single-select mode, selecting a row clears any previous selection.
|
||||
*
|
||||
* @fires ha-list-item-selected - Fires when the user selects a row.
|
||||
* `detail` is the row's index (number).
|
||||
* @fires ha-list-item-deselected - Fires when the user deselects a row (multi-select only).
|
||||
* `detail` is the row's index (number).
|
||||
*/
|
||||
@customElement("ha-list-selectable-virtualized")
|
||||
export class HaListSelectableVirtualized extends SelectableMixin(
|
||||
HaListVirtualized
|
||||
) {
|
||||
/**
|
||||
* Hook: maps a clicked option to its absolute index by offsetting its
|
||||
* position among the rendered (virtualized) children by `rangeStart`.
|
||||
* Returns `-1` if it's not one of our rows or nothing is rendered yet.
|
||||
*/
|
||||
protected optionIndexOf(opt: HaListItemOption): number {
|
||||
if (!this.virtualizerElement || this.rangeStart === -1) {
|
||||
return -1;
|
||||
}
|
||||
const index = Array.from(this.virtualizerElement.children).indexOf(opt);
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
return this.rangeStart + index;
|
||||
}
|
||||
|
||||
/** Deselects every currently rendered (visible) option. */
|
||||
public clearSelection() {
|
||||
if (!this.virtualizerElement || this.rangeStart === -1) {
|
||||
return;
|
||||
}
|
||||
Array.from(this.virtualizerElement.children).forEach((opt) => {
|
||||
if (opt instanceof HaListItemOption && opt.selected) {
|
||||
opt.toggleAttribute("selected", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-selectable-virtualized": HaListSelectableVirtualized;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
import type { HaListSelectedDetail } from "./types";
|
||||
import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
|
||||
/**
|
||||
* @element ha-list-selectable
|
||||
@@ -14,196 +12,11 @@ import type { HaListSelectedDetail } from "./types";
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
*
|
||||
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
|
||||
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
|
||||
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
|
||||
*/
|
||||
@customElement("ha-list-selectable")
|
||||
export class HaListSelectable extends HaListBase {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
private _selectedIndices?: Set<number>;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._onOptionClick);
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._onOptionClick);
|
||||
}
|
||||
|
||||
public updated(changed: Map<string, unknown>) {
|
||||
super.updated(changed);
|
||||
if (changed.has("multi")) {
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
|
||||
const first = Math.min(...this._selectedIndices!);
|
||||
this._setSelection(new Set([first]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current selection. `number` (or `-1` if nothing) when single,
|
||||
* `Set<number>` when multi.
|
||||
*/
|
||||
public get selected(): number | Set<number> {
|
||||
if (this.multi) {
|
||||
return new Set(this._selectedIndices);
|
||||
}
|
||||
return (this._selectedIndices?.size ?? 0) === 0
|
||||
? -1
|
||||
: this._selectedIndices!.values().next().value!;
|
||||
}
|
||||
|
||||
public get selectedItems(): HaListItemOption[] {
|
||||
return this._sortedSelectedIndices()
|
||||
.map((i) => this.items[i] as HaListItemOption | undefined)
|
||||
.filter((it): it is HaListItemOption => !!it);
|
||||
}
|
||||
|
||||
/** Replace the entire selection. */
|
||||
public setSelected(indices: number | number[] | Set<number>): void {
|
||||
const next =
|
||||
typeof indices === "number"
|
||||
? indices < 0
|
||||
? new Set<number>()
|
||||
: new Set([indices])
|
||||
: new Set(indices);
|
||||
if (!this.multi && next.size > 1) {
|
||||
const first = Math.min(...next);
|
||||
this._setSelection(new Set([first]));
|
||||
return;
|
||||
}
|
||||
this._setSelection(next);
|
||||
}
|
||||
|
||||
public select(index: number): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
next.add(index);
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
this._setSelection(new Set([index]));
|
||||
}
|
||||
}
|
||||
|
||||
public toggle(index: number, force?: boolean): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
const isSelected = next.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
if (shouldSelect) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
const isSelected = this._selectedIndices!.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
this._setSelection(shouldSelect ? new Set([index]) : new Set());
|
||||
}
|
||||
}
|
||||
|
||||
public clearSelection(): void {
|
||||
this._setSelection(new Set());
|
||||
}
|
||||
|
||||
public updateListItems() {
|
||||
super.updateListItems();
|
||||
this._syncItemSelectedState(true);
|
||||
}
|
||||
|
||||
private _sortedSelectedIndices(): number[] {
|
||||
return [...this._selectedIndices!].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private _syncItemSelectedState(reset = false): void {
|
||||
if (!this._selectedIndices || reset) {
|
||||
this._selectedIndices = new Set<number>();
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
if (opt.selected) {
|
||||
this._selectedIndices!.add(i);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
const shouldBe = this._selectedIndices!.has(i);
|
||||
if (opt.selected !== shouldBe) {
|
||||
opt.selected = shouldBe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _setSelection(next: Set<number>): void {
|
||||
const prev = this._selectedIndices!;
|
||||
const added = new Set<number>();
|
||||
const removed = new Set<number>();
|
||||
next.forEach((i) => {
|
||||
if (!prev.has(i)) {
|
||||
added.add(i);
|
||||
}
|
||||
});
|
||||
prev.forEach((i) => {
|
||||
if (!next.has(i)) {
|
||||
removed.add(i);
|
||||
}
|
||||
});
|
||||
if (!added.size && !removed.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedIndices = next;
|
||||
this._syncItemSelectedState();
|
||||
|
||||
const detail: HaListSelectedDetail = this.multi
|
||||
? { index: new Set(next), diff: { added, removed } }
|
||||
: {
|
||||
index: next.size === 0 ? -1 : next.values().next().value!,
|
||||
diff: { added, removed },
|
||||
};
|
||||
fireEvent(this, "ha-list-selected", detail);
|
||||
}
|
||||
|
||||
private _onOptionClick = (ev: Event) => {
|
||||
const path = ev.composedPath();
|
||||
for (const el of path) {
|
||||
if (el === this) {
|
||||
return;
|
||||
}
|
||||
if (el instanceof HaListItemOption) {
|
||||
const index = this.items.indexOf(el);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const item = this.items[index];
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
this.toggle(index);
|
||||
} else {
|
||||
this.select(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
export class HaListSelectable extends SelectableMixin(HaListBase) {}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize.js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
|
||||
/**
|
||||
* A single row in a {@link HaListVirtualized}. Identified by a stable `id`
|
||||
* used as the virtualizer key. Extra fields are passed through to the
|
||||
* `rowRenderer`.
|
||||
*/
|
||||
export interface HaListVirtualizedItem {
|
||||
/** Stable key used by the virtualizer to track the row across re-renders. */
|
||||
id: string;
|
||||
/** Whether the row can be focused and activated. Defaults to `false`. */
|
||||
interactive?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @element ha-list-virtualized
|
||||
* @extends {HaListBase}
|
||||
*
|
||||
* @summary
|
||||
* Virtualized list. Renders only the rows currently in view to keep large
|
||||
* lists performant, while preserving the roving-tabindex keyboard navigation
|
||||
* of {@link HaListBase}.
|
||||
*
|
||||
* @csspart base - The scrollable outer container (`<div>`).
|
||||
*
|
||||
* @attr {number} pin-index - Row index to scroll to when the list first
|
||||
* renders. Cleared once the user scrolls.
|
||||
* @attr {string} pin-block - Block alignment for `pin-index`: `start`,
|
||||
* `center` (default), `end`, or `nearest`.
|
||||
*
|
||||
* @fires ha-list-activated - Fired when a row is activated via Enter/Space. `detail: { index, item }`.
|
||||
*/
|
||||
@customElement("ha-list-virtualized")
|
||||
export class HaListVirtualized extends HaListBase {
|
||||
@state() private _virtualizerReady = false;
|
||||
|
||||
/**
|
||||
* The list data. Each item is rendered by `rowRenderer`; its `interactive`
|
||||
* and `disabled` flags determine whether the row is focusable.
|
||||
*/
|
||||
@property({ attribute: false })
|
||||
public rows!: HaListVirtualizedItem[];
|
||||
|
||||
/** Renders a single row from its data and index. */
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
|
||||
|
||||
/** Row index to scroll to on first render (the "pinned" row). */
|
||||
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
|
||||
|
||||
/** Block alignment used when scrolling to `pinIndex`. */
|
||||
@property({ attribute: "pin-block" }) public pinBlock:
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "nearest" = "center";
|
||||
|
||||
@state() private _unpinned = false;
|
||||
|
||||
@query("lit-virtualizer")
|
||||
protected virtualizerElement?: LitVirtualizer<HaListVirtualizedItem>;
|
||||
|
||||
protected rangeStart = -1;
|
||||
protected rangeEnd = -1;
|
||||
private _activeItemFocus = false;
|
||||
private _scrollToActiveItem = false;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this._loadVirtualizer();
|
||||
}
|
||||
|
||||
if (changedProps.has("rows")) {
|
||||
this.recomputeFocusableIndexes();
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadVirtualizer() {
|
||||
await loadVirtualizer();
|
||||
this._virtualizerReady = true;
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult | typeof nothing {
|
||||
if (!this._virtualizerReady) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div part="base" class="base ha-scrollbar">
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="-1"
|
||||
scroller
|
||||
.items=${this.rows}
|
||||
.renderItem=${this.rowRenderer}
|
||||
style="min-height: 36px; height: 100%;"
|
||||
.layout=${!this._unpinned && this.pinIndex !== undefined
|
||||
? {
|
||||
pin: {
|
||||
index: this.pinIndex,
|
||||
block: this.pinBlock,
|
||||
},
|
||||
}
|
||||
: undefined}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@rangeChanged=${this._handleRangeChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the active (roving-tabindex) row. If the row is outside the rendered
|
||||
* range it is scrolled into view first, then activated/focused once the
|
||||
* virtualizer has laid it out.
|
||||
* @param index - Row index to make active; clamped to the valid range.
|
||||
* @param focusItem - Whether to move DOM focus to the row.
|
||||
*/
|
||||
public setActiveItemIndex(index: number, focusItem = false) {
|
||||
if (!this.hasFocusableItem) {
|
||||
this.activeItemIndex = -1;
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = Math.max(0, Math.min(this.rows.length - 1, index));
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
if (
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd
|
||||
) {
|
||||
this.applyActive(focusItem);
|
||||
} else {
|
||||
this._activeItemFocus = focusItem;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement
|
||||
?.element(index)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the row at `index`, scrolling it into view if needed. No-op until
|
||||
* the virtualizer is ready or when `index` is negative.
|
||||
*/
|
||||
public override focusItemAtIndex(index: number) {
|
||||
if (!this._virtualizerReady || index < 0) {
|
||||
return;
|
||||
}
|
||||
this.setActiveItemIndex(index, true);
|
||||
}
|
||||
|
||||
protected override applyActive(focusItem: boolean) {
|
||||
if (this.virtualizerElement && this.rangeStart > -1) {
|
||||
Array.from(this.virtualizerElement.children).forEach((child, index) => {
|
||||
const el = child as HTMLElement;
|
||||
if (index + this.rangeStart === this.activeItemIndex) {
|
||||
el.tabIndex = 0;
|
||||
if (focusItem) {
|
||||
el.focus();
|
||||
}
|
||||
} else {
|
||||
el.removeAttribute("tabindex");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private async _handleRangeChanged(ev: { first: number; last: number }) {
|
||||
this.rangeStart = ev.first;
|
||||
this.rangeEnd = ev.last;
|
||||
|
||||
await this.virtualizerElement?.layoutComplete;
|
||||
this._applySetSize();
|
||||
|
||||
if (!this.virtualizerElement) {
|
||||
return;
|
||||
}
|
||||
const inRange =
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd;
|
||||
const focus = this._scrollToActiveItem && inRange && this._activeItemFocus;
|
||||
this.applyActive(focus);
|
||||
if (this._scrollToActiveItem && inRange) {
|
||||
this._activeItemFocus = false;
|
||||
this._scrollToActiveItem = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose total count + position to assistive tech, since only a slice of
|
||||
// items is in the DOM at any time.
|
||||
private _applySetSize() {
|
||||
if (!this.virtualizerElement || this.rangeStart < 0) {
|
||||
return;
|
||||
}
|
||||
const total = this.rows?.length ?? 0;
|
||||
Array.from(this.virtualizerElement.children).forEach((child, index) => {
|
||||
const el = child as HTMLElement;
|
||||
el.setAttribute("aria-setsize", String(total));
|
||||
el.setAttribute("aria-posinset", String(this.rangeStart + index + 1));
|
||||
});
|
||||
}
|
||||
|
||||
protected onFocusIn = (ev: FocusEvent) => {
|
||||
if (
|
||||
!this.virtualizerElement ||
|
||||
this.rangeStart === -1 ||
|
||||
this.rangeEnd === -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const path = ev.composedPath();
|
||||
const children = Array.from(this.virtualizerElement.children);
|
||||
for (let i = this.rangeStart; i <= this.rangeEnd; i++) {
|
||||
if (path.includes(children[i - this.rangeStart])) {
|
||||
if (i !== this.activeItemIndex) {
|
||||
this.activeItemIndex = i;
|
||||
if (i < this.rangeStart || i > this.rangeEnd) {
|
||||
this._activeItemFocus = true;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement
|
||||
?.element(this.activeItemIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
this.applyActive(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected override onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.virtualizerElement &&
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd
|
||||
) {
|
||||
const active = this.virtualizerElement?.children[
|
||||
this.activeItemIndex - this.rangeStart
|
||||
] as HaListItemBase | undefined;
|
||||
if (active && active instanceof HaListItemBase) {
|
||||
ev.preventDefault();
|
||||
active.activate();
|
||||
fireEvent(this, "ha-list-activated", {
|
||||
index: this.activeItemIndex,
|
||||
item: active,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected isFocusable(index: number): boolean {
|
||||
const item = this.rows[index];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
const { disabled = false, interactive = false } = this.rows[index];
|
||||
return interactive && !disabled;
|
||||
}
|
||||
|
||||
protected override get itemCount(): number {
|
||||
return this.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
protected override moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this.hasFocusableItem) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (next < 0 || next === this.activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = next;
|
||||
if (next < this.rangeStart || next > this.rangeEnd) {
|
||||
this._activeItemFocus = true;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement?.element(this.activeItemIndex)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
} else {
|
||||
this.applyActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override getPageSize(): number {
|
||||
if (this.rangeStart < 0 || this.rangeEnd < 0) {
|
||||
return super.getPageSize();
|
||||
}
|
||||
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
|
||||
}
|
||||
|
||||
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _handleUnpinned() {
|
||||
this._unpinned = true;
|
||||
}
|
||||
|
||||
protected override onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
protected override onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
// ignore
|
||||
};
|
||||
|
||||
static styles = [
|
||||
...HaListBase.styles,
|
||||
css`
|
||||
.base {
|
||||
height: 100%;
|
||||
}
|
||||
[ha-list-item] {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-virtualized": HaListVirtualized;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
|
||||
export interface HaListSelectedDetail {
|
||||
index: number | Set<number>;
|
||||
diff?: { added: Set<number>; removed: Set<number> };
|
||||
value?: string | string[];
|
||||
}
|
||||
|
||||
export interface HaListActivatedDetail {
|
||||
index: number;
|
||||
item: HaListItemBase;
|
||||
@@ -17,7 +11,8 @@ export interface HaListItemRegistrationDetail {
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-list-selected": HaListSelectedDetail;
|
||||
"ha-list-item-selected": number;
|
||||
"ha-list-item-deselected": number;
|
||||
"ha-list-activated": HaListActivatedDetail;
|
||||
"ha-list-item-register": HaListItemRegistrationDetail;
|
||||
"ha-list-item-unregister": HaListItemRegistrationDetail;
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import type { HomeAssistant, ThemeMode } from "../../types";
|
||||
@@ -584,18 +585,17 @@ export class HaMap extends ReactiveElement {
|
||||
const customTitle = typeof entity !== "string" ? entity.name : undefined;
|
||||
const title = customTitle ?? computeStateName(stateObj);
|
||||
const {
|
||||
latitude,
|
||||
longitude,
|
||||
passive,
|
||||
icon,
|
||||
radius,
|
||||
entity_picture: entityPicture,
|
||||
gps_accuracy: gpsAccuracy,
|
||||
} = stateObj.attributes;
|
||||
|
||||
if (!(latitude && longitude)) {
|
||||
const location = getEntityLocation(stateObj, hass.states);
|
||||
if (!location) {
|
||||
continue;
|
||||
}
|
||||
const { latitude, longitude, gpsAccuracy } = location;
|
||||
|
||||
if (computeStateDomain(stateObj) === "zone") {
|
||||
// DRAW ZONE
|
||||
|
||||
@@ -38,7 +38,7 @@ class MediaManageButton extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-button appearance="filled" size="small" @click=${this._manage}>
|
||||
<ha-button appearance="filled" size="s" @click=${this._manage}>
|
||||
<ha-svg-icon .path=${mdiFolderEdit} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.components.media-browser.file_management.manage"
|
||||
|
||||
@@ -150,8 +150,15 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
parts.push(
|
||||
data.map((trace, idx) => {
|
||||
const { path, timestamp, result, error, changed_variables, ...rest } =
|
||||
trace as any;
|
||||
const {
|
||||
path,
|
||||
timestamp,
|
||||
result,
|
||||
error,
|
||||
template_errors,
|
||||
changed_variables,
|
||||
...rest
|
||||
} = trace as any;
|
||||
|
||||
if (result?.enabled === false) {
|
||||
return html`${this.hass!.localize(
|
||||
@@ -240,6 +247,18 @@ export class HaTracePathDetails extends LitElement {
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
${template_errors?.length
|
||||
? html`<div class="error">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.template_errors"
|
||||
)}
|
||||
<ul>
|
||||
${template_errors.map(
|
||||
(templateError: string) => html`<li>${templateError}</li>`
|
||||
)}
|
||||
</ul>
|
||||
</div>`
|
||||
: nothing}
|
||||
${result
|
||||
? html`${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.result"
|
||||
@@ -406,6 +425,11 @@ export class HaTracePathDetails extends LitElement {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.error ul {
|
||||
margin: var(--ha-space-1) 0;
|
||||
padding-left: var(--ha-space-6);
|
||||
}
|
||||
|
||||
ha-tab-group {
|
||||
background-color: var(--primary-background-color);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../types";
|
||||
import type { DeviceRegistryEntry } from "../device/device_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
@@ -39,7 +39,7 @@ export interface AreaRegistryEntryMutableParams {
|
||||
}
|
||||
|
||||
export const createAreaRegistryEntry = (
|
||||
hass: HomeAssistant,
|
||||
hass: HomeAssistantApi,
|
||||
values: AreaRegistryEntryMutableParams
|
||||
) =>
|
||||
hass.callWS<AreaRegistryEntry>({
|
||||
|
||||
@@ -194,6 +194,7 @@ export interface ControlButton {
|
||||
icon: string;
|
||||
// Used as key for action as well as tooltip and aria-label translation key
|
||||
action: keyof TranslationDict["ui"]["card"]["media_player"];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface MediaPlayerItem {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface BaseTraceStep {
|
||||
path: string;
|
||||
timestamp: string;
|
||||
error?: string;
|
||||
template_errors?: string[];
|
||||
changed_variables?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ class EntityPreviewRow extends LitElement {
|
||||
|
||||
if (domain === "button") {
|
||||
return html`
|
||||
<ha-button appearance="plain" size="small" .disabled=${disabled}>
|
||||
<ha-button appearance="plain" size="s" .disabled=${disabled}>
|
||||
${this.hass.localize("ui.card.button.press")}
|
||||
</ha-button>
|
||||
`;
|
||||
@@ -237,7 +237,7 @@ class EntityPreviewRow extends LitElement {
|
||||
.disabled=${disabled}
|
||||
class="text-content"
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${stateObj.state === "locked"
|
||||
? this.hass!.localize("ui.card.lock.unlock")
|
||||
|
||||
@@ -182,7 +182,6 @@ class StepFlowCreateEntry extends LitElement {
|
||||
.device=${device.id}
|
||||
></ha-input>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
.device=${device.id}
|
||||
.value=${this._deviceUpdate[device.id]?.area ??
|
||||
device.area_id ??
|
||||
|
||||
@@ -214,7 +214,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._openSegmentMapping}
|
||||
>
|
||||
<ha-svg-icon
|
||||
|
||||
@@ -31,7 +31,7 @@ class MoreInfoAutomation extends LitElement {
|
||||
<div class="actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._runActions}
|
||||
.disabled=${this.stateObj!.state === UNAVAILABLE}
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@ class MoreInfoCounter extends LitElement {
|
||||
<div class="actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"increment"}
|
||||
@click=${this._handleActionClick}
|
||||
.disabled=${disabled ||
|
||||
@@ -32,7 +32,7 @@ class MoreInfoCounter extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"decrement"}
|
||||
@click=${this._handleActionClick}
|
||||
.disabled=${disabled ||
|
||||
@@ -42,7 +42,7 @@ class MoreInfoCounter extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"reset"}
|
||||
@click=${this._handleActionClick}
|
||||
.disabled=${disabled}
|
||||
|
||||
@@ -431,7 +431,7 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
? html`<ha-button
|
||||
variant="brand"
|
||||
appearance="filled"
|
||||
size="medium"
|
||||
size="m"
|
||||
action=${action}
|
||||
@click=${this._handleClick}
|
||||
class="center-control"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { getEntityLocation } from "../../../common/entity/get_entity_location";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/map/ha-map";
|
||||
import { showZoneEditor } from "../../../data/zone";
|
||||
@@ -21,8 +22,13 @@ class MoreInfoPerson extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const location = getEntityLocation(this.stateObj, this.hass.states);
|
||||
const hasOwnCoordinates =
|
||||
typeof this.stateObj.attributes.latitude === "number" &&
|
||||
typeof this.stateObj.attributes.longitude === "number";
|
||||
|
||||
return html`
|
||||
${this.stateObj.attributes.latitude && this.stateObj.attributes.longitude
|
||||
${location
|
||||
? html`
|
||||
<ha-map
|
||||
.hass=${this.hass}
|
||||
@@ -31,15 +37,12 @@ class MoreInfoPerson extends LitElement {
|
||||
></ha-map>
|
||||
`
|
||||
: ""}
|
||||
${!__DEMO__ &&
|
||||
this.hass.user?.is_admin &&
|
||||
this.stateObj.attributes.latitude &&
|
||||
this.stateObj.attributes.longitude
|
||||
${!__DEMO__ && this.hass.user?.is_admin && hasOwnCoordinates
|
||||
? html`
|
||||
<div class="actions">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._handleAction}
|
||||
>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -52,7 +52,7 @@ class MoreInfoSiren extends LitElement {
|
||||
${allowAdvanced
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._showAdvancedControlsDialog}
|
||||
>
|
||||
${this.hass.localize("ui.components.siren.advanced_controls")}
|
||||
|
||||
@@ -21,7 +21,7 @@ class MoreInfoTimer extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"start"}
|
||||
@click=${this._handleActionClick}
|
||||
>
|
||||
@@ -33,7 +33,7 @@ class MoreInfoTimer extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"pause"}
|
||||
@click=${this._handleActionClick}
|
||||
>
|
||||
@@ -45,7 +45,7 @@ class MoreInfoTimer extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"cancel"}
|
||||
@click=${this._handleActionClick}
|
||||
>
|
||||
@@ -53,7 +53,7 @@ class MoreInfoTimer extends LitElement {
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.action=${"finish"}
|
||||
@click=${this._handleActionClick}
|
||||
>
|
||||
|
||||
@@ -31,10 +31,7 @@ export class HaVoiceAssistantSetupStepArea extends LitElement {
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.area.secondary"
|
||||
)}
|
||||
</p>
|
||||
<ha-area-picker
|
||||
.hass=${this.hass}
|
||||
.value=${device.area_id}
|
||||
></ha-area-picker>
|
||||
<ha-area-picker .value=${device.area_id}></ha-area-picker>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._setArea}
|
||||
|
||||
@@ -95,10 +95,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.local.failed_secondary"
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._prevStep}
|
||||
<ha-button appearance="plain" size="s" @click=${this._prevStep}
|
||||
>${this.hass.localize("ui.common.back")}</ha-button
|
||||
>
|
||||
<ha-button
|
||||
@@ -108,7 +105,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiOpenInNew} slot="start"></ha-svg-icon>
|
||||
@@ -131,10 +128,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.local.not_supported_secondary"
|
||||
)}
|
||||
</p>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._prevStep}
|
||||
<ha-button appearance="plain" size="s" @click=${this._prevStep}
|
||||
>${this.hass.localize("ui.common.back")}</ha-button
|
||||
>
|
||||
<ha-button
|
||||
@@ -145,7 +139,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiOpenInNew} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -131,7 +131,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
></ha-select>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._testWakeWord}
|
||||
>
|
||||
<ha-svg-icon
|
||||
@@ -166,7 +166,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
</ha-select>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._openPipeline}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
@@ -186,11 +186,7 @@ export class HaVoiceAssistantSetupStepSuccess extends LitElement {
|
||||
@value-changed=${this._voicePicked}
|
||||
@closed=${stopPropagation}
|
||||
></ha-tts-voice-picker>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._testTts}
|
||||
>
|
||||
<ha-button appearance="plain" size="s" @click=${this._testTts}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.success.try_tts"
|
||||
|
||||
@@ -150,7 +150,7 @@ export class HaVoiceAssistantSetupStepWakeWord extends LitElement {
|
||||
? html`<div class="footer centered">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._changeWakeWord}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.voice_assistants.satellite_wizard.wake_word.change_wake_word"
|
||||
|
||||
@@ -117,7 +117,7 @@ export class HaVoiceCommandDialog extends LitElement {
|
||||
slot="trigger"
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
size="s"
|
||||
.loading=${!this._pipelines}
|
||||
>
|
||||
${this._pipeline?.name}
|
||||
|
||||
@@ -23,7 +23,7 @@ class HaInitPage extends LitElement {
|
||||
<p class="retry-text">
|
||||
Retrying in ${this._retryInSeconds} seconds...
|
||||
</p>
|
||||
<ha-button size="small" appearance="plain" @click=${this._retry}
|
||||
<ha-button size="s" appearance="plain" @click=${this._retry}
|
||||
>Retry now</ha-button
|
||||
>
|
||||
${location.host.includes("ui.nabu.casa")
|
||||
|
||||
@@ -41,7 +41,7 @@ class HassErrorScreen extends LitElement {
|
||||
<div class="content">
|
||||
<ha-alert alert-type="error">${this.error}</ha-alert>
|
||||
<slot>
|
||||
<ha-button appearance="plain" size="small" @click=${this._handleBack}>
|
||||
<ha-button appearance="plain" size="s" @click=${this._handleBack}>
|
||||
${this.hass?.localize("ui.common.back")}
|
||||
</ha-button>
|
||||
</slot>
|
||||
|
||||
@@ -97,7 +97,7 @@ class NotificationManager extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._buttonClicked}
|
||||
>
|
||||
|
||||
@@ -132,7 +132,7 @@ class OnboardingRestoreBackupRestore extends LitElement {
|
||||
href="https://www.home-assistant.io/installation/#advanced-installation-methods"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${this.localize(
|
||||
"ui.panel.page-onboarding.restore.ha-cloud.learn_more"
|
||||
|
||||
@@ -147,7 +147,7 @@ export class HAFullCalendar extends LitElement {
|
||||
<div class="navigation">
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="small"
|
||||
size="s"
|
||||
class="today"
|
||||
@click=${this._handleToday}
|
||||
>${this.hass.localize(
|
||||
@@ -171,7 +171,7 @@ export class HAFullCalendar extends LitElement {
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="small"
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
@@ -197,7 +197,7 @@ export class HAFullCalendar extends LitElement {
|
||||
<div class="controls buttons">
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
class="today"
|
||||
@click=${this._handleToday}
|
||||
>${this.hass.localize(
|
||||
@@ -207,7 +207,7 @@ export class HAFullCalendar extends LitElement {
|
||||
<ha-button-toggle-group
|
||||
.buttons=${viewToggleButtons}
|
||||
.active=${this._activeView}
|
||||
size="small"
|
||||
size="s"
|
||||
no-wrap
|
||||
@value-changed=${this._handleView}
|
||||
></ha-button-toggle-group>
|
||||
@@ -219,7 +219,7 @@ export class HAFullCalendar extends LitElement {
|
||||
|
||||
<div id="calendar"></div>
|
||||
${this.addFab && this._hasMutableCalendars
|
||||
? html`<ha-button size="large" slot="fab" @click=${this._createEvent}>
|
||||
? html`<ha-button size="l" slot="fab" @click=${this._createEvent}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.calendar.event.add")}
|
||||
</ha-button>`
|
||||
|
||||
@@ -175,7 +175,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._deleteSelected}
|
||||
variant="danger"
|
||||
>${this.hass.localize(
|
||||
@@ -199,11 +199,7 @@ export class HaConfigApplicationCredentials extends LitElement {
|
||||
</ha-help-tooltip>
|
||||
`}
|
||||
</div>
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
@click=${this._addApplicationCredential}
|
||||
>
|
||||
<ha-button slot="fab" size="l" @click=${this._addApplicationCredential}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.application_credentials.picker.add_application_credential"
|
||||
|
||||
@@ -156,7 +156,7 @@ export class HaConfigAppsInstalled extends LitElement {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ha-button size="large" href="/config/apps/available">
|
||||
<ha-button size="l" href="/config/apps/available">
|
||||
<ha-svg-icon slot="start" .path=${mdiStorePlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.apps.installed.add_app")}
|
||||
</ha-button>
|
||||
@@ -295,7 +295,7 @@ export class HaConfigAppsInstalled extends LitElement {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ha-button[size="large"] {
|
||||
ha-button[size="l"] {
|
||||
position: fixed;
|
||||
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
|
||||
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
|
||||
@@ -116,7 +116,7 @@ export class HaConfigAppsRegistries extends LitElement {
|
||||
id="registry"
|
||||
has-fab
|
||||
></ha-data-table>
|
||||
<ha-button size="large" @click=${this._showAddRegistryDialog}>
|
||||
<ha-button size="l" @click=${this._showAddRegistryDialog}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.apps.registries.add")}
|
||||
</ha-button>
|
||||
@@ -184,7 +184,7 @@ export class HaConfigAppsRegistries extends LitElement {
|
||||
ha-icon-button.delete {
|
||||
color: var(--error-color);
|
||||
}
|
||||
ha-button[size="large"] {
|
||||
ha-button[size="l"] {
|
||||
position: fixed;
|
||||
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
|
||||
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
|
||||
@@ -195,7 +195,7 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
id="slug"
|
||||
has-fab
|
||||
></ha-data-table>
|
||||
<ha-button size="large" @click=${this._showAddRepositoryDialog}>
|
||||
<ha-button size="l" @click=${this._showAddRepositoryDialog}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.apps.repositories.add")}
|
||||
</ha-button>
|
||||
@@ -292,7 +292,7 @@ export class HaConfigAppsRepositories extends LitElement {
|
||||
ha-icon-button.delete {
|
||||
color: var(--error-color);
|
||||
}
|
||||
ha-button[size="large"] {
|
||||
ha-button[size="l"] {
|
||||
position: fixed;
|
||||
right: calc(var(--ha-space-4) + var(--safe-area-inset-right));
|
||||
bottom: calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
|
||||
|
||||
@@ -169,6 +169,9 @@ export class SupervisorAppsRepositoryEl extends LitElement {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
ha-card:hover {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
.card-content.has-footer {
|
||||
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,6 @@ class DialogFloorDetail extends LitElement {
|
||||
</p>`}
|
||||
<ha-area-picker
|
||||
no-add
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._addArea}
|
||||
.excludeAreas=${areas.map((a) => a.area_id)}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
|
||||
@@ -22,6 +22,10 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
@@ -33,7 +37,6 @@ import { afterNextRender } from "../../../common/util/render-status";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
@@ -240,6 +243,10 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("areaId")) {
|
||||
this._findRelated();
|
||||
fireEvent(this, "hass-related-context", {
|
||||
itemType: "area",
|
||||
itemId: this.areaId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-dropdown slot="fab" @wa-select=${this._handleCreateAction}>
|
||||
<ha-button slot="trigger" id="fab" size="large">
|
||||
<ha-button slot="trigger" id="fab" size="l">
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.common.add")}
|
||||
</ha-button>
|
||||
|
||||
@@ -120,7 +120,7 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addActionDialog}
|
||||
.appearance=${this.root ? "accent" : "filled"}
|
||||
.size=${this.root ? "medium" : "small"}
|
||||
.size=${this.root ? "m" : "s"}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -728,7 +728,7 @@ class DialogAddAutomationElement
|
||||
active-variant="brand"
|
||||
.buttons=${tabButtons}
|
||||
.active=${this._tab}
|
||||
size="small"
|
||||
size="s"
|
||||
full-width
|
||||
@value-changed=${this._switchTab}
|
||||
></ha-button-toggle-group>`
|
||||
|
||||
@@ -194,7 +194,6 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
||||
${this._visibleOptionals.includes("area")
|
||||
? html` <ha-area-picker
|
||||
id="area"
|
||||
.hass=${this.hass}
|
||||
.value=${this._entryUpdates.area}
|
||||
@value-changed=${this._registryEntryChanged}
|
||||
></ha-area-picker>`
|
||||
|
||||
@@ -35,7 +35,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
|
||||
)}
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._enable}
|
||||
>
|
||||
@@ -57,7 +57,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
|
||||
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
size="l"
|
||||
class=${this.dirty ? "dirty" : ""}
|
||||
.disabled=${this.saving}
|
||||
@click=${this._saveAutomation}
|
||||
|
||||
@@ -294,7 +294,7 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addConditionDialog}
|
||||
.appearance=${this.root ? "accent" : "filled"}
|
||||
.size=${this.root ? "medium" : "small"}
|
||||
.size=${this.root ? "m" : "s"}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
|
||||
@@ -237,7 +237,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
@click=${this._showTrace}
|
||||
slot="toolbar-icon"
|
||||
>
|
||||
@@ -490,7 +490,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
)}
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="small"
|
||||
size="s"
|
||||
variant="warning"
|
||||
slot="action"
|
||||
@click=${this._duplicate}
|
||||
@@ -508,7 +508,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
"ui.panel.config.automation.editor.disabled"
|
||||
)}
|
||||
<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._toggle}
|
||||
>
|
||||
@@ -533,7 +533,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
)}
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="small"
|
||||
size="s"
|
||||
slot="action"
|
||||
@click=${this._toggle}
|
||||
>
|
||||
@@ -553,7 +553,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
></ha-yaml-editor>
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
size="l"
|
||||
class=${this.dirty ? "dirty" : ""}
|
||||
.disabled=${this.saving}
|
||||
@click=${this._handleSaveAutomation}
|
||||
|
||||
@@ -23,11 +23,7 @@ export class HaAutomationNote extends LitElement {
|
||||
"ui.panel.config.automation.editor.note.label"
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
@click=${this._handleClick}
|
||||
size="small"
|
||||
appearance="plain"
|
||||
>
|
||||
<ha-button @click=${this._handleClick} size="s" appearance="plain">
|
||||
${this._i18n.localize("ui.common.edit")}
|
||||
</ha-button>
|
||||
</div>
|
||||
|
||||
@@ -695,14 +695,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
target="_blank"
|
||||
appearance="plain"
|
||||
rel="noreferrer"
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.common.learn_more")}
|
||||
<ha-svg-icon slot="end" .path=${mdiOpenInNew}> </ha-svg-icon>
|
||||
</ha-button>
|
||||
</div>`
|
||||
: nothing}
|
||||
<ha-button slot="fab" size="large" @click=${this._createNew}>
|
||||
<ha-button slot="fab" size="l" @click=${this._createNew}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.picker.add_automation"
|
||||
|
||||
@@ -142,21 +142,42 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
|
||||
value: PromiseLike<EntityRegistryEntry> | EntityRegistryEntry
|
||||
) => void;
|
||||
|
||||
private _relatedContextAreaId?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("registryEntry")) {
|
||||
const areaId = this.registryEntry?.area_id;
|
||||
if (areaId) {
|
||||
fireEvent(this, "hass-related-context", {
|
||||
itemType: "area",
|
||||
itemId: areaId,
|
||||
});
|
||||
} else {
|
||||
fireEvent(this, "hass-related-context", undefined);
|
||||
}
|
||||
if (
|
||||
changedProps.has("currentEntityId") ||
|
||||
changedProps.has("entityRegistry")
|
||||
) {
|
||||
this._setRelatedContext();
|
||||
}
|
||||
}
|
||||
|
||||
private _setRelatedContext(): void {
|
||||
const areaId = this.currentEntityId
|
||||
? this.entityRegistry?.find(
|
||||
({ entity_id }) => entity_id === this.currentEntityId
|
||||
)?.area_id || undefined
|
||||
: undefined;
|
||||
|
||||
if (areaId === this._relatedContextAreaId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._relatedContextAreaId = areaId;
|
||||
fireEvent(
|
||||
this,
|
||||
"hass-related-context",
|
||||
areaId
|
||||
? {
|
||||
itemType: "area",
|
||||
itemId: areaId,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
protected renderLoading(): TemplateResult {
|
||||
return html`
|
||||
<ha-fade-in .delay=${500}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiDotsVertical,
|
||||
mdiDownload,
|
||||
@@ -33,6 +34,8 @@ import type {
|
||||
NodeInfo,
|
||||
} from "../../../components/trace/hat-script-graph";
|
||||
import type { AutomationEntity } from "../../../data/automation";
|
||||
import { fullEntitiesContext } from "../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../../data/logbook";
|
||||
import { getLogbookDataForContext } from "../../../data/logbook";
|
||||
import type {
|
||||
@@ -63,6 +66,10 @@ export class HaAutomationTrace extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public route!: Route;
|
||||
|
||||
@state()
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
_entityRegistry?: EntityRegistryEntry[];
|
||||
|
||||
@state() private _entityId?: string;
|
||||
|
||||
@state() private _traces?: AutomationTrace[];
|
||||
@@ -110,7 +117,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
class="trace-link"
|
||||
@click=${this._navigateToAutomation}
|
||||
slot="toolbar-icon"
|
||||
@@ -343,7 +350,7 @@ export class HaAutomationTrace extends LitElement {
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("automations") &&
|
||||
(changedProps.has("automationId") || changedProps.has("automations")) &&
|
||||
this.automationId &&
|
||||
!this._entityId
|
||||
) {
|
||||
@@ -352,6 +359,32 @@ export class HaAutomationTrace extends LitElement {
|
||||
);
|
||||
this._entityId = automation?.entity_id;
|
||||
}
|
||||
|
||||
if (
|
||||
changedProps.has("automationId") ||
|
||||
changedProps.has("_entityId") ||
|
||||
changedProps.has("_entityRegistry")
|
||||
) {
|
||||
this._setRelatedContext();
|
||||
}
|
||||
}
|
||||
|
||||
private _setRelatedContext() {
|
||||
const areaId = this._entityId
|
||||
? this._entityRegistry?.find(
|
||||
(entry) => entry.entity_id === this._entityId
|
||||
)?.area_id
|
||||
: undefined;
|
||||
fireEvent(
|
||||
this,
|
||||
"hass-related-context",
|
||||
areaId
|
||||
? {
|
||||
itemType: "area",
|
||||
itemId: areaId,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _pickOlderTrace() {
|
||||
|
||||
@@ -114,7 +114,7 @@ export const ManualEditorMixin = <TConfig>(
|
||||
<div class="fab-positioner">
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
size="l"
|
||||
class=${this.dirty ? "dirty" : ""}
|
||||
.disabled=${this.saving}
|
||||
@click=${this.saveConfig}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default class HaAutomationOption extends AutomationSortableListMixin<Opti
|
||||
<div class="buttons">
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
size="small"
|
||||
size="s"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addOption}
|
||||
>
|
||||
@@ -104,7 +104,7 @@ export default class HaAutomationOption extends AutomationSortableListMixin<Opti
|
||||
${!this.showDefaultActions
|
||||
? html`<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._showDefaultActions}
|
||||
>
|
||||
|
||||
@@ -179,7 +179,7 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._addTriggerDialog}
|
||||
.appearance=${this.root ? "accent" : "filled"}
|
||||
.size=${this.root ? "medium" : "small"}
|
||||
.size=${this.root ? "m" : "s"}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.add"
|
||||
|
||||
@@ -40,7 +40,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
@click=${this._download}
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
@@ -63,7 +63,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
@click=${this._show}
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.encryption_key.show_encryption_key_action"
|
||||
@@ -84,7 +84,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
variant="danger"
|
||||
size="small"
|
||||
size="s"
|
||||
slot="end"
|
||||
@click=${this._change}
|
||||
>
|
||||
@@ -156,7 +156,7 @@ class HaBackupConfigEncryptionKey extends LitElement {
|
||||
ha-list-item-base::part(supporting-text) {
|
||||
white-space: wrap;
|
||||
}
|
||||
ha-button[size="small"] ha-svg-icon {
|
||||
ha-button[size="s"] ha-svg-icon {
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -379,7 +379,7 @@ class DialogBackupOnboarding extends LitElement implements HassDialog {
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
@click=${this._downloadKey}
|
||||
|
||||
@@ -146,7 +146,7 @@ class DialogSetBackupEncryptionKey extends LitElement implements HassDialog {
|
||||
)}
|
||||
</span>
|
||||
<ha-button
|
||||
size="small"
|
||||
size="s"
|
||||
appearance="plain"
|
||||
slot="end"
|
||||
@click=${this._download}
|
||||
|
||||
@@ -533,7 +533,7 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
|
||||
? html`
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
size="l"
|
||||
?disabled=${backupInProgress}
|
||||
@click=${this._newBackup}
|
||||
>
|
||||
|
||||
@@ -161,7 +161,7 @@ class HaConfigBackupDetails extends LitElement {
|
||||
slot="end"
|
||||
rel="noreferrer noopener"
|
||||
appearance="plain"
|
||||
size="small"
|
||||
size="s"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.location.encryption.location_encrypted_cloud_learn_more"
|
||||
|
||||
@@ -323,7 +323,7 @@ class HaConfigBackupOverview extends LitElement {
|
||||
|
||||
<ha-button
|
||||
slot="fab"
|
||||
size="large"
|
||||
size="l"
|
||||
.loading=${backupInProgress}
|
||||
@click=${this._newBackup}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user