mirror of
https://github.com/home-assistant/frontend.git
synced 2025-11-21 16:57:09 +00:00
Compare commits
55 Commits
sec_pypi_p
...
add-automa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c15354302 | ||
|
|
fedcf85264 | ||
|
|
4b9a716df7 | ||
|
|
e319d116dc | ||
|
|
c8b1fd074c | ||
|
|
ec9bf7dd94 | ||
|
|
6d01949ff5 | ||
|
|
8a8d7dc77a | ||
|
|
5fc629f5c5 | ||
|
|
37e12a83be | ||
|
|
755c6dbb93 | ||
|
|
4a90331ac7 | ||
|
|
d070754bb9 | ||
|
|
7b264ae338 | ||
|
|
bb5fefce2b | ||
|
|
5703de9616 | ||
|
|
eee2c1e8fd | ||
|
|
d4c1642ccc | ||
|
|
0d693e692a | ||
|
|
a2a0f900a8 | ||
|
|
b18d3bff21 | ||
|
|
b06b51d03d | ||
|
|
022343c113 | ||
|
|
5ca10f4a38 | ||
|
|
33af5b6a08 | ||
|
|
0c89415c1c | ||
|
|
bd6b2c685c | ||
|
|
c6091971b6 | ||
|
|
cdb37edc5f | ||
|
|
d68b1571ae | ||
|
|
a274727713 | ||
|
|
58a1aac131 | ||
|
|
4cdbda25ed | ||
|
|
618c6e1ef2 | ||
|
|
10b308b906 | ||
|
|
563c6f0fbf | ||
|
|
1dc7f0ab73 | ||
|
|
5c8954b714 | ||
|
|
e506e4b082 | ||
|
|
9628d69998 | ||
|
|
350d8e7a49 | ||
|
|
61a5b607e0 | ||
|
|
2f3a2b8418 | ||
|
|
12c1e4eec4 | ||
|
|
b704b621f2 | ||
|
|
0eb993a8df | ||
|
|
cda97766ac | ||
|
|
e6936a9294 | ||
|
|
5ad73287a2 | ||
|
|
591b464508 | ||
|
|
acf963d38c | ||
|
|
68f383c293 | ||
|
|
0a25d8106c | ||
|
|
5c3cf17df9 | ||
|
|
e905fa6f23 |
183
.github/copilot-instructions.md
vendored
183
.github/copilot-instructions.md
vendored
@@ -2,6 +2,8 @@
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
@@ -151,6 +153,10 @@ try {
|
||||
### Styling Guidelines
|
||||
|
||||
- **Use CSS custom properties**: Leverage the theme system
|
||||
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
|
||||
- Spacing scale: `--ha-space-0` (0px) through `--ha-space-20` (80px) in 4px increments
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Follow Material Design**: Use Material Web Components where appropriate
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
@@ -159,21 +165,68 @@ try {
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
--spacing: 16px;
|
||||
padding: var(--spacing);
|
||||
padding: var(--ha-space-4);
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
:host {
|
||||
--spacing: 8px;
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
### View Transitions
|
||||
|
||||
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
|
||||
|
||||
**Core Resources:**
|
||||
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
**Default Root Transition:**
|
||||
|
||||
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
|
||||
|
||||
**Important Constraints:**
|
||||
|
||||
- Each `view-transition-name` must be unique at any given time
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Current Usage & Planned Applications:**
|
||||
|
||||
- Launch screen fade out (implemented)
|
||||
- Automation sidebar transitions (planned - #27238)
|
||||
- More info dialog content changes (planned - #27672)
|
||||
- Toolbar navigation, ha-spinner transitions (planned)
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
|
||||
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
|
||||
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
|
||||
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
|
||||
|
||||
### Performance Best Practices
|
||||
|
||||
- **Code split**: Split code at the panel/dialog level
|
||||
@@ -195,8 +248,9 @@ static get styles() {
|
||||
|
||||
**Available Dialog Types:**
|
||||
|
||||
- `ha-md-dialog` - Preferred for new code (Material Design 3)
|
||||
- `ha-dialog` - Legacy component still widely used
|
||||
- `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
|
||||
- `ha-md-dialog` - Material Design 3 dialog component
|
||||
- `ha-dialog` - Legacy component (still widely used)
|
||||
|
||||
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||
|
||||
@@ -211,15 +265,45 @@ fireEvent(this, "show-dialog", {
|
||||
**Dialog Implementation Requirements:**
|
||||
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `createCloseHeading()` for standard headers
|
||||
- Import `haStyleDialog` for consistent styling
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
- Return `nothing` when no params (loading state)
|
||||
- Fire `dialog-closed` event when closing
|
||||
- Add `dialogInitialFocus` for accessibility
|
||||
- Fire `dialog-closed` event in `_dialogClosed()` handler
|
||||
- Use `header-title` attribute for simple titles
|
||||
- Use `header-subtitle` attribute for simple subtitles
|
||||
- Use slots for custom content where the standard attributes are not enough
|
||||
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
|
||||
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
|
||||
|
||||
````
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
|
||||
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
|
||||
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
**Recent Examples:**
|
||||
|
||||
See these files for current patterns:
|
||||
|
||||
- `src/panels/config/repairs/dialog-repairs-issue.ts`
|
||||
- `src/dialogs/restart/dialog-restart.ts`
|
||||
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-wa-dialog.markdown`
|
||||
- `gallery/src/pages/components/ha-dialogs.markdown`
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
|
||||
- Built-in validation with error display
|
||||
@@ -235,7 +319,11 @@ fireEvent(this, "show-dialog", {
|
||||
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-form>
|
||||
````
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-form.markdown`
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
@@ -249,6 +337,35 @@ fireEvent(this, "show-dialog", {
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-alert.markdown`
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Automatically blocks shortcuts when input fields are focused
|
||||
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
|
||||
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Class definition**: `src/common/keyboard/shortcuts.ts`
|
||||
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
|
||||
|
||||
### Tooltip Component (ha-tooltip)
|
||||
|
||||
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a Panel
|
||||
@@ -289,11 +406,19 @@ export class DialogMyFeature
|
||||
@state()
|
||||
private _params?: MyDialogParams;
|
||||
|
||||
@state()
|
||||
private _open = false;
|
||||
|
||||
public async showDialog(params: MyDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
@@ -304,23 +429,27 @@ export class DialogMyFeature
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
open
|
||||
@closed=${this.closeDialog}
|
||||
.heading=${createCloseHeading(this.hass, this._params.title)}
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
header-subtitle=${this._params.subtitle}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<!-- Dialog content -->
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
slot="secondaryAction"
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button @click=${this._submit} slot="primaryAction">
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog>
|
||||
<p>Dialog content</p>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._submit}>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
13
.github/workflows/release.yaml
vendored
13
.github/workflows/release.yaml
vendored
@@ -19,11 +19,8 @@ jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
contents: write # Required to upload release assets
|
||||
id-token: write # For "Trusted Publisher" to PyPi
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -49,18 +46,14 @@ jobs:
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
- name: Build and release package
|
||||
run: |
|
||||
python3 -m pip install build
|
||||
python3 -m pip install twine build
|
||||
export TWINE_USERNAME="__token__"
|
||||
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
|
||||
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
- name: Upload release assets
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
with:
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
"@fullcalendar/list": "6.1.19",
|
||||
"@fullcalendar/luxon3": "6.1.19",
|
||||
"@fullcalendar/timegrid": "6.1.19",
|
||||
"@home-assistant/webawesome": "3.0.0",
|
||||
"@home-assistant/webawesome": "3.0.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.0.9",
|
||||
"@lit-labs/observers": "2.0.6",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Pushes a new version to PyPi.
|
||||
|
||||
# Stop on errors
|
||||
set -e
|
||||
@@ -11,4 +12,5 @@ yarn install
|
||||
script/build_frontend
|
||||
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build -q
|
||||
python3 -m build
|
||||
python3 -m twine upload dist/*.whl --skip-existing
|
||||
|
||||
@@ -188,6 +188,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
layout: physicsEnabled ? "force" : "none",
|
||||
draggable: true,
|
||||
roam: true,
|
||||
roamTrigger: "global",
|
||||
selectedMode: "single",
|
||||
label: {
|
||||
show: showLabels,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { HomeAssistant } from "../types";
|
||||
import { AudioRecorder } from "../util/audio-recorder";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import "./ha-alert";
|
||||
import "./ha-markdown";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
|
||||
@@ -40,7 +41,11 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
@query("#message-input") private _messageInput!: HaTextField;
|
||||
|
||||
@query("#scroll-container") private _scrollContainer!: HTMLDivElement;
|
||||
@query(".message:last-child")
|
||||
private _lastChatMessage!: LitElement;
|
||||
|
||||
@query(".message:last-child img:last-of-type")
|
||||
private _lastChatMessageImage: HTMLImageElement | undefined;
|
||||
|
||||
@state() private _conversation: AssistMessage[] = [];
|
||||
|
||||
@@ -92,10 +97,7 @@ export class HaAssistChat extends LitElement {
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._audioRecorder?.close();
|
||||
this._audioRecorder = undefined;
|
||||
this._unloadAudio();
|
||||
this._conversation = [];
|
||||
this._conversationId = null;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -112,7 +114,7 @@ export class HaAssistChat extends LitElement {
|
||||
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
|
||||
|
||||
return html`
|
||||
<div class="messages" id="scroll-container">
|
||||
<div class="messages">
|
||||
${controlHA
|
||||
? nothing
|
||||
: html`
|
||||
@@ -124,11 +126,18 @@ export class HaAssistChat extends LitElement {
|
||||
`}
|
||||
<div class="spacer"></div>
|
||||
${this._conversation!.map(
|
||||
// New lines matter for messages
|
||||
// prettier-ignore
|
||||
(message) => html`
|
||||
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
|
||||
`
|
||||
<ha-markdown
|
||||
class="message ${classMap({
|
||||
error: !!message.error,
|
||||
[message.who]: true,
|
||||
})}"
|
||||
breaks
|
||||
cache
|
||||
.content=${message.text}
|
||||
>
|
||||
</ha-markdown>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
<div class="input" slot="primaryAction">
|
||||
@@ -189,12 +198,28 @@ export class HaAssistChat extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _scrollMessagesBottom() {
|
||||
const scrollContainer = this._scrollContainer;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
private async _scrollMessagesBottom() {
|
||||
const lastChatMessage = this._lastChatMessage;
|
||||
if (!lastChatMessage.hasUpdated) {
|
||||
await lastChatMessage.updateComplete;
|
||||
}
|
||||
if (
|
||||
this._lastChatMessageImage &&
|
||||
!this._lastChatMessageImage.naturalHeight
|
||||
) {
|
||||
try {
|
||||
await this._lastChatMessageImage.decode();
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to decode image:", err);
|
||||
}
|
||||
}
|
||||
const isLastMessageFullyVisible =
|
||||
lastChatMessage.getBoundingClientRect().y <
|
||||
this.getBoundingClientRect().top + 24;
|
||||
if (!isLastMessageFullyVisible) {
|
||||
lastChatMessage.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
|
||||
}
|
||||
|
||||
private _handleKeyUp(ev: KeyboardEvent) {
|
||||
@@ -586,42 +611,31 @@ export class HaAssistChat extends LitElement {
|
||||
flex: 1;
|
||||
}
|
||||
.message {
|
||||
white-space: pre-line;
|
||||
font-size: var(--ha-font-size-l);
|
||||
clear: both;
|
||||
max-width: -webkit-fill-available;
|
||||
overflow-wrap: break-word;
|
||||
scroll-margin-top: 24px;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
}
|
||||
.message:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.message {
|
||||
font-size: var(--ha-font-size-l);
|
||||
}
|
||||
}
|
||||
|
||||
.message p {
|
||||
margin: 0;
|
||||
}
|
||||
.message p:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
margin-left: 24px;
|
||||
margin-inline-start: 24px;
|
||||
margin-inline-end: initial;
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
border-bottom-right-radius: 0px;
|
||||
--markdown-link-color: var(--text-primary-color);
|
||||
background-color: var(--chat-background-color-user, var(--primary-color));
|
||||
color: var(--text-primary-color);
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.message.hass {
|
||||
margin-right: 24px;
|
||||
margin-inline-end: 24px;
|
||||
@@ -636,20 +650,21 @@ export class HaAssistChat extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.message.user a {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.message.hass a {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: var(--error-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
ha-markdown {
|
||||
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
|
||||
--markdown-table-border-color: var(--divider-color);
|
||||
--markdown-code-background-color: var(--primary-background-color);
|
||||
--markdown-code-text-color: var(--primary-text-color);
|
||||
&:not(:has(ha-markdown-element)) {
|
||||
min-height: 1lh;
|
||||
min-width: 1lh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
.bouncer {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
@@ -90,7 +90,8 @@ export class HaDialog extends DialogBase {
|
||||
}
|
||||
.mdc-dialog__actions {
|
||||
justify-content: var(--justify-action-buttons, flex-end);
|
||||
padding: 12px 16px 16px 16px;
|
||||
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
|
||||
var(--ha-space-4);
|
||||
}
|
||||
.mdc-dialog__actions span:nth-child(1) {
|
||||
flex: var(--secondary-action-button-flex, unset);
|
||||
@@ -100,22 +101,24 @@ export class HaDialog extends DialogBase {
|
||||
}
|
||||
.mdc-dialog__container {
|
||||
align-items: var(--vertical-align-dialog, center);
|
||||
padding: var(--dialog-container-padding, var(--ha-space-0));
|
||||
}
|
||||
.mdc-dialog__title {
|
||||
padding: 16px 16px 0 16px;
|
||||
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-0)
|
||||
var(--ha-space-4);
|
||||
}
|
||||
.mdc-dialog__title:has(span) {
|
||||
padding: 12px 12px 0;
|
||||
padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-0);
|
||||
}
|
||||
.mdc-dialog__title::before {
|
||||
content: unset;
|
||||
}
|
||||
.mdc-dialog .mdc-dialog__content {
|
||||
position: var(--dialog-content-position, relative);
|
||||
padding: var(--dialog-content-padding, 24px);
|
||||
padding: var(--dialog-content-padding, var(--ha-space-6));
|
||||
}
|
||||
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
|
||||
padding-bottom: var(--dialog-content-padding, 24px);
|
||||
padding-bottom: var(--dialog-content-padding, var(--ha-space-6));
|
||||
}
|
||||
.mdc-dialog .mdc-dialog__surface {
|
||||
position: var(--dialog-surface-position, relative);
|
||||
@@ -133,7 +136,7 @@ export class HaDialog extends DialogBase {
|
||||
--ha-dialog-surface-background,
|
||||
var(--mdc-theme-surface, #fff)
|
||||
);
|
||||
padding: var(--dialog-surface-padding);
|
||||
padding: var(--dialog-surface-padding, var(--ha-space-0));
|
||||
}
|
||||
:host([flexContent]) .mdc-dialog .mdc-dialog__content {
|
||||
display: flex;
|
||||
@@ -150,22 +153,22 @@ export class HaDialog extends DialogBase {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
margin-right: 12px;
|
||||
margin-inline-end: 12px;
|
||||
padding-left: var(--ha-space-1);
|
||||
padding-right: var(--ha-space-1);
|
||||
margin-right: var(--ha-space-3);
|
||||
margin-inline-end: var(--ha-space-3);
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.header_button {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
inset-inline-start: initial;
|
||||
inset-inline-end: -12px;
|
||||
inset-inline-end: calc(var(--ha-space-3) * -1);
|
||||
direction: var(--direction);
|
||||
}
|
||||
.dialog-actions {
|
||||
inset-inline-start: initial !important;
|
||||
inset-inline-end: 0px !important;
|
||||
inset-inline-end: var(--ha-space-0) !important;
|
||||
direction: var(--direction);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -154,7 +154,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
return this._getLabelsMemoized(
|
||||
this.hass,
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labels,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { ReactiveElement, render, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
// eslint-disable-next-line import/extensions
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import hash from "object-hash";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { renderMarkdown } from "../resources/render-markdown";
|
||||
import { CacheManager } from "../util/cache-manager";
|
||||
|
||||
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
|
||||
|
||||
const markdownCache = new CacheManager<string>(1000);
|
||||
|
||||
const _gitHubMarkdownAlerts = {
|
||||
@@ -48,18 +52,26 @@ class HaMarkdownElement extends ReactiveElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
|
||||
|
||||
protected update(changedProps) {
|
||||
super.update(changedProps);
|
||||
if (this.content !== undefined) {
|
||||
this._render();
|
||||
this._renderPromise = this._render();
|
||||
}
|
||||
}
|
||||
|
||||
protected async getUpdateComplete(): Promise<boolean> {
|
||||
await super.getUpdateComplete();
|
||||
await this._renderPromise;
|
||||
return true;
|
||||
}
|
||||
|
||||
protected willUpdate(_changedProperties: PropertyValues): void {
|
||||
if (!this.innerHTML && this.cache) {
|
||||
const key = this._computeCacheKey();
|
||||
if (markdownCache.has(key)) {
|
||||
this.innerHTML = markdownCache.get(key)!;
|
||||
render(markdownCache.get(key)!, this.renderRoot);
|
||||
this._resize();
|
||||
}
|
||||
}
|
||||
@@ -75,7 +87,7 @@ class HaMarkdownElement extends ReactiveElement {
|
||||
}
|
||||
|
||||
private async _render() {
|
||||
this.innerHTML = await renderMarkdown(
|
||||
const elements = await renderMarkdown(
|
||||
String(this.content),
|
||||
{
|
||||
breaks: this.breaks,
|
||||
@@ -87,6 +99,11 @@ class HaMarkdownElement extends ReactiveElement {
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
elements.map((e) => h(unsafeHTML(e))),
|
||||
this.renderRoot
|
||||
);
|
||||
|
||||
this._resize();
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type ReactiveElement,
|
||||
type CSSResultGroup,
|
||||
} from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import "./ha-markdown-element";
|
||||
|
||||
@customElement("ha-markdown")
|
||||
@@ -18,6 +25,14 @@ export class HaMarkdown extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public cache = false;
|
||||
|
||||
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
|
||||
|
||||
protected async getUpdateComplete() {
|
||||
const result = await super.getUpdateComplete();
|
||||
await this._markdownElement.updateComplete;
|
||||
return result;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.content) {
|
||||
return nothing;
|
||||
@@ -53,19 +68,46 @@ export class HaMarkdown extends LitElement {
|
||||
margin: var(--ha-space-1) 0;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
color: var(--markdown-link-color, var(--primary-color));
|
||||
}
|
||||
img {
|
||||
background-color: rgba(10, 10, 10, 0.15);
|
||||
border-radius: var(--markdown-image-border-radius);
|
||||
max-width: 100%;
|
||||
min-height: 2lh;
|
||||
height: auto;
|
||||
width: auto;
|
||||
text-indent: 4px;
|
||||
transition: height 0.2s ease-in-out;
|
||||
}
|
||||
p:first-child > img:first-child {
|
||||
vertical-align: top;
|
||||
}
|
||||
p:first-child > img:last-child {
|
||||
vertical-align: top;
|
||||
}
|
||||
ol,
|
||||
ul {
|
||||
list-style-position: inside;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
li {
|
||||
&:has(input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
& > input[type="checkbox"] {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
svg {
|
||||
background-color: var(--markdown-svg-background-color, none);
|
||||
color: var(--markdown-svg-color, none);
|
||||
}
|
||||
code,
|
||||
pre {
|
||||
background-color: var(--markdown-code-background-color, none);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
svg {
|
||||
background-color: var(--markdown-svg-background-color, none);
|
||||
color: var(--markdown-svg-color, none);
|
||||
color: var(--markdown-code-text-color, inherit);
|
||||
}
|
||||
code {
|
||||
font-size: var(--ha-font-size-s);
|
||||
@@ -97,6 +139,24 @@ export class HaMarkdown extends LitElement {
|
||||
border-bottom: none;
|
||||
margin: var(--ha-space-4) 0;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
th {
|
||||
text-align: start;
|
||||
}
|
||||
td,
|
||||
th {
|
||||
border: 1px solid var(--markdown-table-border-color, transparent);
|
||||
padding: 0.25em 0.5em;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid var(--divider-color);
|
||||
margin-inline: 0;
|
||||
padding-inline: 1em;
|
||||
}
|
||||
` as CSSResultGroup;
|
||||
}
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ export class HaMdDialog extends Dialog {
|
||||
}
|
||||
|
||||
slot[name="actions"]::slotted(*) {
|
||||
padding: 16px;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
@@ -195,7 +195,7 @@ export class HaMdDialog extends Dialog {
|
||||
}
|
||||
|
||||
slot[name="content"]::slotted(*) {
|
||||
padding: var(--dialog-content-padding, 24px);
|
||||
padding: var(--dialog-content-padding, var(--ha-space-6));
|
||||
}
|
||||
.scrim {
|
||||
z-index: 10; /* overlay navigation */
|
||||
|
||||
@@ -192,7 +192,7 @@ export class HaPickerComboBox extends LitElement {
|
||||
@focus=${this._focusList}
|
||||
@visibilityChanged=${this._visibilityChanged}
|
||||
>
|
||||
</lit-virtualizer> `;
|
||||
</lit-virtualizer>`;
|
||||
}
|
||||
|
||||
private _renderSectionButtons() {
|
||||
|
||||
28
src/components/ha-section-title.ts
Normal file
28
src/components/ha-section-title.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-section-title")
|
||||
class HaSectionTitle extends LitElement {
|
||||
protected render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
min-height: var(--ha-space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-section-title": HaSectionTitle;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetType,
|
||||
type TargetTypeFloorless,
|
||||
} from "../data/target";
|
||||
@@ -47,7 +48,6 @@ import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@@ -634,35 +634,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getRowType = (
|
||||
item:
|
||||
| PickerComboBoxItem
|
||||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
) => {
|
||||
if (
|
||||
(item as FloorComboBoxItem).type === "area" ||
|
||||
(item as FloorComboBoxItem).type === "floor"
|
||||
) {
|
||||
return (item as FloorComboBoxItem).type;
|
||||
}
|
||||
|
||||
if ("domain" in item) {
|
||||
return "device";
|
||||
}
|
||||
|
||||
if ("stateObj" in item) {
|
||||
return "entity";
|
||||
}
|
||||
|
||||
if (item.id === EMPTY_SEARCH) {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
return "label";
|
||||
};
|
||||
|
||||
private _sectionTitleFunction = ({
|
||||
firstIndex,
|
||||
lastIndex,
|
||||
@@ -686,7 +657,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = this._getRowType(firstItem as PickerComboBoxItem);
|
||||
const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
|
||||
const translationType:
|
||||
| "areas"
|
||||
| "entities"
|
||||
@@ -858,7 +829,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
if (!filterType || filterType === "label") {
|
||||
let labels = this._getLabelsMemoized(
|
||||
this.hass,
|
||||
this.hass.states,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._labelRegistry,
|
||||
includeDomains,
|
||||
undefined,
|
||||
@@ -974,7 +948,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const type = this._getRowType(item);
|
||||
const type = getTargetComboBoxItemType(item);
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
|
||||
@@ -235,7 +235,7 @@ export class HaWaDialog extends LitElement {
|
||||
}
|
||||
|
||||
:host([width="large"]) wa-dialog {
|
||||
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
|
||||
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
|
||||
}
|
||||
|
||||
:host([width="full"]) wa-dialog {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
removeLocalMedia,
|
||||
} from "../../data/media_source";
|
||||
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-check-list-item";
|
||||
@@ -305,6 +305,7 @@ class DialogMediaManage extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-z-index: 9;
|
||||
@@ -314,9 +315,9 @@ class DialogMediaManage extends LitElement {
|
||||
@media (min-width: 800px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: 40px;
|
||||
--mdc-dialog-max-height: calc(100vh - 72px);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
MediaPlayerItem,
|
||||
MediaPlayerLayoutType,
|
||||
} from "../../data/media-player";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-dialog";
|
||||
import "../ha-dialog-header";
|
||||
@@ -223,6 +223,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-z-index: 9;
|
||||
@@ -230,23 +231,27 @@ class DialogMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
|
||||
ha-media-player-browse {
|
||||
--media-browser-max-height: calc(100vh - 65px);
|
||||
--media-browser-max-height: calc(
|
||||
100vh - 65px - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
|
||||
:host(.opened) ha-media-player-browse {
|
||||
height: calc(100vh - 65px);
|
||||
height: calc(100vh - 65px - var(--safe-area-inset-y));
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: 40px;
|
||||
--mdc-dialog-max-height: calc(100vh - 72px);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
ha-media-player-browse {
|
||||
position: initial;
|
||||
--media-browser-max-height: calc(100vh - 145px);
|
||||
--media-browser-max-height: calc(
|
||||
100vh - 145px - var(--safe-area-inset-y)
|
||||
);
|
||||
width: 700px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,52 @@ export interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
|
||||
floor?: FloorRegistryEntry;
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
|
||||
areas: FloorComboBoxItem[];
|
||||
}
|
||||
|
||||
export interface AreaFloorValue {
|
||||
id: string;
|
||||
type: "floor" | "area";
|
||||
}
|
||||
|
||||
export const getAreasNestedInFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[]
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors,
|
||||
true
|
||||
) as (FloorNestedComboBoxItem | UnassignedAreasFloorComboBoxItem)[];
|
||||
|
||||
export const getAreasAndFloors = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
@@ -40,7 +81,43 @@ export const getAreasAndFloors = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[]
|
||||
): FloorComboBoxItem[] => {
|
||||
) =>
|
||||
getAreasAndFloorsItems(
|
||||
states,
|
||||
haFloors,
|
||||
haAreas,
|
||||
haDevices,
|
||||
haEntities,
|
||||
formatId,
|
||||
includeDomains,
|
||||
excludeDomains,
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
excludeAreas,
|
||||
excludeFloors
|
||||
) as FloorComboBoxItem[];
|
||||
|
||||
export const getAreasAndFloorsItems = (
|
||||
states: HomeAssistant["states"],
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
formatId: (value: AreaFloorValue) => string,
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
includeDeviceClasses?: string[],
|
||||
deviceFilter?: HaDevicePickerDeviceFilterFunc,
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
excludeAreas?: string[],
|
||||
excludeFloors?: string[],
|
||||
nested = false
|
||||
): (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
@@ -181,7 +258,11 @@ export const getAreasAndFloors = (
|
||||
|
||||
const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
|
||||
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
const items: (
|
||||
| FloorComboBoxItem
|
||||
| FloorNestedComboBoxItem
|
||||
| UnassignedAreasFloorComboBoxItem
|
||||
)[] = [];
|
||||
|
||||
hierarchy.floors.forEach((f) => {
|
||||
const floor = haFloors[f.id];
|
||||
@@ -196,7 +277,7 @@ export const getAreasAndFloors = (
|
||||
})
|
||||
.flat();
|
||||
|
||||
items.push({
|
||||
const floorItem: FloorComboBoxItem | FloorNestedComboBoxItem = {
|
||||
id: formatId({ id: floor.floor_id, type: "floor" }),
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
@@ -208,41 +289,53 @@ export const getAreasAndFloors = (
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName || area.area_id,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [
|
||||
area.area_id,
|
||||
...(areaName ? [areaName] : []),
|
||||
...area.aliases,
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
items.push(floorItem);
|
||||
|
||||
items.push(
|
||||
...hierarchy.areas.map((areaId) => {
|
||||
const area = haAreas[areaId];
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
const floorAreasItems = floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
primary: areaName || area.area_id,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
search_labels: [
|
||||
area.area_id,
|
||||
...(areaName ? [areaName] : []),
|
||||
...area.aliases,
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (nested && floor) {
|
||||
(floorItem as unknown as FloorNestedComboBoxItem).areas = floorAreasItems;
|
||||
} else {
|
||||
items.push(...floorAreasItems);
|
||||
}
|
||||
});
|
||||
|
||||
const unassignedAreaItems = hierarchy.areas.map((areaId) => {
|
||||
const area = haAreas[areaId];
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: formatId({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
});
|
||||
|
||||
if (nested && unassignedAreaItems.length) {
|
||||
items.push({
|
||||
areas: unassignedAreaItems,
|
||||
} as UnassignedAreasFloorComboBoxItem);
|
||||
} else {
|
||||
items.push(...unassignedAreaItems);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type { EntityRegistryEntry } from "./entity_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "./entity_registry";
|
||||
import type { RegistryEntry } from "./registry";
|
||||
|
||||
export { subscribeAreaRegistry } from "./ws-area_registry";
|
||||
@@ -18,7 +21,10 @@ export interface AreaRegistryEntry extends RegistryEntry {
|
||||
temperature_entity_id: string | null;
|
||||
}
|
||||
|
||||
export type AreaEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
export type AreaEntityLookup = Record<
|
||||
string,
|
||||
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
>;
|
||||
|
||||
export type AreaDeviceLookup = Record<string, DeviceRegistryEntry[]>;
|
||||
|
||||
@@ -69,11 +75,17 @@ export const reorderAreaRegistryEntries = (
|
||||
});
|
||||
|
||||
export const getAreaEntityLookup = (
|
||||
entities: EntityRegistryEntry[]
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
|
||||
filterHidden = false
|
||||
): AreaEntityLookup => {
|
||||
const areaEntityLookup: AreaEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity.area_id) {
|
||||
if (
|
||||
!entity.area_id ||
|
||||
(filterHidden &&
|
||||
((entity as EntityRegistryDisplayEntry).hidden ||
|
||||
(entity as EntityRegistryEntry).hidden_by))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!(entity.area_id in areaEntityLookup)) {
|
||||
|
||||
@@ -50,7 +50,10 @@ export type DeviceEntityDisplayLookup = Record<
|
||||
EntityRegistryDisplayEntry[]
|
||||
>;
|
||||
|
||||
export type DeviceEntityLookup = Record<string, EntityRegistryEntry[]>;
|
||||
export type DeviceEntityLookup = Record<
|
||||
string,
|
||||
(EntityRegistryEntry | EntityRegistryDisplayEntry)[]
|
||||
>;
|
||||
|
||||
export interface DeviceRegistryEntryMutableParams {
|
||||
area_id?: string | null;
|
||||
@@ -107,11 +110,17 @@ export const sortDeviceRegistryByName = (
|
||||
);
|
||||
|
||||
export const getDeviceEntityLookup = (
|
||||
entities: EntityRegistryEntry[]
|
||||
entities: (EntityRegistryEntry | EntityRegistryDisplayEntry)[],
|
||||
filterHidden = false
|
||||
): DeviceEntityLookup => {
|
||||
const deviceEntityLookup: DeviceEntityLookup = {};
|
||||
for (const entity of entities) {
|
||||
if (!entity.device_id) {
|
||||
if (
|
||||
!entity.device_id ||
|
||||
(filterHidden &&
|
||||
((entity as EntityRegistryDisplayEntry).hidden ||
|
||||
(entity as EntityRegistryEntry).hidden_by))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!(entity.device_id in deviceEntityLookup)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const integrationsWithPanel = {
|
||||
bluetooth: "config/bluetooth",
|
||||
@@ -25,6 +25,8 @@ export type IntegrationType =
|
||||
| "entity"
|
||||
| "system";
|
||||
|
||||
export type DomainManifestLookup = Record<string, IntegrationManifest>;
|
||||
|
||||
export interface IntegrationManifest {
|
||||
is_built_in: boolean;
|
||||
overwrites_built_in?: boolean;
|
||||
|
||||
@@ -101,7 +101,10 @@ export const deleteLabelRegistryEntry = (
|
||||
});
|
||||
|
||||
export const getLabels = (
|
||||
hass: HomeAssistant,
|
||||
hassStates: HomeAssistant["states"],
|
||||
hassAreas: HomeAssistant["areas"],
|
||||
hassDevices: HomeAssistant["devices"],
|
||||
hassEntities: HomeAssistant["entities"],
|
||||
labels?: LabelRegistryEntry[],
|
||||
includeDomains?: string[],
|
||||
excludeDomains?: string[],
|
||||
@@ -115,8 +118,8 @@ export const getLabels = (
|
||||
return [];
|
||||
}
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
const entities = Object.values(hass.entities);
|
||||
const devices = Object.values(hassDevices);
|
||||
const entities = Object.values(hassEntities);
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
@@ -170,7 +173,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -181,7 +184,7 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
return (
|
||||
stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class)
|
||||
@@ -200,7 +203,7 @@ export const getLabels = (
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -208,7 +211,7 @@ export const getLabels = (
|
||||
});
|
||||
});
|
||||
inputEntities = inputEntities!.filter((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
const stateObj = hassStates[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
}
|
||||
@@ -245,7 +248,7 @@ export const getLabels = (
|
||||
|
||||
if (areaIds) {
|
||||
areaIds.forEach((areaId) => {
|
||||
const area = hass.areas[areaId];
|
||||
const area = hassAreas[areaId];
|
||||
area.labels.forEach((label) => usedLabels.add(label));
|
||||
});
|
||||
}
|
||||
|
||||
78
src/data/labs.ts
Normal file
78
src/data/labs.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface LabPreviewFeature {
|
||||
preview_feature: string;
|
||||
domain: string;
|
||||
enabled: boolean;
|
||||
is_built_in: boolean;
|
||||
feedback_url?: string;
|
||||
learn_more_url?: string;
|
||||
report_issue_url?: string;
|
||||
}
|
||||
|
||||
export interface LabPreviewFeaturesResponse {
|
||||
features: LabPreviewFeature[];
|
||||
}
|
||||
|
||||
export const fetchLabFeatures = async (
|
||||
hass: HomeAssistant
|
||||
): Promise<LabPreviewFeature[]> => {
|
||||
const response = await hass.callWS<LabPreviewFeaturesResponse>({
|
||||
type: "labs/list",
|
||||
});
|
||||
return response.features;
|
||||
};
|
||||
|
||||
export const labsUpdatePreviewFeature = (
|
||||
hass: HomeAssistant,
|
||||
domain: string,
|
||||
preview_feature: string,
|
||||
enabled: boolean,
|
||||
create_backup?: boolean
|
||||
): Promise<void> =>
|
||||
hass.callWS({
|
||||
type: "labs/update",
|
||||
domain,
|
||||
preview_feature,
|
||||
enabled,
|
||||
...(create_backup !== undefined && { create_backup }),
|
||||
});
|
||||
|
||||
const fetchLabFeaturesCollection = (conn: Connection) =>
|
||||
conn
|
||||
.sendMessagePromise<LabPreviewFeaturesResponse>({
|
||||
type: "labs/list",
|
||||
})
|
||||
.then((response) => response.features);
|
||||
|
||||
const subscribeLabUpdates = (
|
||||
conn: Connection,
|
||||
store: Store<LabPreviewFeature[]>
|
||||
) =>
|
||||
conn.subscribeEvents(
|
||||
debounce(
|
||||
() =>
|
||||
fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) =>
|
||||
store.setState(features, true)
|
||||
),
|
||||
500,
|
||||
true
|
||||
),
|
||||
"labs_updated"
|
||||
);
|
||||
|
||||
export const subscribeLabFeatures = (
|
||||
conn: Connection,
|
||||
onChange: (features: LabPreviewFeature[]) => void
|
||||
) =>
|
||||
createCollection<LabPreviewFeature[]>(
|
||||
"_labFeatures",
|
||||
fetchLabFeaturesCollection,
|
||||
subscribeLabUpdates,
|
||||
conn,
|
||||
onChange
|
||||
);
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { FloorComboBoxItem } from "./area_floor";
|
||||
import type { AreaRegistryEntry } from "./area_registry";
|
||||
import type { DeviceRegistryEntry } from "./device_registry";
|
||||
import type { DevicePickerItem, DeviceRegistryEntry } from "./device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity_registry";
|
||||
import type {
|
||||
EntityComboBoxItem,
|
||||
EntityRegistryDisplayEntry,
|
||||
} from "./entity_registry";
|
||||
|
||||
export const TARGET_SEPARATOR = "________";
|
||||
|
||||
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
|
||||
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
|
||||
@@ -35,6 +42,28 @@ export const extractFromTarget = async (
|
||||
target,
|
||||
});
|
||||
|
||||
export const getTriggersForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
expandGroup = true
|
||||
) =>
|
||||
callWS<string[]>({
|
||||
type: "get_triggers_for_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const getServicesForTarget = async (
|
||||
callWS: HomeAssistant["callWS"],
|
||||
target: HassServiceTarget,
|
||||
expandGroup = true
|
||||
) =>
|
||||
callWS<string[]>({
|
||||
type: "get_services_for_target",
|
||||
target,
|
||||
expand_group: expandGroup,
|
||||
});
|
||||
|
||||
export const areaMeetsFilter = (
|
||||
area: AreaRegistryEntry,
|
||||
devices: HomeAssistant["devices"],
|
||||
@@ -162,3 +191,32 @@ export const entityRegMeetsFilter = (
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getTargetComboBoxItemType = (
|
||||
item:
|
||||
| PickerComboBoxItem
|
||||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||||
| EntityComboBoxItem
|
||||
| DevicePickerItem
|
||||
) => {
|
||||
if (
|
||||
(item as FloorComboBoxItem).type === "area" ||
|
||||
(item as FloorComboBoxItem).type === "floor"
|
||||
) {
|
||||
return (item as FloorComboBoxItem).type;
|
||||
}
|
||||
|
||||
if ("domain" in item) {
|
||||
return "device";
|
||||
}
|
||||
|
||||
if ("stateObj" in item) {
|
||||
return "entity";
|
||||
}
|
||||
|
||||
if (item.id === "___EMPTY_SEARCH___") {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
return "label";
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ export type TranslationCategory =
|
||||
| "system_health"
|
||||
| "application_credentials"
|
||||
| "issues"
|
||||
| "preview_features"
|
||||
| "selector"
|
||||
| "services"
|
||||
| "triggers";
|
||||
|
||||
@@ -50,7 +50,7 @@ import { lightSupportsFavoriteColors } from "../../data/light";
|
||||
import type { ItemType } from "../../data/search";
|
||||
import { SearchableDomains } from "../../data/search";
|
||||
import { getSensorNumericDeviceClasses } from "../../data/sensor";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
|
||||
import "../../state-summary/state-card-content";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import {
|
||||
@@ -707,14 +707,9 @@ export class MoreInfoDialog extends LitElement {
|
||||
static get styles() {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: max(
|
||||
var(--ha-space-10),
|
||||
var(--safe-area-inset-top, var(--ha-space-0))
|
||||
);
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@@ -737,13 +732,6 @@ export class MoreInfoDialog extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
/* When in fullscreen dialog should be attached to top */
|
||||
ha-dialog {
|
||||
--dialog-surface-margin-top: var(--ha-space-0);
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 600px) and (min-height: 501px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-min-width: 580px;
|
||||
|
||||
@@ -46,7 +46,11 @@ import { getPanelNameTranslationKey } from "../../data/panel";
|
||||
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
|
||||
import { configSections } from "../../panels/config/ha-panel-config";
|
||||
import { HaFuse } from "../../resources/fuse";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
haStyleScrollbar,
|
||||
} from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
@@ -986,6 +990,7 @@ export class QuickBar extends LitElement {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-list {
|
||||
position: relative;
|
||||
@@ -1010,9 +1015,9 @@ export class QuickBar extends LitElement {
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 800px;
|
||||
--mdc-dialog-min-width: 500px;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: var(--ha-space-10);
|
||||
--mdc-dialog-max-height: calc(100% - var(--ha-space-18));
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,8 @@ import {
|
||||
fetchHassioHassOsInfo,
|
||||
fetchHassioHostInfo,
|
||||
} from "../../../data/hassio/host";
|
||||
import type { LabPreviewFeature } from "../../../data/labs";
|
||||
import { fetchLabFeatures } from "../../../data/labs";
|
||||
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
@@ -50,6 +52,8 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
|
||||
@state() private _externalAccess = false;
|
||||
|
||||
@state() private _labFeatures?: LabPreviewFeature[];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const pages = configSections.general
|
||||
.filter((page) => canShowPage(this.hass, page))
|
||||
@@ -94,6 +98,12 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
this._boardName ||
|
||||
this.hass.localize("ui.panel.config.hardware.description");
|
||||
break;
|
||||
case "labs":
|
||||
description =
|
||||
this._labFeatures && this._labFeatures.some((f) => f.enabled)
|
||||
? this.hass.localize("ui.panel.config.labs.description_enabled")
|
||||
: this.hass.localize("ui.panel.config.labs.description");
|
||||
break;
|
||||
|
||||
default:
|
||||
description = this.hass.localize(
|
||||
@@ -156,6 +166,7 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
const isHassioLoaded = isComponentLoaded(this.hass, "hassio");
|
||||
this._fetchBackupInfo();
|
||||
this._fetchHardwareInfo(isHassioLoaded);
|
||||
this._fetchLabFeatures();
|
||||
if (isHassioLoaded) {
|
||||
this._fetchStorageInfo();
|
||||
}
|
||||
@@ -211,6 +222,12 @@ class HaConfigSystemNavigation extends LitElement {
|
||||
this._externalAccess = this.hass.config.external_url !== null;
|
||||
}
|
||||
|
||||
private async _fetchLabFeatures() {
|
||||
if (isComponentLoaded(this.hass, "labs")) {
|
||||
this._labFeatures = await fetchLabFeatures(this.hass);
|
||||
}
|
||||
}
|
||||
|
||||
private async _showRestartDialog() {
|
||||
showRestartDialog(this);
|
||||
}
|
||||
|
||||
@@ -281,8 +281,12 @@ class DialogNewDashboard extends LitElement implements HassDialog {
|
||||
@media all and (min-width: 850px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-min-width: 845px;
|
||||
--mdc-dialog-min-height: calc(100vh - 72px);
|
||||
--mdc-dialog-max-height: calc(100vh - 72px);
|
||||
--mdc-dialog-min-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mdiCog,
|
||||
mdiDatabase,
|
||||
mdiDevices,
|
||||
mdiFlask,
|
||||
mdiInformation,
|
||||
mdiInformationOutline,
|
||||
mdiLabel,
|
||||
@@ -328,6 +329,13 @@ export const configSections: Record<string, PageNavigation[]> = {
|
||||
iconPath: mdiShape,
|
||||
iconColor: "#f1c447",
|
||||
},
|
||||
{
|
||||
path: "/config/labs",
|
||||
translationKey: "labs",
|
||||
iconPath: mdiFlask,
|
||||
iconColor: "#b1b134",
|
||||
core: true,
|
||||
},
|
||||
{
|
||||
path: "/config/network",
|
||||
translationKey: "network",
|
||||
@@ -515,6 +523,10 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
|
||||
tag: "ha-config-section-general",
|
||||
load: () => import("./core/ha-config-section-general"),
|
||||
},
|
||||
labs: {
|
||||
tag: "ha-config-labs",
|
||||
load: () => import("./labs/ha-config-labs"),
|
||||
},
|
||||
zha: {
|
||||
tag: "zha-config-dashboard-router",
|
||||
load: () =>
|
||||
|
||||
@@ -462,7 +462,7 @@ class AddIntegrationDialog extends LitElement {
|
||||
style=${styleMap({
|
||||
width: `${this._width}px`,
|
||||
height: this._narrow
|
||||
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
|
||||
? "calc(100vh - 184px - var(--safe-area-inset-top, var(--ha-space-0)) - var(--safe-area-inset-bottom, var(--ha-space-0)))"
|
||||
: "500px",
|
||||
})}
|
||||
@click=${this._integrationPicked}
|
||||
|
||||
@@ -12,7 +12,10 @@ import "../../../../../components/ha-tab-group";
|
||||
import "../../../../../components/ha-tab-group-tab";
|
||||
import type { ZHADevice, ZHAGroup } from "../../../../../data/zha";
|
||||
import { fetchBindableDevices, fetchGroups } from "../../../../../data/zha";
|
||||
import { haStyleDialog } from "../../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { sortZHADevices, sortZHAGroups } from "./functions";
|
||||
import type {
|
||||
@@ -211,11 +214,11 @@ class DialogZHAManageZigbeeDevice extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-surface-position: static;
|
||||
--dialog-content-position: static;
|
||||
--vertical-align-dialog: flex-start;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -229,8 +232,9 @@ class DialogZHAManageZigbeeDevice extends LitElement {
|
||||
ha-dialog {
|
||||
--mdc-dialog-min-width: 560px;
|
||||
--mdc-dialog-max-width: 560px;
|
||||
--dialog-surface-margin-top: 40px;
|
||||
--mdc-dialog-max-height: calc(100% - 72px);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
223
src/panels/config/labs/dialog-labs-preview-feature-enable.ts
Normal file
223
src/panels/config/labs/dialog-labs-preview-feature-enable.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { relativeTime } from "../../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-button";
|
||||
import type { HaMdDialog } from "../../../components/ha-md-dialog";
|
||||
import "../../../components/ha-md-dialog";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import "../../../components/ha-switch";
|
||||
import type { BackupConfig } from "../../../data/backup";
|
||||
import { fetchBackupConfig } from "../../../data/backup";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LabsPreviewFeatureEnableDialogParams } from "./show-dialog-labs-preview-feature-enable";
|
||||
|
||||
@customElement("dialog-labs-preview-feature-enable")
|
||||
export class DialogLabsPreviewFeatureEnable
|
||||
extends LitElement
|
||||
implements HassDialog<LabsPreviewFeatureEnableDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: LabsPreviewFeatureEnableDialogParams;
|
||||
|
||||
@state() private _backupConfig?: BackupConfig;
|
||||
|
||||
@state() private _createBackup = false;
|
||||
|
||||
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||
|
||||
public async showDialog(
|
||||
params: LabsPreviewFeatureEnableDialogParams
|
||||
): Promise<void> {
|
||||
this._params = params;
|
||||
this._createBackup = false;
|
||||
await this._fetchBackupConfig();
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._dialog?.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
this._backupConfig = undefined;
|
||||
this._createBackup = false;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
private async _fetchBackupConfig() {
|
||||
try {
|
||||
const { config } = await fetchBackupConfig(this.hass);
|
||||
this._backupConfig = config;
|
||||
|
||||
// Default to enabled if automatic backups are configured, disabled otherwise
|
||||
this._createBackup =
|
||||
config.automatic_backups_configured &&
|
||||
!!config.create_backup.password &&
|
||||
config.create_backup.agent_ids.length > 0;
|
||||
} catch {
|
||||
// User will get manual backup option if fetch fails
|
||||
this._createBackup = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _computeCreateBackupTexts():
|
||||
| { title: string; description?: string }
|
||||
| undefined {
|
||||
if (
|
||||
!this._backupConfig ||
|
||||
!this._backupConfig.automatic_backups_configured ||
|
||||
!this._backupConfig.create_backup.password ||
|
||||
this._backupConfig.create_backup.agent_ids.length === 0
|
||||
) {
|
||||
return {
|
||||
title: this.hass.localize("ui.panel.config.labs.create_backup.manual"),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.config.labs.create_backup.manual_description"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const lastAutomaticBackupDate = this._backupConfig
|
||||
.last_completed_automatic_backup
|
||||
? new Date(this._backupConfig.last_completed_automatic_backup)
|
||||
: null;
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
title: this.hass.localize("ui.panel.config.labs.create_backup.automatic"),
|
||||
description: lastAutomaticBackupDate
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.labs.create_backup.automatic_description_last",
|
||||
{
|
||||
relative_time: relativeTime(
|
||||
lastAutomaticBackupDate,
|
||||
this.hass.locale,
|
||||
now,
|
||||
true
|
||||
),
|
||||
}
|
||||
)
|
||||
: this.hass.localize(
|
||||
"ui.panel.config.labs.create_backup.automatic_description_none"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private _createBackupChanged(ev: Event): void {
|
||||
this._createBackup = (ev.target as HaSwitch).checked;
|
||||
}
|
||||
|
||||
private _handleCancel(): void {
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
private _handleConfirm(): void {
|
||||
if (this._params) {
|
||||
this._params.onConfirm(this._createBackup);
|
||||
}
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const createBackupTexts = this._computeCreateBackupTexts();
|
||||
|
||||
return html`
|
||||
<ha-md-dialog open @closed=${this._dialogClosed}>
|
||||
<span slot="headline">
|
||||
${this.hass.localize("ui.panel.config.labs.enable_title")}
|
||||
</span>
|
||||
<div slot="content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
`component.${this._params.preview_feature.domain}.preview_features.${this._params.preview_feature.preview_feature}.enable_confirmation`
|
||||
) || this.hass.localize("ui.panel.config.labs.enable_confirmation")}
|
||||
</p>
|
||||
</div>
|
||||
<div slot="actions">
|
||||
${createBackupTexts
|
||||
? html`
|
||||
<ha-md-list>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline">${createBackupTexts.title}</span>
|
||||
${createBackupTexts.description
|
||||
? html`
|
||||
<span slot="supporting-text">
|
||||
${createBackupTexts.description}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
<ha-switch
|
||||
slot="end"
|
||||
.checked=${this._createBackup}
|
||||
@change=${this._createBackupChanged}
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
`
|
||||
: nothing}
|
||||
<div>
|
||||
<ha-button appearance="plain" @click=${this._handleCancel}>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
variant="brand"
|
||||
@click=${this._handleConfirm}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.labs.enable")}
|
||||
</ha-button>
|
||||
</div>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles = css`
|
||||
ha-md-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
div[slot="actions"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
background: none;
|
||||
--md-list-item-leading-space: var(--ha-space-6);
|
||||
--md-list-item-trailing-space: var(--ha-space-6);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
|
||||
div[slot="actions"] > div {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-4) var(--ha-space-6);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-labs-preview-feature-enable": DialogLabsPreviewFeatureEnable;
|
||||
}
|
||||
}
|
||||
113
src/panels/config/labs/dialog-labs-progress.ts
Normal file
113
src/panels/config/labs/dialog-labs-progress.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-md-dialog";
|
||||
import "../../../components/ha-spinner";
|
||||
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LabsProgressDialogParams } from "./show-dialog-labs-progress";
|
||||
|
||||
@customElement("dialog-labs-progress")
|
||||
export class DialogLabsProgress
|
||||
extends LitElement
|
||||
implements HassDialog<LabsProgressDialogParams>
|
||||
{
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: LabsProgressDialogParams;
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
public async showDialog(params: LabsProgressDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): boolean {
|
||||
this._open = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-md-dialog
|
||||
.open=${this._open}
|
||||
hideActions
|
||||
scrimClickAction=""
|
||||
escapeKeyAction=""
|
||||
@closed=${this._handleClosed}
|
||||
>
|
||||
<div slot="content">
|
||||
<div class="summary">
|
||||
<ha-spinner></ha-spinner>
|
||||
<div class="content">
|
||||
<p class="heading">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.labs.progress.creating_backup"
|
||||
)}
|
||||
</p>
|
||||
<p class="description">
|
||||
${this.hass.localize(
|
||||
this._params.enabled
|
||||
? "ui.panel.config.labs.progress.backing_up_before_enabling"
|
||||
: "ui.panel.config.labs.progress.backing_up_before_disabling"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-md-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static readonly styles = css`
|
||||
ha-md-dialog {
|
||||
--dialog-content-padding: var(--ha-space-6);
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
column-gap: var(--ha-space-4);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-4) 0;
|
||||
}
|
||||
ha-spinner {
|
||||
--ha-spinner-size: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.heading {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--primary-text-color);
|
||||
margin: 0 0 var(--ha-space-1);
|
||||
}
|
||||
.description {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.25px;
|
||||
color: var(--secondary-text-color);
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"dialog-labs-progress": DialogLabsProgress;
|
||||
}
|
||||
}
|
||||
549
src/panels/config/labs/ha-config-labs.ts
Normal file
549
src/panels/config/labs/ha-config-labs.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import { mdiFlask, mdiHelpCircle, mdiOpenInNew } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import {
|
||||
labsUpdatePreviewFeature,
|
||||
subscribeLabFeatures,
|
||||
} from "../../../data/labs";
|
||||
import type { LabPreviewFeature } from "../../../data/labs";
|
||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import { showLabsPreviewFeatureEnableDialog } from "./show-dialog-labs-preview-feature-enable";
|
||||
import {
|
||||
showLabsProgressDialog,
|
||||
closeLabsProgressDialog,
|
||||
} from "./show-dialog-labs-progress";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-switch";
|
||||
import "../../../layouts/hass-subpage";
|
||||
|
||||
@customElement("ha-config-labs")
|
||||
class HaConfigLabs extends SubscribeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _preview_features: LabPreviewFeature[] = [];
|
||||
|
||||
@state() private _highlightedPreviewFeature?: string;
|
||||
|
||||
private _sortedPreviewFeatures = memoizeOne(
|
||||
(localize: LocalizeFunc, features: LabPreviewFeature[]) =>
|
||||
// Sort by localized integration name alphabetically
|
||||
[...features].sort((a, b) =>
|
||||
domainToName(localize, a.domain).localeCompare(
|
||||
domainToName(localize, b.domain)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
public hassSubscribe() {
|
||||
return [
|
||||
subscribeLabFeatures(this.hass.connection, (features) => {
|
||||
// Load title translations for integrations with preview features
|
||||
const domains = [...new Set(features.map((f) => f.domain))];
|
||||
this.hass.loadBackendTranslation("title", domains);
|
||||
|
||||
this._preview_features = features;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues): void {
|
||||
super.firstUpdated(changedProps);
|
||||
// Load preview_features translations
|
||||
this.hass.loadBackendTranslation("preview_features");
|
||||
this._handleUrlParams();
|
||||
}
|
||||
|
||||
private _handleUrlParams(): void {
|
||||
// Check for feature parameters in URL
|
||||
const domain = extractSearchParam("domain");
|
||||
const previewFeature = extractSearchParam("preview_feature");
|
||||
if (domain && previewFeature) {
|
||||
const previewFeatureId = `${domain}.${previewFeature}`;
|
||||
this._highlightedPreviewFeature = previewFeatureId;
|
||||
// Wait for next render to ensure cards are in DOM
|
||||
this.updateComplete.then(() => {
|
||||
this._scrollToPreviewFeature(previewFeatureId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const sortedFeatures = this._sortedPreviewFeatures(
|
||||
this.hass.localize,
|
||||
this._preview_features
|
||||
);
|
||||
|
||||
return html`
|
||||
<hass-subpage
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
back-path="/config"
|
||||
.header=${this.hass.localize("ui.panel.config.labs.caption")}
|
||||
>
|
||||
${sortedFeatures.length
|
||||
? html`
|
||||
<a
|
||||
slot="toolbar-icon"
|
||||
href="https://www.home-assistant.io/integrations/labs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
.title=${this.hass.localize("ui.common.help")}
|
||||
>
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize("ui.common.help")}
|
||||
.path=${mdiHelpCircle}
|
||||
></ha-icon-button>
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
<div class="content">
|
||||
${!sortedFeatures.length
|
||||
? html`
|
||||
<div class="empty">
|
||||
<ha-svg-icon .path=${mdiFlask}></ha-svg-icon>
|
||||
<h1>
|
||||
${this.hass.localize("ui.panel.config.labs.empty.title")}
|
||||
</h1>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.labs.empty.description"
|
||||
)}
|
||||
<a
|
||||
href="https://www.home-assistant.io/integrations/labs/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.labs.learn_more")}
|
||||
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</a>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<ha-card outlined>
|
||||
<div class="card-content intro-card">
|
||||
<h1>
|
||||
${this.hass.localize("ui.panel.config.labs.intro_title")}
|
||||
</h1>
|
||||
<p class="intro-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.labs.intro_description"
|
||||
)}
|
||||
</p>
|
||||
<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.labs.intro_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
${sortedFeatures.map((preview_feature) =>
|
||||
this._renderPreviewFeature(preview_feature)
|
||||
)}
|
||||
`}
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPreviewFeature(
|
||||
preview_feature: LabPreviewFeature
|
||||
): TemplateResult {
|
||||
const featureName = this.hass.localize(
|
||||
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.name`
|
||||
);
|
||||
|
||||
const description = this.hass.localize(
|
||||
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.description`
|
||||
);
|
||||
|
||||
const integrationName = domainToName(
|
||||
this.hass.localize,
|
||||
preview_feature.domain
|
||||
);
|
||||
|
||||
const integrationNameWithCustomLabel = !preview_feature.is_built_in
|
||||
? `${integrationName} • ${this.hass.localize("ui.panel.config.labs.custom_integration")}`
|
||||
: integrationName;
|
||||
|
||||
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
|
||||
const isHighlighted = this._highlightedPreviewFeature === previewFeatureId;
|
||||
|
||||
// Build description with learn more link if available
|
||||
const descriptionWithLink = preview_feature.learn_more_url
|
||||
? `${description}\n\n[${this.hass.localize("ui.panel.config.labs.learn_more")}](${preview_feature.learn_more_url})`
|
||||
: description;
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
outlined
|
||||
data-feature-id=${previewFeatureId}
|
||||
class=${isHighlighted ? "highlighted" : ""}
|
||||
>
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<img
|
||||
alt=""
|
||||
src=${brandsUrl({
|
||||
domain: preview_feature.domain,
|
||||
type: "icon",
|
||||
useFallback: true,
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div class="feature-title">
|
||||
<span class="integration-name"
|
||||
>${integrationNameWithCustomLabel}</span
|
||||
>
|
||||
<h2>${featureName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ha-markdown .content=${descriptionWithLink} breaks></ha-markdown>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<div>
|
||||
${preview_feature.feedback_url
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.feedback_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.labs.provide_feedback"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
${preview_feature.report_issue_url
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
href=${preview_feature.report_issue_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.labs.report_issue")}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<ha-button
|
||||
appearance="filled"
|
||||
.variant=${preview_feature.enabled ? "danger" : "brand"}
|
||||
@click=${this._handleToggle}
|
||||
.preview_feature=${preview_feature}
|
||||
>
|
||||
${this.hass.localize(
|
||||
preview_feature.enabled
|
||||
? "ui.panel.config.labs.disable"
|
||||
: "ui.panel.config.labs.enable"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _scrollToPreviewFeature(previewFeatureId: string): void {
|
||||
const card = this.shadowRoot?.querySelector(
|
||||
`[data-feature-id="${previewFeatureId}"]`
|
||||
) as HTMLElement;
|
||||
if (card) {
|
||||
card.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Clear highlight after animation
|
||||
setTimeout(() => {
|
||||
this._highlightedPreviewFeature = undefined;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleToggle(ev: Event): Promise<void> {
|
||||
const buttonEl = ev.currentTarget as HTMLElement & {
|
||||
preview_feature: LabPreviewFeature;
|
||||
};
|
||||
const preview_feature = buttonEl.preview_feature;
|
||||
const enabled = !preview_feature.enabled;
|
||||
const previewFeatureId = `${preview_feature.domain}.${preview_feature.preview_feature}`;
|
||||
|
||||
if (enabled) {
|
||||
// Show custom enable dialog with backup option
|
||||
showLabsPreviewFeatureEnableDialog(this, {
|
||||
preview_feature,
|
||||
previewFeatureId,
|
||||
onConfirm: async (shouldCreateBackup) => {
|
||||
await this._performToggle(
|
||||
previewFeatureId,
|
||||
enabled,
|
||||
shouldCreateBackup
|
||||
);
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show simple confirmation dialog for disable
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize("ui.panel.config.labs.disable_title"),
|
||||
text:
|
||||
this.hass.localize(
|
||||
`component.${preview_feature.domain}.preview_features.${preview_feature.preview_feature}.disable_confirmation`
|
||||
) || this.hass.localize("ui.panel.config.labs.disable_confirmation"),
|
||||
confirmText: this.hass.localize("ui.panel.config.labs.disable"),
|
||||
dismissText: this.hass.localize("ui.common.cancel"),
|
||||
destructive: true,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._performToggle(previewFeatureId, enabled, false);
|
||||
}
|
||||
|
||||
private async _performToggle(
|
||||
previewFeatureId: string,
|
||||
enabled: boolean,
|
||||
createBackup: boolean
|
||||
): Promise<void> {
|
||||
if (createBackup) {
|
||||
showLabsProgressDialog(this, { enabled });
|
||||
}
|
||||
|
||||
const parts = previewFeatureId.split(".", 2);
|
||||
if (parts.length !== 2) {
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.unknown_error"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const [domain, preview_feature] = parts;
|
||||
|
||||
try {
|
||||
await labsUpdatePreviewFeature(
|
||||
this.hass,
|
||||
domain,
|
||||
preview_feature,
|
||||
enabled,
|
||||
createBackup
|
||||
);
|
||||
} catch (err: any) {
|
||||
if (createBackup) {
|
||||
closeLabsProgressDialog();
|
||||
}
|
||||
const errorMessage =
|
||||
err?.message || this.hass.localize("ui.common.unknown_error");
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
enabled
|
||||
? "ui.panel.config.labs.enable_failed"
|
||||
: "ui.panel.config.labs.disable_failed",
|
||||
{ error: errorMessage }
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog before showing success toast
|
||||
if (createBackup) {
|
||||
closeLabsProgressDialog();
|
||||
}
|
||||
|
||||
// Show success toast - collection will auto-update via labs_updated event
|
||||
showToast(this, {
|
||||
message: this.hass.localize(
|
||||
enabled
|
||||
? "ui.panel.config.labs.enabled_success"
|
||||
: "ui.panel.config.labs.disabled_success"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyle,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: var(--ha-space-4);
|
||||
min-height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.content:has(.empty) {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
position: relative;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
ha-card.highlighted {
|
||||
animation: highlight-fade 2.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes highlight-fade {
|
||||
0% {
|
||||
box-shadow:
|
||||
0 0 0 var(--ha-border-width-md) var(--primary-color),
|
||||
0 0 var(--ha-shadow-blur-lg) rgba(var(--rgb-primary-color), 0.4);
|
||||
}
|
||||
100% {
|
||||
box-shadow:
|
||||
0 0 0 var(--ha-border-width-md) transparent,
|
||||
0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Intro card */
|
||||
.intro-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.intro-card h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.intro-text {
|
||||
margin: 0 0 var(--ha-space-3);
|
||||
}
|
||||
|
||||
/* Feature cards */
|
||||
.card-content {
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
gap: var(--ha-space-3);
|
||||
margin-bottom: var(--ha-space-4);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.card-header img {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feature-title h2 {
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.integration-name {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: var(--ha-space-12) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty ha-svg-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
color: var(--secondary-text-color);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.empty h1 {
|
||||
margin: var(--ha-space-6) 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0 0 var(--ha-space-6);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.empty a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty a:focus-visible {
|
||||
outline: var(--ha-border-width-md) solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
|
||||
.empty a ha-svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Card actions */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-2);
|
||||
border-top: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
}
|
||||
|
||||
.card-actions > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-labs": HaConfigLabs;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LabPreviewFeature } from "../../../data/labs";
|
||||
|
||||
export interface LabsPreviewFeatureEnableDialogParams {
|
||||
preview_feature: LabPreviewFeature;
|
||||
previewFeatureId: string;
|
||||
onConfirm: (createBackup: boolean) => void;
|
||||
}
|
||||
|
||||
export const loadLabsPreviewFeatureEnableDialog = () =>
|
||||
import("./dialog-labs-preview-feature-enable");
|
||||
|
||||
export const showLabsPreviewFeatureEnableDialog = (
|
||||
element: HTMLElement,
|
||||
params: LabsPreviewFeatureEnableDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-labs-preview-feature-enable",
|
||||
dialogImport: loadLabsPreviewFeatureEnableDialog,
|
||||
dialogParams: params,
|
||||
});
|
||||
};
|
||||
22
src/panels/config/labs/show-dialog-labs-progress.ts
Normal file
22
src/panels/config/labs/show-dialog-labs-progress.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { closeDialog } from "../../../dialogs/make-dialog-manager";
|
||||
|
||||
export interface LabsProgressDialogParams {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export const loadLabsProgressDialog = () => import("./dialog-labs-progress");
|
||||
|
||||
export const showLabsProgressDialog = (
|
||||
element: HTMLElement,
|
||||
dialogParams: LabsProgressDialogParams
|
||||
): void => {
|
||||
fireEvent(element, "show-dialog", {
|
||||
dialogTag: "dialog-labs-progress",
|
||||
dialogImport: loadLabsProgressDialog,
|
||||
dialogParams,
|
||||
});
|
||||
};
|
||||
|
||||
export const closeLabsProgressDialog = () =>
|
||||
closeDialog("dialog-labs-progress");
|
||||
@@ -1265,6 +1265,10 @@ export class HaSceneEditor extends PreventUnsavedMixin(
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
ha-alert ha-button[slot="action"] {
|
||||
width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-fab.dirty {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
protected render() {
|
||||
const showAttributes = !this.narrow && this.showAttributes;
|
||||
return html`
|
||||
<div
|
||||
<div
|
||||
class=${classMap({ entities: true, "hide-attributes": !showAttributes })}
|
||||
role="table"
|
||||
>
|
||||
@@ -245,6 +245,7 @@ class HaPanelDevStateRenderer extends LitElement {
|
||||
:host([virtualize]) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.entities {
|
||||
|
||||
@@ -46,12 +46,39 @@ export class EnergyElectricityViewStrategy extends ReactiveElement {
|
||||
const hasBattery = prefs.energy_sources.some(
|
||||
(source) => source.type === "battery"
|
||||
);
|
||||
const hasPowerSources = prefs.energy_sources.find(
|
||||
(source) =>
|
||||
(source.type === "solar" && source.stat_rate) ||
|
||||
(source.type === "battery" && source.stat_rate) ||
|
||||
(source.type === "grid" && source.power?.length)
|
||||
);
|
||||
const hasPowerDevices = prefs.device_consumption.find(
|
||||
(device) => device.stat_rate
|
||||
);
|
||||
|
||||
view.cards!.push({
|
||||
type: "energy-compare",
|
||||
collection_key: "energy_dashboard",
|
||||
});
|
||||
|
||||
if (hasPowerSources) {
|
||||
if (hasPowerDevices) {
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.power_sankey_title"),
|
||||
type: "power-sankey",
|
||||
collection_key: collectionKey,
|
||||
grid_options: {
|
||||
columns: 24,
|
||||
},
|
||||
});
|
||||
}
|
||||
view.cards!.push({
|
||||
title: hass.localize("ui.panel.energy.cards.power_sources_graph_title"),
|
||||
type: "power-sources-graph",
|
||||
collection_key: collectionKey,
|
||||
});
|
||||
}
|
||||
|
||||
// Only include if we have a grid or battery.
|
||||
if (hasGrid || hasBattery) {
|
||||
view.cards!.push({
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
} from "../../../../data/lovelace_custom_cards";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
|
||||
import "../../badges/hui-badge";
|
||||
@@ -395,6 +398,7 @@ export class HuiDialogEditBadge
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
:host {
|
||||
--code-mirror-max-height: calc(100vh - 176px);
|
||||
@@ -403,8 +407,6 @@ export class HuiDialogEditBadge
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 100px;
|
||||
--dialog-z-index: 6;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: 40px;
|
||||
--mdc-dialog-max-width: 90vw;
|
||||
--dialog-content-padding: 24px 12px;
|
||||
}
|
||||
|
||||
@@ -184,19 +184,15 @@ export class HuiCreateDialogCard
|
||||
return [
|
||||
haStyleDialog,
|
||||
css`
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
/* overrule the ha-style-dialog max-height on small screens */
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media all and (min-width: 850px) {
|
||||
ha-dialog {
|
||||
--mdc-dialog-min-width: 845px;
|
||||
--mdc-dialog-min-height: calc(100vh - 72px);
|
||||
--mdc-dialog-max-height: calc(100vh - 72px);
|
||||
--mdc-dialog-min-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
} from "../../../../data/lovelace_custom_cards";
|
||||
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
|
||||
@@ -371,6 +374,7 @@ export class HuiDialogEditCard
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
:host {
|
||||
--code-mirror-max-height: calc(100vh - 176px);
|
||||
@@ -379,8 +383,6 @@ export class HuiDialogEditCard
|
||||
ha-dialog {
|
||||
--mdc-dialog-max-width: 100px;
|
||||
--dialog-z-index: 6;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: 40px;
|
||||
--mdc-dialog-max-width: 90vw;
|
||||
--dialog-content-padding: 24px 12px;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ import "../../../../../components/ha-dialog-header";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-list-item";
|
||||
import type { LovelaceStrategyConfig } from "../../../../../data/lovelace/config/strategy";
|
||||
import { haStyleDialog } from "../../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
import { showSaveSuccessToast } from "../../../../../util/toast-saved-success";
|
||||
import { cleanLegacyStrategyConfig } from "../../../strategies/legacy-strategy";
|
||||
@@ -219,14 +222,21 @@ class DialogDashboardStrategyEditor extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0 24px;
|
||||
--dialog-surface-position: fixed;
|
||||
--dialog-surface-top: 40px;
|
||||
--mdc-dialog-min-width: min(640px, calc(100% - 32px));
|
||||
--mdc-dialog-max-width: min(640px, calc(100% - 32px));
|
||||
--mdc-dialog-max-height: calc(100% - 80px);
|
||||
--mdc-dialog-min-width: min(
|
||||
640px,
|
||||
calc(100vw - var(--safe-area-inset-x))
|
||||
);
|
||||
--mdc-dialog-max-width: min(
|
||||
640px,
|
||||
calc(100vw - var(--safe-area-inset-x))
|
||||
);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--ha-space-20) - var(--safe-area-inset-y)
|
||||
);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
@@ -234,9 +244,12 @@ class DialogDashboardStrategyEditor extends LitElement {
|
||||
ha-dialog {
|
||||
height: 100%;
|
||||
--dialog-surface-top: 0px;
|
||||
--mdc-dialog-min-width: 100%;
|
||||
--mdc-dialog-max-width: 100%;
|
||||
--mdc-dialog-max-height: 100%;
|
||||
--mdc-dialog-min-width: 100vw;
|
||||
--mdc-dialog-max-width: 100vw;
|
||||
--mdc-dialog-min-height: 100vh;
|
||||
--mdc-dialog-min-height: 100svh;
|
||||
--mdc-dialog-max-height: 100vh;
|
||||
--mdc-dialog-max-height: 100svh;
|
||||
--dialog-content-padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ import {
|
||||
} from "../../../../data/lovelace/config/view";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { Lovelace } from "../../types";
|
||||
import { addSection, deleteSection, moveSection } from "../config-util";
|
||||
@@ -418,19 +421,8 @@ export class HuiDialogEditSection
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: 40px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
/* When in fullscreen dialog should be attached to top */
|
||||
ha-dialog {
|
||||
--dialog-surface-margin-top: 0px;
|
||||
}
|
||||
}
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,10 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "../../components/hui-entity-editor";
|
||||
import type { Lovelace } from "../../types";
|
||||
@@ -631,19 +634,8 @@ export class HuiDialogEditView extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: 40px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
/* When in fullscreen dialog should be attached to top */
|
||||
ha-dialog {
|
||||
--dialog-surface-margin-top: 0px;
|
||||
}
|
||||
}
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ import "../../../../components/ha-yaml-editor";
|
||||
import type { HaYamlEditor } from "../../../../components/ha-yaml-editor";
|
||||
import type { LovelaceViewHeaderConfig } from "../../../../data/lovelace/config/view";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../../../resources/styles";
|
||||
import {
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
} from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import "./hui-view-header-settings-editor";
|
||||
import type { EditViewHeaderDialogParams } from "./show-edit-view-header-dialog";
|
||||
@@ -201,19 +204,8 @@ export class HuiDialogEditViewHeader extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleDialog,
|
||||
haStyleDialogFixedTop,
|
||||
css`
|
||||
ha-dialog {
|
||||
/* Set the top top of the dialog to a fixed position, so it doesnt jump when the content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: 40px;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
/* When in fullscreen dialog should be attached to top */
|
||||
ha-dialog {
|
||||
--dialog-surface-margin-top: 0px;
|
||||
}
|
||||
}
|
||||
ha-dialog.yaml-mode {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
|
||||
@@ -188,6 +188,13 @@ export const getMyRedirects = (): Redirects => ({
|
||||
helpers: {
|
||||
redirect: "/config/helpers",
|
||||
},
|
||||
labs: {
|
||||
redirect: "/config/labs",
|
||||
params: {
|
||||
domain: "string?",
|
||||
preview_feature: "string?",
|
||||
},
|
||||
},
|
||||
tags: {
|
||||
component: "tag",
|
||||
redirect: "/config/tags",
|
||||
|
||||
@@ -15,7 +15,7 @@ const renderMarkdown = async (
|
||||
allowSvg?: boolean;
|
||||
allowDataUrl?: boolean;
|
||||
} = {}
|
||||
): Promise<string> => {
|
||||
): Promise<string[]> => {
|
||||
if (!whiteListNormal) {
|
||||
whiteListNormal = {
|
||||
...getDefaultWhiteList(),
|
||||
@@ -53,38 +53,43 @@ const renderMarkdown = async (
|
||||
whiteList.a.push("download");
|
||||
}
|
||||
|
||||
return filterXSS(await marked(content, markedOptions), {
|
||||
whiteList,
|
||||
onTagAttr: (
|
||||
tag: string,
|
||||
name: string,
|
||||
value: string
|
||||
): string | undefined => {
|
||||
// Override the default `onTagAttr` behavior to only render
|
||||
// our markdown checkboxes.
|
||||
// Returning undefined causes the default measure to be taken
|
||||
// in the xss library.
|
||||
if (tag === "input") {
|
||||
if (
|
||||
(name === "type" && value === "checkbox") ||
|
||||
name === "checked" ||
|
||||
name === "disabled"
|
||||
) {
|
||||
return undefined;
|
||||
marked.setOptions(markedOptions);
|
||||
|
||||
const tokens = marked.lexer(content);
|
||||
return tokens.map((token) =>
|
||||
filterXSS(marked.parser([token]), {
|
||||
whiteList,
|
||||
onTagAttr: (
|
||||
tag: string,
|
||||
name: string,
|
||||
value: string
|
||||
): string | undefined => {
|
||||
// Override the default `onTagAttr` behavior to only render
|
||||
// our markdown checkboxes.
|
||||
// Returning undefined causes the default measure to be taken
|
||||
// in the xss library.
|
||||
if (tag === "input") {
|
||||
if (
|
||||
(name === "type" && value === "checkbox") ||
|
||||
name === "checked" ||
|
||||
name === "disabled"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (
|
||||
hassOptions.allowDataUrl &&
|
||||
tag === "a" &&
|
||||
name === "href" &&
|
||||
value.startsWith("data:")
|
||||
) {
|
||||
return `href="${value}"`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
if (
|
||||
hassOptions.allowDataUrl &&
|
||||
tag === "a" &&
|
||||
name === "href" &&
|
||||
value.startsWith("data:")
|
||||
) {
|
||||
return `href="${value}"`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const api = {
|
||||
|
||||
@@ -142,6 +142,11 @@ export const haStyleDialog = css`
|
||||
--mdc-dialog-max-width: 600px;
|
||||
--mdc-dialog-max-width: min(600px, 95vw);
|
||||
--justify-action-buttons: space-between;
|
||||
--dialog-container-padding: var(--safe-area-inset-top, var(--ha-space-0))
|
||||
var(--safe-area-inset-right, var(--ha-space-0))
|
||||
var(--safe-area-inset-bottom, var(--ha-space-0))
|
||||
var(--safe-area-inset-left, var(--ha-space-0));
|
||||
--dialog-surface-padding: var(--ha-space-0);
|
||||
}
|
||||
|
||||
ha-dialog .form {
|
||||
@@ -161,9 +166,11 @@ export const haStyleDialog = css`
|
||||
--mdc-dialog-min-height: 100svh;
|
||||
--mdc-dialog-max-height: 100vh;
|
||||
--mdc-dialog-max-height: 100svh;
|
||||
--dialog-surface-padding: var(--safe-area-inset-top)
|
||||
var(--safe-area-inset-right) var(--safe-area-inset-bottom)
|
||||
var(--safe-area-inset-left);
|
||||
--dialog-container-padding: var(--ha-space-0);
|
||||
--dialog-surface-padding: var(--safe-area-inset-top, var(--ha-space-0))
|
||||
var(--safe-area-inset-right, var(--ha-space-0))
|
||||
var(--safe-area-inset-bottom, var(--ha-space-0))
|
||||
var(--safe-area-inset-left, var(--ha-space-0));
|
||||
--vertical-align-dialog: flex-end;
|
||||
--ha-dialog-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
@@ -173,6 +180,37 @@ export const haStyleDialog = css`
|
||||
}
|
||||
`;
|
||||
|
||||
export const haStyleDialogFixedTop = css`
|
||||
ha-dialog {
|
||||
/* Pin dialog to top so it doesn't jump when content changes size */
|
||||
--vertical-align-dialog: flex-start;
|
||||
--dialog-surface-margin-top: var(--ha-space-10);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100vh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
|
||||
--safe-area-inset-y,
|
||||
var(--ha-space-0)
|
||||
)
|
||||
);
|
||||
--mdc-dialog-max-height: calc(
|
||||
100svh - var(--dialog-surface-margin-top) - var(--ha-space-2) - var(
|
||||
--safe-area-inset-y,
|
||||
var(--ha-space-0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-dialog {
|
||||
/* When in fullscreen, dialog should be attached to top */
|
||||
--dialog-surface-margin-top: var(--ha-space-0);
|
||||
--mdc-dialog-min-height: 100vh;
|
||||
--mdc-dialog-min-height: 100svh;
|
||||
--mdc-dialog-max-height: 100vh;
|
||||
--mdc-dialog-max-height: 100svh;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const haStyleScrollbar = css`
|
||||
.ha-scrollbar::-webkit-scrollbar {
|
||||
width: 0.4rem;
|
||||
|
||||
@@ -52,6 +52,8 @@ export const waColorStyles = css`
|
||||
--wa-color-danger-on-normal: var(--ha-color-on-danger-normal);
|
||||
--wa-color-danger-on-quiet: var(--ha-color-on-danger-quiet);
|
||||
|
||||
--wa-color-text-quiet: var(--ha-color-text-secondary);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
--wa-color-surface-default: var(--card-background-color);
|
||||
--wa-color-surface-raised: var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff));
|
||||
@@ -62,5 +64,7 @@ export const waColorStyles = css`
|
||||
|
||||
--wa-focus-ring-color: var(--ha-color-neutral-60);
|
||||
--wa-shadow-l: 4px 8px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
|
||||
--wa-color-text-normal: var(--ha-color-text-primary);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -32,6 +32,9 @@ export const mainStyles = css`
|
||||
--safe-area-inset-bottom: var(--app-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||
--safe-area-inset-left: var(--app-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||
--safe-area-inset-right: var(--app-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||
|
||||
--safe-area-inset-y: calc(var(--safe-area-inset-top, 0px) + var(--safe-area-inset-bottom, 0px));
|
||||
--safe-area-inset-x: calc(var(--safe-area-inset-left, 0px) + var(--safe-area-inset-right, 0px));
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -9,12 +9,16 @@ export const waMainStyles = css`
|
||||
--wa-focus-ring-offset: 2px;
|
||||
--wa-focus-ring: var(--wa-focus-ring-style) var(--wa-focus-ring-width) var(--wa-focus-ring-color);
|
||||
|
||||
--wa-space-xs: var(--ha-space-2);
|
||||
--wa-space-m: var(--ha-space-4);
|
||||
--wa-space-l: var(--ha-space-6);
|
||||
--wa-space-xl: var(--ha-space-8);
|
||||
|
||||
--wa-form-control-padding-block: 0.75em;
|
||||
--wa-form-control-value-line-height: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-font-weight-action: var(--ha-font-weight-medium);
|
||||
--wa-transition-normal: 150ms;
|
||||
--wa-transition-fast: 75ms;
|
||||
--wa-transition-easing: ease;
|
||||
|
||||
@@ -28,6 +32,7 @@ export const waMainStyles = css`
|
||||
|
||||
--wa-line-height-condensed: var(--ha-line-height-condensed);
|
||||
|
||||
--wa-font-size-m: var(--ha-font-size-m);
|
||||
--wa-shadow-s: var(--ha-box-shadow-s);
|
||||
--wa-shadow-m: var(--ha-box-shadow-m);
|
||||
--wa-shadow-l: var(--ha-box-shadow-l);
|
||||
|
||||
@@ -3998,7 +3998,25 @@
|
||||
"item_pasted": "{item} pasted",
|
||||
"ctrl": "Ctrl",
|
||||
"del": "Del",
|
||||
"targets": "Targets",
|
||||
"select_target": "Select a target",
|
||||
"home": "Home",
|
||||
"unassigned": "Unassigned",
|
||||
"blocks": "Blocks",
|
||||
"show_more": "Show more",
|
||||
"unassigned_entities": "Unassigned entities",
|
||||
"unassigned_devices": "Unassigned devices",
|
||||
"empty_section_search": {
|
||||
"block": "No blocks found for {term}",
|
||||
"entity": "No entities found for {term}",
|
||||
"device": "No devices found for {term}",
|
||||
"area": "No areas or floors found for {term}",
|
||||
"label": "No labels found for {term}"
|
||||
},
|
||||
"load_target_items_failed": "Failed to load target items for",
|
||||
"other_areas": "Other areas",
|
||||
"services": "Services",
|
||||
"helpers": "Helpers",
|
||||
"triggers": {
|
||||
"name": "Triggers",
|
||||
"header": "When",
|
||||
@@ -4006,7 +4024,10 @@
|
||||
"learn_more": "Learn more about triggers",
|
||||
"triggered": "Triggered",
|
||||
"add": "Add trigger",
|
||||
"empty_search": "No triggers found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No triggers and targets found for {term}",
|
||||
"item": "No triggers found for {term}"
|
||||
},
|
||||
"id": "Trigger ID",
|
||||
"optional": "Optional",
|
||||
"edit_id": "Edit ID",
|
||||
@@ -4027,6 +4048,7 @@
|
||||
"copied_to_clipboard": "Trigger copied to clipboard",
|
||||
"cut_to_clipboard": "Trigger cut to clipboard",
|
||||
"select": "Select a trigger",
|
||||
"no_items_for_target": "No triggers available for",
|
||||
"groups": {
|
||||
"device": {
|
||||
"label": "Device"
|
||||
@@ -4268,7 +4290,10 @@
|
||||
"description": "All conditions added here need to be satisfied for the automation to run. A condition can be satisfied or not at any given time, for example: ''If {user} is home''. You can use building blocks to create more complex conditions.",
|
||||
"learn_more": "Learn more about conditions",
|
||||
"add": "Add condition",
|
||||
"empty_search": "No conditions and blocks found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No conditions, blocks and targets found for {term}",
|
||||
"item": "No conditions found for {term}"
|
||||
},
|
||||
"add_building_block": "Add building block",
|
||||
"test": "Test",
|
||||
"testing_error": "Condition did not pass",
|
||||
@@ -4291,6 +4316,7 @@
|
||||
"copied_to_clipboard": "Condition copied to clipboard",
|
||||
"cut_to_clipboard": "Condition cut to clipboard",
|
||||
"select": "Select a condition",
|
||||
"no_items_for_target": "No conditions available for",
|
||||
"groups": {
|
||||
"device": {
|
||||
"label": "Device"
|
||||
@@ -4436,7 +4462,10 @@
|
||||
"description": "All actions added here will be performed in sequence when the automation runs. An action usually controls one of your areas, devices, or entities, for example: 'Turn on the lights'. You can use building blocks to create more complex sequences of actions.",
|
||||
"learn_more": "Learn more about actions",
|
||||
"add": "Add action",
|
||||
"empty_search": "No actions and blocks found for {term}",
|
||||
"empty_search": {
|
||||
"global": "No actions, blocks and targets found for {term}",
|
||||
"item": "No actions found for {term}"
|
||||
},
|
||||
"add_building_block": "Add building block",
|
||||
"invalid_action": "Invalid action",
|
||||
"run": "Run action",
|
||||
@@ -4461,6 +4490,7 @@
|
||||
"copied_to_clipboard": "Action copied to clipboard",
|
||||
"cut_to_clipboard": "Action cut to clipboard",
|
||||
"select": "Select an action",
|
||||
"no_items_for_target": "No actions available for",
|
||||
"groups": {
|
||||
"device_id": {
|
||||
"label": "Device"
|
||||
@@ -6792,6 +6822,45 @@
|
||||
"intro": "Share anonymized information from your installation to help make Home Assistant better and help us convince manufacturers to add local control and privacy-focused features.",
|
||||
"download_device_info": "Preview device analytics"
|
||||
},
|
||||
"labs": {
|
||||
"caption": "Labs",
|
||||
"custom_integration": "Custom integration",
|
||||
"description": "Preview new features",
|
||||
"description_enabled": "Preview features are enabled",
|
||||
"intro_title": "Home Assistant Labs",
|
||||
"intro_subtitle": "Preview upcoming features",
|
||||
"intro_description": "Home Assistant Labs lets you preview new features we're actively working on. These features are stable and fully functional, but may not yet include the complete feature set we envision. We're still refining the design and approach based on your feedback.",
|
||||
"intro_warning": "Preview features may change or be replaced with different solutions in future releases.",
|
||||
"empty": {
|
||||
"title": "No preview features available",
|
||||
"description": "There are currently no preview features available to try. Check back in future releases for new features!"
|
||||
},
|
||||
"learn_more": "Learn more",
|
||||
"provide_feedback": "Provide feedback",
|
||||
"report_issue": "Report issue",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enable_title": "Enable preview feature?",
|
||||
"enable_confirmation": "This preview feature is stable but may evolve based on feedback. Enabling it may affect your setup, and changes may persist after disabling.",
|
||||
"disable_title": "Disable preview feature?",
|
||||
"disable_confirmation": "This will disable the preview feature, but changes made while it was active may still affect your setup.",
|
||||
"enabled_success": "Preview feature enabled",
|
||||
"disabled_success": "Preview feature disabled",
|
||||
"enable_failed": "Enabling preview feature failed: {error}",
|
||||
"disable_failed": "Disabling preview feature failed: {error}",
|
||||
"progress": {
|
||||
"creating_backup": "Creating backup",
|
||||
"backing_up_before_enabling": "Home Assistant is being backed up before enabling the Home Assistant Labs preview feature",
|
||||
"backing_up_before_disabling": "Home Assistant is being backed up before disabling the Home Assistant Labs preview feature"
|
||||
},
|
||||
"create_backup": {
|
||||
"automatic": "Automatic backup before enabling",
|
||||
"automatic_description_last": "Last automatic backup {relative_time}.",
|
||||
"automatic_description_none": "No automatic backup yet.",
|
||||
"manual": "Create manual backup before enabling",
|
||||
"manual_description": "Includes Home Assistant settings and history."
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"caption": "Network",
|
||||
"description": "External access {state}",
|
||||
@@ -9470,7 +9539,8 @@
|
||||
"energy_devices_detail_graph_title": "Individual devices detail usage",
|
||||
"energy_sankey_title": "Energy flow",
|
||||
"energy_top_consumers_title": "Top consumers",
|
||||
"power_sankey_title": "Current power flow"
|
||||
"power_sankey_title": "Current power flow",
|
||||
"power_sources_graph_title": "Power sources"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -1940,9 +1940,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@home-assistant/webawesome@npm:3.0.0":
|
||||
version: 3.0.0
|
||||
resolution: "@home-assistant/webawesome@npm:3.0.0"
|
||||
"@home-assistant/webawesome@npm:3.0.0-ha.0":
|
||||
version: 3.0.0-ha.0
|
||||
resolution: "@home-assistant/webawesome@npm:3.0.0-ha.0"
|
||||
dependencies:
|
||||
"@ctrl/tinycolor": "npm:4.1.0"
|
||||
"@floating-ui/dom": "npm:^1.6.13"
|
||||
@@ -1953,7 +1953,7 @@ __metadata:
|
||||
lit: "npm:^3.2.1"
|
||||
nanoid: "npm:^5.1.5"
|
||||
qr-creator: "npm:^1.0.0"
|
||||
checksum: 10/03400894cfee8548fd5b1f5c56d31d253830e704b18ba69d36ce6b761d8b1bef2fb52cffba8d9b033033bb582f2f51a2d6444d82622f66d70150e2104fcb49e2
|
||||
checksum: 10/2034d498d5b26bb0573ebc2c9aadd144604bb48c04becbae0c67b16857d8e5d6562626e795974362c3fc41e9b593a9005595d8b5ff434b1569b2d724af13043b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -9226,7 +9226,7 @@ __metadata:
|
||||
"@fullcalendar/list": "npm:6.1.19"
|
||||
"@fullcalendar/luxon3": "npm:6.1.19"
|
||||
"@fullcalendar/timegrid": "npm:6.1.19"
|
||||
"@home-assistant/webawesome": "npm:3.0.0"
|
||||
"@home-assistant/webawesome": "npm:3.0.0-ha.0"
|
||||
"@lezer/highlight": "npm:1.2.3"
|
||||
"@lit-labs/motion": "npm:1.0.9"
|
||||
"@lit-labs/observers": "npm:2.0.6"
|
||||
|
||||
Reference in New Issue
Block a user