mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-06 21:14:26 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb88d66421 | ||
|
|
23b161d257 | ||
|
|
123a32dd81 | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 |
No files matched your search
@@ -16,6 +16,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
@@ -35,6 +36,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -28,6 +28,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -68,8 +68,42 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
// Its exports define the components source aliases below
|
||||
path.join(__dirname, "..", "packages", "components", "package.json"),
|
||||
];
|
||||
|
||||
// Resolve @home-assistant/components entry points to their sources, so the package
|
||||
// needs no build step during development. The entry points are read from the
|
||||
// "ha-source" export condition of the package; subpath patterns are expanded
|
||||
// for every element, i.e. every directory with a global.ts, like "card".
|
||||
const COMPONENTS_PACKAGE_DIR = path.join(
|
||||
paths.root_dir,
|
||||
"packages",
|
||||
"components"
|
||||
);
|
||||
const COMPONENTS_SRC_DIR = path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
"src",
|
||||
"components"
|
||||
);
|
||||
const componentElements = fs
|
||||
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
|
||||
.filter((file) => path.basename(file) === "global.ts")
|
||||
.map((file) => path.dirname(file).split(path.sep).join("/"));
|
||||
const componentsSourceAliases = Object.fromEntries(
|
||||
Object.entries(require("../packages/components/package.json").exports)
|
||||
.filter(([, target]) => target["ha-source"])
|
||||
.flatMap(([subpath, target]) =>
|
||||
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
|
||||
`${path.posix.join("@home-assistant/components", name ? subpath.replaceAll("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replaceAll("*", name) : target["ha-source"]
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
@@ -244,6 +278,9 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -428,6 +465,7 @@ const createRspackConfig = ({
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -301,6 +301,14 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Published as native ESM, where package subpath imports such as
|
||||
// "lit/decorators.js" need their extension.
|
||||
files: ["packages/*/src/**/*.ts"],
|
||||
rules: {
|
||||
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
+3
-2
@@ -13,7 +13,7 @@
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -147,13 +147,14 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.0",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Registration
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | For |
|
||||
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
|
||||
|
||||
`<element>` is the tag name without the `hac-` prefix, like `card` for
|
||||
`hac-card`.
|
||||
|
||||
Both use the copy bundled with your code. A scoped copy is isolated from the
|
||||
one Home Assistant registers globally, so a custom card keeps the version it
|
||||
was built with.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/card";
|
||||
```
|
||||
|
||||
### Scoped
|
||||
|
||||
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
|
||||
`createScopedElementRegistry` for plain custom elements. Forgetting the
|
||||
elements is a TypeScript error, and a runtime error in plain JavaScript.
|
||||
|
||||
Lit:
|
||||
|
||||
```ts
|
||||
import { LitElement, html } from "lit";
|
||||
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
|
||||
render() {
|
||||
return html`<hac-card>…</hac-card>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain custom elements:
|
||||
|
||||
```js
|
||||
import {
|
||||
attachScopedShadow,
|
||||
createScopedElement,
|
||||
createScopedElementRegistry,
|
||||
} from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
// Create the registry once, outside the class: all instances of the card share it
|
||||
const registry = createScopedElementRegistry([hacCard]);
|
||||
|
||||
class MyCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = attachScopedShadow(this, { mode: "open" }, registry);
|
||||
root.innerHTML = "<hac-card>…</hac-card>";
|
||||
// or: root.append(createScopedElement(root, "hac-card"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
|
||||
registry. `document.createElement` uses the global one, so avoid it for
|
||||
scoped elements.
|
||||
|
||||
Scoped registries need native support (Chrome 146+, Safari 26) or
|
||||
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
|
||||
Without either, elements are registered globally instead, and an already
|
||||
registered tag name keeps its existing class.
|
||||
|
||||
Only elements rendered inside the shadow root use the scoped registry. Light
|
||||
DOM content, such as slotted elements, uses the registry of the tree it is
|
||||
written in.
|
||||
|
||||
`lit` is a peer dependency.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
|
||||
`document.querySelector("hac-card")` and templates are typed once the element
|
||||
is imported. For the class itself, import its type from the `scoped` entry
|
||||
point; `import type` loads nothing at runtime:
|
||||
|
||||
```ts
|
||||
import type { HacCard } from "@home-assistant/components/card/scoped";
|
||||
```
|
||||
|
||||
The package has no root entry point: each element and the scoped helpers
|
||||
(`@home-assistant/components/scoped`) have their own.
|
||||
|
||||
## Theming
|
||||
|
||||
Each component exposes its own `--hac-*` custom properties, documented in its
|
||||
source, which take precedence. Next come the theme variables of the existing
|
||||
Home Assistant element it replaces, so current themes keep working: `hac-card`
|
||||
reads the variables of `ha-card` (`--ha-card-background`,
|
||||
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
|
||||
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
|
||||
their light values, so the components also render outside Home Assistant.
|
||||
|
||||
The legacy theme variables are a temporary bridge for this experiment, not the
|
||||
target design. Home Assistant always defines some of them, like
|
||||
`--card-background-color`, so inside Home Assistant they currently win over
|
||||
the semantic tokens. Once themes move to the semantic tokens, the legacy
|
||||
variables will be removed from the fallbacks.
|
||||
|
||||
## Structure
|
||||
|
||||
Each element has its own directory, named like its import path:
|
||||
|
||||
```
|
||||
src/components/card/ <hac-card> -> @home-assistant/components/card
|
||||
hac-card.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts element for a scoped registry -> …/card/scoped
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
Entry points are mapped with subpath patterns in `package.json`, so a new
|
||||
element directory needs no `package.json` change. Only `global.ts` files have
|
||||
side effects, which `sideEffects` relies on for tree shaking.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @home-assistant/components build # ESM, type declarations and custom-elements.json to dist/
|
||||
pnpm --filter @home-assistant/components dev # rebuild on change
|
||||
```
|
||||
|
||||
Inside this repository the app uses the sources directly through
|
||||
the `ha-source` export condition, so no build is needed during development.
|
||||
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
|
||||
source file becomes its own ES module. The build then generates a
|
||||
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
|
||||
the JSDoc of each element, so document new slots, parts, CSS properties and
|
||||
events there.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
|
||||
// editors and documentation tools. Only hac-*.ts files declare elements; the
|
||||
// entry points just re-export them.
|
||||
|
||||
const forEachDeclaration = (customElementsManifest, callback) => {
|
||||
for (const module of customElementsManifest.modules) {
|
||||
for (const declaration of module.declarations ?? []) {
|
||||
callback(declaration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The analyzer leaves the readme empty. Paths in the manifest are relative to
|
||||
// the package root, like the module paths.
|
||||
const packageReadme = () => ({
|
||||
name: "package-readme",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
customElementsManifest.readme = "README.md";
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are documented with @summary, which the analyzer only puts into
|
||||
// `summary`. Most tools show `description`, so fall back to the summary.
|
||||
const summaryAsDescription = () => ({
|
||||
name: "summary-as-description",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
if (!declaration.description && declaration.summary) {
|
||||
declaration.description = declaration.summary;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Private and protected members are internal, so keep them out of the
|
||||
// documented API.
|
||||
const publicApiOnly = () => ({
|
||||
name: "public-api-only",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
declaration.members = declaration.members?.filter(
|
||||
(member) => !member.privacy || member.privacy === "public"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const distPaths = () => {
|
||||
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
|
||||
const rewrite = (node) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(rewrite);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if ((key === "path" || key === "module") && typeof value === "string") {
|
||||
node[key] = toDist(value);
|
||||
} else {
|
||||
rewrite(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "dist-paths",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
rewrite(customElementsManifest.modules);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
globs: ["src/components/**/hac-*.ts"],
|
||||
outdir: "dist",
|
||||
litelement: true,
|
||||
plugins: [
|
||||
packageReadme(),
|
||||
summaryAsDescription(),
|
||||
publicApiOnly(),
|
||||
distPaths(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@home-assistant/components",
|
||||
"version": "0.0.0",
|
||||
"description": "Home Assistant web components (hac-* elements) built with Lit",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/home-assistant/frontend.git",
|
||||
"directory": "packages/components"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/components/**/global.js",
|
||||
"./src/components/**/global.ts"
|
||||
],
|
||||
"customElements": "dist/custom-elements.json",
|
||||
"exports": {
|
||||
"./scoped": {
|
||||
"ha-source": "./src/scoped.ts",
|
||||
"types": "./dist/scoped.d.ts",
|
||||
"import": "./dist/scoped.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./*/scoped": {
|
||||
"ha-source": "./src/components/*/scoped.ts",
|
||||
"types": "./dist/components/*/scoped.d.ts",
|
||||
"import": "./dist/components/*/scoped.js"
|
||||
},
|
||||
"./*/info": {
|
||||
"ha-source": "./src/components/*/info.ts",
|
||||
"types": "./dist/components/*/info.d.ts",
|
||||
"import": "./dist/components/*/info.js"
|
||||
},
|
||||
"./*": {
|
||||
"ha-source": "./src/components/*/global.ts",
|
||||
"types": "./dist/components/*/global.d.ts",
|
||||
"import": "./dist/components/*/global.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rslib build && cem analyze",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.5",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
// Bundleless build: every source file becomes its own ESM file, so consumers'
|
||||
// bundlers only include the entry points they import. Only global.ts files
|
||||
// register elements; everything else is free of side effects.
|
||||
export default defineConfig({
|
||||
source: {
|
||||
entry: { index: ["./src/**/*.ts"] },
|
||||
decorators: { version: "legacy" },
|
||||
tsconfigPath: "./tsconfig.json",
|
||||
},
|
||||
tools: {
|
||||
swc: { jsc: { transform: { decoratorMetadata: false } } },
|
||||
},
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
bundle: false,
|
||||
syntax: "es2021",
|
||||
dts: true,
|
||||
output: { target: "web", distPath: { root: "./dist" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { define } from "../../utils/registration/define";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> in the global registry, as used by Home Assistant
|
||||
define(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* @element hac-card
|
||||
*
|
||||
* @summary
|
||||
* Card that groups related content, with an optional header and footer.
|
||||
*
|
||||
* @slot header - Header of the card. Its spacing replaces the top padding of
|
||||
* the content.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card. Hidden when empty.
|
||||
*
|
||||
* @csspart header - Wrapper of the header slot.
|
||||
* @csspart content - Wrapper of the default slot.
|
||||
* @csspart footer - Wrapper of the footer slot.
|
||||
*
|
||||
* @cssprop --hac-card-background - Background of the card.
|
||||
* @cssprop --hac-card-color - Text color of the card.
|
||||
* @cssprop --hac-card-border-color - Border color of the card and the footer divider.
|
||||
* @cssprop --hac-card-border-width - Border width of the card.
|
||||
* @cssprop --hac-card-border-radius - Border radius of the card.
|
||||
* @cssprop --hac-card-box-shadow - Box shadow of the card.
|
||||
* @cssprop --hac-card-content-padding - Padding of the content.
|
||||
* @cssprop --hac-card-footer-padding - Padding of the footer.
|
||||
*/
|
||||
export class HacCard extends LitElement {
|
||||
/**
|
||||
* Visual style of the card. `outlined` draws a border without a shadow.
|
||||
* `raised` hides the border and lifts the card with a shadow instead.
|
||||
*/
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header" part="header" ?hidden=${!this._hasHeader}>
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
</div>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
part="content"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" part="footer" ?hidden=${!this._hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._footerChanged}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _headerChanged(ev: Event) {
|
||||
this._hasHeader = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _contentChanged(ev: Event) {
|
||||
this._hasContent = hasAssignedContent(ev);
|
||||
}
|
||||
|
||||
private _footerChanged(ev: Event) {
|
||||
this._hasFooter = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback order: --hac-card-*, then the theme variables of ha-card
|
||||
* (--ha-card-*, --card-background-color, --divider-color,
|
||||
* --primary-text-color) so existing themes keep working, then the semantic
|
||||
* tokens and their light values for use outside Home Assistant.
|
||||
* The ha-card variables are temporary, see "Theming" in the README.
|
||||
*/
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default, #fff))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
color: var(
|
||||
--hac-card-color,
|
||||
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
|
||||
);
|
||||
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
|
||||
solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(
|
||||
--ha-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
)
|
||||
);
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
var(
|
||||
--ha-card-box-shadow,
|
||||
var(
|
||||
--ha-box-shadow-s,
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.08),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
|
||||
}
|
||||
|
||||
/* The header already provides the spacing above the content */
|
||||
.content.below-header {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
|
||||
border-block-start: 1px solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
);
|
||||
}
|
||||
|
||||
.header[hidden],
|
||||
.content[hidden],
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-card": HacCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-card";
|
||||
|
||||
/** Version of <hac-card>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacCard };
|
||||
|
||||
/**
|
||||
* The bundled <hac-card> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacCard])` or
|
||||
* `createScopedElementRegistry([hacCard])`.
|
||||
*/
|
||||
export const hacCard: ElementDefinition<typeof HacCard> = {
|
||||
tagName: TAG_NAME,
|
||||
element: HacCard,
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { define } from "./utils/registration/define";
|
||||
import type { ElementDefinition } from "./utils/registration/definition";
|
||||
import {
|
||||
createScopedRegistry,
|
||||
scopedCreationScope,
|
||||
scopedRegistrySupport,
|
||||
scopedShadowRootInit,
|
||||
} from "./utils/registration/scoped-registry";
|
||||
|
||||
export type { ElementDefinition } from "./utils/registration/definition";
|
||||
|
||||
/**
|
||||
* Creates a registry with the given elements, for `attachScopedShadow`:
|
||||
* definitions from `scoped` entry points, e.g. `[hacCard]`.
|
||||
* Create it once, outside the class, so all instances of a card share it.
|
||||
*
|
||||
* Without scoped registry support (native or polyfill), the elements are
|
||||
* registered in the global registry instead, unless their tag name is already
|
||||
* taken there, and the global registry is returned.
|
||||
*/
|
||||
export const createScopedElementRegistry = (
|
||||
elements: readonly ElementDefinition[]
|
||||
): CustomElementRegistry => {
|
||||
// Checked as unknown: Array.isArray would narrow the list to any[]
|
||||
if (!Array.isArray(elements as unknown)) {
|
||||
throw new TypeError(
|
||||
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
|
||||
);
|
||||
}
|
||||
const registry = createScopedRegistry() ?? customElements;
|
||||
for (const { tagName, element } of elements) {
|
||||
define(tagName, element, registry);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
const shadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
registry === customElements ? init : scopedShadowRootInit(init, registry);
|
||||
|
||||
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
|
||||
|
||||
/**
|
||||
* Attaches a shadow root that uses `registry` for custom elements in it.
|
||||
*
|
||||
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
|
||||
* Create single elements with `createScopedElement`; `document.createElement`
|
||||
* would use the global registry.
|
||||
*
|
||||
* Light DOM content, like slotted elements, keeps using the registry of the
|
||||
* tree it is written in.
|
||||
*/
|
||||
export const attachScopedShadow = (
|
||||
host: HTMLElement,
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRoot => {
|
||||
const root = host.attachShadow(shadowRootInit(init, registry));
|
||||
registries.set(root, registry);
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Creates an element from the registry of a shadow root. */
|
||||
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
|
||||
root: ShadowRoot,
|
||||
tagName: K
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const registry = registries.get(root);
|
||||
if (!registry || registry === customElements) {
|
||||
return document.createElement(tagName);
|
||||
}
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds createElement to shadow roots
|
||||
return (
|
||||
root as unknown as {
|
||||
createElement(tagName: K): HTMLElementTagNameMap[K];
|
||||
}
|
||||
).createElement(tagName);
|
||||
}
|
||||
return (
|
||||
document.createElement as (
|
||||
tagName: K,
|
||||
options: { customElementRegistry: CustomElementRegistry }
|
||||
) => HTMLElementTagNameMap[K]
|
||||
)(tagName, { customElementRegistry: registry });
|
||||
};
|
||||
|
||||
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
|
||||
|
||||
/**
|
||||
* Renders a Lit element into a shadow root with its own custom element
|
||||
* registry, containing `elements`:
|
||||
*
|
||||
* ```ts
|
||||
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
|
||||
* ```
|
||||
*
|
||||
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
|
||||
*/
|
||||
export const ScopedElementsMixin = <T extends LitElementConstructor>(
|
||||
superClass: T,
|
||||
elements: readonly ElementDefinition[]
|
||||
) => {
|
||||
// Created on first use, so defining the class has no side effects
|
||||
let registry: CustomElementRegistry | undefined;
|
||||
const getRegistry = () => {
|
||||
registry ??= createScopedElementRegistry(elements);
|
||||
return registry;
|
||||
};
|
||||
|
||||
abstract class ScopedElementsHost extends superClass {
|
||||
public attachShadow(init: ShadowRootInit): ShadowRoot {
|
||||
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
|
||||
registries.set(root, getRegistry());
|
||||
return root;
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
const scoped = getRegistry();
|
||||
if (scoped !== customElements && root instanceof ShadowRoot) {
|
||||
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
// Typed as the base class: the overrides add no public API
|
||||
return ScopedElementsHost as unknown as T;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Whether the slot of a `slotchange` event has elements assigned. */
|
||||
export const hasAssignedElements = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
|
||||
/**
|
||||
* Whether the slot of a `slotchange` event has content assigned: elements or
|
||||
* text. Whitespace between tags is not content.
|
||||
*/
|
||||
export const hasAssignedContent = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement)
|
||||
.assignedNodes()
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Registers a custom element unless the tag name is already taken in the
|
||||
* registry.
|
||||
*
|
||||
* Home Assistant and custom cards can each ship a copy of this library, so
|
||||
* registering the same tag name twice must not throw. The first registration
|
||||
* wins.
|
||||
*/
|
||||
export const define = (
|
||||
tagName: string,
|
||||
elementClass: CustomElementConstructor,
|
||||
registry: CustomElementRegistry = customElements
|
||||
): void => {
|
||||
if (!registry.get(tagName)) {
|
||||
registry.define(tagName, elementClass);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* An element to register: its tag name and its class. The `scoped` entry
|
||||
* point of an element exports one, e.g. `hacCard`.
|
||||
*/
|
||||
export interface ElementDefinition<
|
||||
E extends CustomElementConstructor = CustomElementConstructor,
|
||||
> {
|
||||
readonly tagName: string;
|
||||
readonly element: E;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Scoped custom element registries come in two flavors: the standard API
|
||||
* (Chrome 146+, Safari 26) and the older API of
|
||||
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
|
||||
* and which replaces the native implementation when present.
|
||||
*/
|
||||
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
|
||||
|
||||
let support: ScopedRegistrySupport | undefined;
|
||||
|
||||
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
|
||||
if (support) {
|
||||
return support;
|
||||
}
|
||||
if ("CustomElementRegistryPolyfill" in globalThis) {
|
||||
support = "polyfill";
|
||||
} else {
|
||||
try {
|
||||
new CustomElementRegistry();
|
||||
support = "native";
|
||||
} catch (_err) {
|
||||
support = "none";
|
||||
}
|
||||
}
|
||||
return support;
|
||||
};
|
||||
|
||||
/** A new scoped registry, or `undefined` if the browser has no support. */
|
||||
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
|
||||
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
|
||||
|
||||
/** Shadow root options that make the shadow root use `registry`. */
|
||||
export const scopedShadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
scopedRegistrySupport() === "polyfill"
|
||||
? ({ ...init, registry } as ShadowRootInit)
|
||||
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
|
||||
|
||||
interface CreationScope {
|
||||
importNode(node: Node, deep?: boolean): Node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object Lit should create template content with, so custom elements in
|
||||
* it are created from the scoped registry (Lit's `creationScope`).
|
||||
*/
|
||||
export const scopedCreationScope = (
|
||||
root: ShadowRoot,
|
||||
registry: CustomElementRegistry
|
||||
): CreationScope => {
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds importNode to shadow roots
|
||||
return root as unknown as CreationScope;
|
||||
}
|
||||
// Standard API: the registry is passed to importNode
|
||||
return {
|
||||
importNode: (node, deep) =>
|
||||
(
|
||||
document.importNode as (
|
||||
node: Node,
|
||||
options: { selfOnly: boolean; customElementRegistry: unknown }
|
||||
) => Node
|
||||
)(node, { selfOnly: !deep, customElementRegistry: registry }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { define } from "../src/utils/registration/define";
|
||||
|
||||
// Home Assistant and custom cards can each ship a copy of the library, so a
|
||||
// second registration of the same tag must be ignored instead of throwing.
|
||||
describe("define", () => {
|
||||
it("registers an element that is not registered yet", () => {
|
||||
class First extends HTMLElement {}
|
||||
define("hac-test-first", First);
|
||||
expect(customElements.get("hac-test-first")).toBe(First);
|
||||
});
|
||||
|
||||
it("keeps the first registration of a tag name", () => {
|
||||
class Original extends HTMLElement {}
|
||||
class Duplicate extends HTMLElement {}
|
||||
define("hac-test-duplicate", Original);
|
||||
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
|
||||
expect(customElements.get("hac-test-duplicate")).toBe(Original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../src/utils/element/slot";
|
||||
|
||||
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
|
||||
// Whitespace between tags must not count as content.
|
||||
const slotEvent = (light: string, name?: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = light;
|
||||
const slot = document.createElement("slot");
|
||||
if (name) {
|
||||
slot.name = name;
|
||||
}
|
||||
host.attachShadow({ mode: "open" }).append(slot);
|
||||
return { target: slot } as unknown as Event;
|
||||
};
|
||||
|
||||
describe("hasAssignedContent", () => {
|
||||
it("ignores whitespace", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts text", () => {
|
||||
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
|
||||
});
|
||||
|
||||
it("counts elements", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAssignedElements", () => {
|
||||
it("only counts elements assigned to the slot", () => {
|
||||
const light = '<span slot="icon"></span> text';
|
||||
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
|
||||
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Generated
+765
-106
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
@@ -19,6 +23,7 @@ trustPolicyExclude:
|
||||
allowBuilds:
|
||||
"@tsparticles/engine": false
|
||||
core-js: false
|
||||
rs-module-lexer: false
|
||||
unrs-resolver: false
|
||||
|
||||
overrides:
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
@@ -124,16 +125,16 @@ export interface DataTableRowData {
|
||||
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
// Slot for the actions at the end of a group's header, they don't toggle the
|
||||
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
|
||||
// Renders the actions at the end of a group's header, they don't toggle the
|
||||
// group. Return `ha-icon-button`s with a label. They get the rows' icon
|
||||
// colour and size by default, which `--data-table-group-actions-color`,
|
||||
// `--data-table-group-actions-button-size` and
|
||||
// `--data-table-group-actions-icon-size` change for all of them; style a
|
||||
// button itself for its own colour or size.
|
||||
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
|
||||
|
||||
export const groupActionsSlot = (group: string) =>
|
||||
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
|
||||
// button itself for its own colour or size. Not a slot per group, as WebKit
|
||||
// is slow to add and remove slots while the rows scroll.
|
||||
export type DataTableGroupActions = (
|
||||
group: string
|
||||
) => TemplateResult | typeof nothing;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
|
||||
@@ -197,6 +198,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public sortColumn?: string;
|
||||
|
||||
@property({ attribute: false }) public sortDirection: SortingDirection = null;
|
||||
@@ -506,7 +510,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -759,8 +764,6 @@ export class HaDataTable extends LitElement {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
// Rendered before the rows, so the group's actions slot
|
||||
// shows here and not in its scrolled out header row
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
@@ -775,11 +778,15 @@ export class HaDataTable extends LitElement {
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
${this._renderGroupHeader(
|
||||
${keyed(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
true
|
||||
this._renderGroupHeader(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
this.renderGroupActions,
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -803,7 +810,14 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row;
|
||||
// Group headers are keyed by their group, so they keep their actions' state
|
||||
// when the data changes. The prefixes keep them apart from row ids.
|
||||
private _keyFunction = (row: DataTableRowData) =>
|
||||
row?.[this.id]
|
||||
? `row-${row[this.id]}`
|
||||
: row?.groupHeader !== undefined
|
||||
? `group-${row.groupHeader}`
|
||||
: row;
|
||||
|
||||
private _renderMainValue(value: unknown) {
|
||||
return this.clickable
|
||||
@@ -1063,7 +1077,8 @@ export class HaDataTable extends LitElement {
|
||||
groupOrder: string[] | undefined,
|
||||
collapsedGroups: string[],
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection
|
||||
sortDirection: SortingDirection,
|
||||
renderGroupActions: DataTableGroupActions | undefined
|
||||
) => {
|
||||
if (appendRow || groupColumn) {
|
||||
let items = [...data];
|
||||
@@ -1122,7 +1137,8 @@ export class HaDataTable extends LitElement {
|
||||
content: this._renderGroupHeader(
|
||||
groupName,
|
||||
collapsedGroups.includes(groupName),
|
||||
localize
|
||||
localize,
|
||||
renderGroupActions
|
||||
),
|
||||
});
|
||||
if (!collapsedGroups.includes(groupName)) {
|
||||
@@ -1148,6 +1164,7 @@ export class HaDataTable extends LitElement {
|
||||
groupName: string,
|
||||
collapsed: boolean,
|
||||
localize: LocalizeFunc | undefined,
|
||||
renderGroupActions: DataTableGroupActions | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy's button and name are hidden from assistive technology,
|
||||
@@ -1183,7 +1200,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
</span>
|
||||
<div class="group-actions" @click=${this._stopGroupToggle}>
|
||||
<slot name=${groupActionsSlot(groupName)}></slot>
|
||||
${renderGroupActions?.(groupName)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1264,7 +1281,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1961,7 +1979,7 @@ export class HaDataTable extends LitElement {
|
||||
align-self: stretch;
|
||||
margin-top: -12px;
|
||||
margin-inline-start: auto;
|
||||
/* The rows' icon button size, inherited by the slotted ones */
|
||||
/* The rows' icon button size, inherited by the actions */
|
||||
--_button-size: var(--data-table-group-actions-button-size, 48px);
|
||||
--ha-icon-button-size: var(--_button-size);
|
||||
--mdc-icon-size: var(--data-table-group-actions-icon-size, 24px);
|
||||
@@ -1974,9 +1992,9 @@ export class HaDataTable extends LitElement {
|
||||
position: sticky;
|
||||
inset-inline-end: 0;
|
||||
cursor: default;
|
||||
/* Don't pass the group name's weight on to slotted menus */
|
||||
/* Don't pass the group name's weight on to the actions */
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
/* Inherited by the slotted icon buttons, like the rows' icon cells */
|
||||
/* Inherited by the actions, like the rows' icon cells */
|
||||
color: var(
|
||||
--data-table-group-actions-color,
|
||||
var(--secondary-text-color)
|
||||
|
||||
@@ -219,6 +219,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (this._searchFieldElement) {
|
||||
this._searchFieldElement.value = value;
|
||||
}
|
||||
this._applySearch(value);
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
@@ -691,7 +692,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _filterChanged = (ev: InputEvent) => {
|
||||
const textfield = ev.target as HaInputSearch;
|
||||
const searchString = (textfield.value ?? "").trim();
|
||||
this._applySearch(textfield.value ?? "");
|
||||
};
|
||||
|
||||
private _applySearch(value: string) {
|
||||
const searchString = value.trim();
|
||||
this._search = searchString;
|
||||
|
||||
if (this.sections?.length) {
|
||||
@@ -736,7 +741,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
this._resetSelectedItem();
|
||||
this._resetListScroll();
|
||||
};
|
||||
}
|
||||
|
||||
// Keep focus in the search field, so a focus change doesn't scroll the
|
||||
// list to the active row and make the click miss.
|
||||
|
||||
@@ -27,9 +27,10 @@ import {
|
||||
dataTableQueryStateFromUrl,
|
||||
} from "../common/url/data-table-query-params";
|
||||
import "../components/chips/ha-assist-chip";
|
||||
import { GROUP_ACTIONS_SLOT_PREFIX } from "../components/data-table/ha-data-table";
|
||||
import "../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableGroupActions,
|
||||
DataTableRowData,
|
||||
HaDataTable,
|
||||
SortingDirection,
|
||||
@@ -216,6 +217,9 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public columnOrder?: string[];
|
||||
|
||||
@property({ attribute: false }) public hiddenColumns?: string[];
|
||||
@@ -627,13 +631,11 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
.sortDirection=${this._sortDirection}
|
||||
.groupColumn=${this._groupColumn}
|
||||
.groupOrder=${this.groupOrder}
|
||||
.renderGroupActions=${this.renderGroupActions}
|
||||
.initialCollapsedGroups=${this.initialCollapsedGroups}
|
||||
.columnOrder=${this.columnOrder}
|
||||
.hiddenColumns=${this.hiddenColumns}
|
||||
>
|
||||
${this._groupActionsSlots.map(
|
||||
(slot) => html`<slot name=${slot} slot=${slot}></slot>`
|
||||
)}
|
||||
${
|
||||
!this.narrow
|
||||
? html`
|
||||
@@ -849,46 +851,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
this._setGroupColumn(group);
|
||||
}
|
||||
|
||||
// Slots of the group actions the page supplies, passed through to the table
|
||||
@state() private _groupActionsSlots: string[] = [];
|
||||
|
||||
private _groupActionsObserver = new MutationObserver(() =>
|
||||
this._updateGroupActionsSlots()
|
||||
);
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._updateGroupActionsSlots();
|
||||
// Subtree, so slot changes on the page's children are seen too
|
||||
this._groupActionsObserver.observe(this, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ["slot"],
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this._groupActionsObserver.disconnect();
|
||||
}
|
||||
|
||||
private _updateGroupActionsSlots() {
|
||||
const slots = [
|
||||
...new Set(
|
||||
Array.from(this.children, (child) => child.slot).filter((slot) =>
|
||||
slot.startsWith(GROUP_ACTIONS_SLOT_PREFIX)
|
||||
)
|
||||
),
|
||||
];
|
||||
|
||||
if (
|
||||
slots.length !== this._groupActionsSlots.length ||
|
||||
slots.some((slot, index) => slot !== this._groupActionsSlots[index])
|
||||
) {
|
||||
this._groupActionsSlots = slots;
|
||||
}
|
||||
}
|
||||
|
||||
private _setGroupColumn(columnId: string, link = false) {
|
||||
this._groupColumn = columnId;
|
||||
fireEvent(this, "grouping-changed", { value: columnId, link });
|
||||
|
||||
@@ -122,7 +122,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { turnOnOffEntity } from "../../lovelace/common/entity/turn-on-off-entity";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -537,6 +537,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
.columns=${this._columns(this.narrow, this.hass.localize, automations)}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"automation",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -565,12 +571,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiHelpCircleOutline}
|
||||
@click=${this._showHelp}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"automation",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-filter-floor-areas
|
||||
.type=${"automation"}
|
||||
.value=${this._filters["ha-filter-floor-areas"]?.value}
|
||||
@@ -941,6 +941,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
+14
-16
@@ -1,6 +1,5 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
|
||||
import type { DataTableGroupActions } from "../../../components/data-table/ha-data-table";
|
||||
import type { CategoryRegistryEntry } from "../../../data/category_registry";
|
||||
import "./ha-category-group-actions";
|
||||
|
||||
@@ -8,22 +7,21 @@ import "./ha-category-group-actions";
|
||||
// group headers of a table grouped by category, which names its groups after
|
||||
// the categories. Other groupings can have groups with the same names.
|
||||
// onDeleted lets the page take a deleted category out of its filter.
|
||||
export const renderCategoryGroupActions = (
|
||||
export const categoryGroupActions = (
|
||||
scope: string,
|
||||
categories: CategoryRegistryEntry[] | undefined,
|
||||
grouping: string | undefined,
|
||||
onDeleted: (categoryId: string) => void
|
||||
) =>
|
||||
): DataTableGroupActions | undefined =>
|
||||
grouping === "category"
|
||||
? repeat(
|
||||
categories ?? [],
|
||||
(category) => category.category_id,
|
||||
(category) =>
|
||||
html`<ha-category-group-actions
|
||||
slot=${groupActionsSlot(category.name)}
|
||||
.category=${category}
|
||||
.scope=${scope}
|
||||
.onDeleted=${onDeleted}
|
||||
></ha-category-group-actions>`
|
||||
)
|
||||
: nothing;
|
||||
? (group) => {
|
||||
const category = categories?.find((cat) => cat.name === group);
|
||||
return category
|
||||
? html`<ha-category-group-actions
|
||||
.category=${category}
|
||||
.scope=${scope}
|
||||
.onDeleted=${onDeleted}
|
||||
></ha-category-group-actions>`
|
||||
: nothing;
|
||||
}
|
||||
: undefined;
|
||||
@@ -38,3 +38,54 @@ export type Helper =
|
||||
type HelperWithoutId<T> = T extends { id: string } ? Omit<T, "id"> : never;
|
||||
|
||||
export type HelperFormItem = HelperWithoutId<Helper>;
|
||||
|
||||
export const HELPER_CATEGORIES = [
|
||||
"input",
|
||||
"time",
|
||||
"calculation",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type HelperCategory = (typeof HELPER_CATEGORIES)[number];
|
||||
|
||||
// Helper integrations have no category or short description in their
|
||||
// manifest, so the ones shipped with Home Assistant are listed here. Any other
|
||||
// helper falls into "other" and has no description.
|
||||
const HELPER_DOMAIN_CATEGORIES = {
|
||||
input_boolean: "input",
|
||||
input_button: "input",
|
||||
input_datetime: "input",
|
||||
input_number: "input",
|
||||
input_select: "input",
|
||||
input_text: "input",
|
||||
counter: "input",
|
||||
schedule: "time",
|
||||
timer: "time",
|
||||
tod: "time",
|
||||
history_stats: "time",
|
||||
derivative: "calculation",
|
||||
filter: "calculation",
|
||||
integration: "calculation",
|
||||
min_max: "calculation",
|
||||
mold_indicator: "calculation",
|
||||
random: "calculation",
|
||||
statistics: "calculation",
|
||||
threshold: "calculation",
|
||||
trend: "calculation",
|
||||
template: "calculation",
|
||||
utility_meter: "calculation",
|
||||
generic_hygrostat: "other",
|
||||
generic_thermostat: "other",
|
||||
group: "other",
|
||||
otp: "other",
|
||||
switch_as_x: "other",
|
||||
} as const satisfies Record<string, HelperCategory>;
|
||||
|
||||
export type KnownHelperDomain = keyof typeof HELPER_DOMAIN_CATEGORIES;
|
||||
|
||||
export const isKnownHelperDomain = (
|
||||
domain: string
|
||||
): domain is KnownHelperDomain => domain in HELPER_DOMAIN_CATEGORIES;
|
||||
|
||||
export const getHelperCategory = (domain: string): HelperCategory =>
|
||||
isKnownHelperDomain(domain) ? HELPER_DOMAIN_CATEGORIES[domain] : "other";
|
||||
@@ -1,32 +1,33 @@
|
||||
import { mdiAlertOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { dynamicElement } from "../../../common/dom/dynamic-element-directive";
|
||||
import type {
|
||||
HASSDomCurrentTargetEvent,
|
||||
HASSDomEvent,
|
||||
HASSDomTargetEvent,
|
||||
} from "../../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-area-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-combo-box-item";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-dialog-footer";
|
||||
import "../../../components/ha-domain-icon";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-picker-combo-box";
|
||||
import type {
|
||||
HaPickerComboBox,
|
||||
PickerComboBoxIndexSelectedDetail,
|
||||
PickerComboBoxItem,
|
||||
} from "../../../components/ha-picker-combo-box";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../components/item/ha-list-item-option";
|
||||
import "../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "../../../components/list/ha-list-selectable";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import { getConfigFlowHandlers } from "../../../data/config_flow";
|
||||
import { createCounter } from "../../../data/counter";
|
||||
import {
|
||||
@@ -51,11 +52,25 @@ import { createTimer } from "../../../data/timer";
|
||||
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
|
||||
import { DirtyStateProviderMixin } from "../../../mixins/dirty-state-provider-mixin";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../../../resources/fuseMultiTerm";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { Helper, HelperDomain, HelperFormItem } from "./const";
|
||||
import { isHelperDomain } from "./const";
|
||||
import type {
|
||||
Helper,
|
||||
HelperCategory,
|
||||
HelperDomain,
|
||||
HelperFormItem,
|
||||
} from "./const";
|
||||
import {
|
||||
getHelperCategory,
|
||||
HELPER_CATEGORIES,
|
||||
isHelperDomain,
|
||||
isKnownHelperDomain,
|
||||
} from "./const";
|
||||
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
|
||||
|
||||
type HelperCreators = Record<
|
||||
@@ -116,6 +131,18 @@ const HELPERS: HelperCreators = {
|
||||
},
|
||||
};
|
||||
|
||||
interface HelperPickerItem extends PickerComboBoxItem {
|
||||
category: HelperCategory;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const HELPER_SEARCH_KEYS: FuseWeightedKey[] = [
|
||||
{ name: "primary", weight: 10 },
|
||||
{ name: "search_labels.aliases", weight: 8 },
|
||||
{ name: "id", weight: 4 },
|
||||
{ name: "secondary", weight: 2 },
|
||||
];
|
||||
|
||||
@customElement("dialog-helper-detail")
|
||||
export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
{ item?: HelperFormItem; area?: string } | undefined
|
||||
@@ -128,6 +155,8 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _open = false;
|
||||
|
||||
@state() private _shown = false;
|
||||
|
||||
@state() private _domain?: string;
|
||||
|
||||
@state() private _error?: string;
|
||||
@@ -138,10 +167,11 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _loading = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
private _category?: HelperCategory;
|
||||
|
||||
@query("ha-input-search")
|
||||
private _searchInput?: HTMLElement & { updateComplete?: Promise<unknown> };
|
||||
private _search?: string;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
private _pendingConfigFlow?: {
|
||||
startFlowHandler: string;
|
||||
@@ -157,6 +187,8 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._domain = params.domain;
|
||||
this._item = params.item;
|
||||
this._area = undefined;
|
||||
this._category = undefined;
|
||||
this._search = undefined;
|
||||
if (this._domain && this._domain in HELPERS) {
|
||||
this._loading = true;
|
||||
try {
|
||||
@@ -178,19 +210,41 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._helperFlows = flows;
|
||||
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
this._comboBox?.refreshItems();
|
||||
this._focusPicker();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
super.updated(changedProps);
|
||||
const oldHass = changedProps.get("hass");
|
||||
if (
|
||||
oldHass &&
|
||||
(oldHass.localize !== this.hass.localize ||
|
||||
oldHass.config !== this.hass.config)
|
||||
) {
|
||||
this._comboBox?.refreshItems();
|
||||
}
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogShown(ev: Event): void {
|
||||
if (ev.eventPhase !== Event.AT_TARGET) {
|
||||
return;
|
||||
}
|
||||
this._shown = true;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._open = false;
|
||||
this._shown = false;
|
||||
this._error = undefined;
|
||||
this._domain = undefined;
|
||||
this._params = undefined;
|
||||
this._filter = undefined;
|
||||
this._category = undefined;
|
||||
this._search = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
|
||||
if (this._pendingConfigFlow) {
|
||||
@@ -211,6 +265,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
let content: TemplateResult;
|
||||
let footer: TemplateResult | typeof nothing = nothing;
|
||||
let showPicker = false;
|
||||
|
||||
if (this._domain && !this._loading) {
|
||||
content = html`
|
||||
@@ -255,78 +310,34 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
} else if (this._loading || this._helperFlows === undefined) {
|
||||
content = html`<ha-spinner></ha-spinner>`;
|
||||
} else {
|
||||
const items = this._filterHelpers(
|
||||
HELPERS,
|
||||
this._helperFlows,
|
||||
this._filter
|
||||
);
|
||||
|
||||
showPicker = true;
|
||||
content = html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
<ha-picker-combo-box
|
||||
autofocus
|
||||
.value=${this._filter}
|
||||
@input=${this._filterChanged}
|
||||
.placeholder=${this.hass.localize(
|
||||
mode="dialog"
|
||||
.shown=${this._shown}
|
||||
no-sort
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search_helper"
|
||||
)}
|
||||
></ha-input-search>
|
||||
<ha-list-selectable
|
||||
controlled
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.helpers.dialog.create_helper"
|
||||
.getItems=${this._getItems}
|
||||
.rowRenderer=${this._renderHelperRow}
|
||||
.sections=${this._getSections(
|
||||
this._helperFlows,
|
||||
this.hass.localize,
|
||||
this.hass.config,
|
||||
this.hass.locale.language
|
||||
)}
|
||||
@ha-list-item-selected=${this._domainPicked}
|
||||
>
|
||||
${items.map(([domain, label]) => {
|
||||
// Only OG helpers need to be loaded prior adding one
|
||||
const isLoaded =
|
||||
!(domain in HELPERS) ||
|
||||
isComponentLoaded(this.hass.config, domain);
|
||||
|
||||
return html`
|
||||
<ha-list-item-option appearance="line" .value=${domain}>
|
||||
<img
|
||||
slot="start"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<span slot="headline">${label}</span>
|
||||
${
|
||||
isLoaded
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: html`<ha-svg-icon
|
||||
slot="end"
|
||||
.id="icon-${domain}"
|
||||
path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip slot="end" .for="icon-${domain}">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
{ platform: domain }
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-list-item-option>
|
||||
`;
|
||||
})}
|
||||
</ha-list-selectable>
|
||||
.selectedSection=${this._category}
|
||||
@index-selected=${this._helperPicked}
|
||||
></ha-picker-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
class=${showPicker ? "picker" : ""}
|
||||
.flexContent=${showPicker}
|
||||
.open=${this._open}
|
||||
.preventScrimClose=${this.isDirtyState}
|
||||
header-title=${
|
||||
@@ -346,6 +357,7 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
)
|
||||
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
|
||||
}
|
||||
@after-show=${this._dialogShown}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${content} ${footer}
|
||||
@@ -353,51 +365,148 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
`;
|
||||
}
|
||||
|
||||
private _filterHelpers = memoizeOne(
|
||||
private _getHelperItems = memoizeOne(
|
||||
(
|
||||
predefinedHelpers: HelperCreators,
|
||||
flowHelpers?: string[],
|
||||
filter?: string
|
||||
) => {
|
||||
const items: [string, string][] = [];
|
||||
|
||||
for (const helper of Object.keys(
|
||||
predefinedHelpers
|
||||
) as (keyof typeof predefinedHelpers)[]) {
|
||||
items.push([
|
||||
helper,
|
||||
this.hass.localize(`ui.panel.config.helpers.types.${helper}`) ||
|
||||
helper,
|
||||
]);
|
||||
}
|
||||
|
||||
if (flowHelpers) {
|
||||
for (const domain of flowHelpers) {
|
||||
items.push([domain, domainToName(this.hass.localize, domain)]);
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.filter(([domain, label]) => {
|
||||
if (filter) {
|
||||
const lowerFilter = filter.toLowerCase();
|
||||
return (
|
||||
label.toLowerCase().includes(lowerFilter) ||
|
||||
domain.toLowerCase().includes(lowerFilter) ||
|
||||
(predefinedHelpers[domain as HelperDomain]?.alias || []).some(
|
||||
(alias) => alias.toLowerCase().includes(lowerFilter)
|
||||
)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
flowHelpers: string[],
|
||||
localize: LocalizeFunc,
|
||||
config: HassConfig,
|
||||
language: string
|
||||
): HelperPickerItem[] => {
|
||||
const domains = [
|
||||
...(Object.keys(HELPERS) as HelperDomain[]),
|
||||
...flowHelpers,
|
||||
];
|
||||
return domains
|
||||
.map((domain) => {
|
||||
const helperDomain = isHelperDomain(domain) ? domain : undefined;
|
||||
return {
|
||||
id: domain,
|
||||
primary:
|
||||
(helperDomain &&
|
||||
localize(`ui.panel.config.helpers.types.${helperDomain}`)) ||
|
||||
domainToName(localize, domain),
|
||||
secondary: isKnownHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.dialog.descriptions.${domain}`
|
||||
)
|
||||
: undefined,
|
||||
category: getHelperCategory(domain),
|
||||
// Only OG helpers need to be loaded prior adding one
|
||||
loaded: !helperDomain || isComponentLoaded(config, domain),
|
||||
search_labels: {
|
||||
aliases: helperDomain
|
||||
? (HELPERS[helperDomain].alias?.join(" ") ?? null)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort((a, b) => stringCompare(a[1], b[1], this.hass.locale.language));
|
||||
.sort((a, b) => stringCompare(a.primary, b.primary, language));
|
||||
}
|
||||
);
|
||||
|
||||
private async _filterChanged(e: HASSDomTargetEvent<HaInputSearch>) {
|
||||
this._filter = e.target.value;
|
||||
}
|
||||
// Only offer the categories that have helpers.
|
||||
private _getSections = memoizeOne(
|
||||
(
|
||||
flowHelpers: string[],
|
||||
localize: LocalizeFunc,
|
||||
config: HassConfig,
|
||||
language: string
|
||||
) => {
|
||||
const items = this._getHelperItems(
|
||||
flowHelpers,
|
||||
localize,
|
||||
config,
|
||||
language
|
||||
);
|
||||
return HELPER_CATEGORIES.filter((category) =>
|
||||
items.some((item) => item.category === category)
|
||||
).map((category) => ({
|
||||
id: category,
|
||||
label: localize(
|
||||
`ui.panel.config.helpers.dialog.categories.${category}`
|
||||
),
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _fuseIndex = memoizeOne((items: HelperPickerItem[]) =>
|
||||
Fuse.createIndex(HELPER_SEARCH_KEYS, items)
|
||||
);
|
||||
|
||||
private _getItems = (
|
||||
search?: string,
|
||||
section?: string
|
||||
): (HelperPickerItem | string)[] => {
|
||||
this._category = section as HelperCategory | undefined;
|
||||
this._search = search;
|
||||
const allItems = this._getHelperItems(
|
||||
this._helperFlows ?? [],
|
||||
this.hass.localize,
|
||||
this.hass.config,
|
||||
this.hass.locale.language
|
||||
);
|
||||
const items = search
|
||||
? multiTermSortedSearch(
|
||||
allItems,
|
||||
search,
|
||||
(item) => item.id,
|
||||
this._fuseIndex(allItems)
|
||||
)
|
||||
: allItems;
|
||||
|
||||
if (this._category) {
|
||||
return items.filter((item) => item.category === this._category);
|
||||
}
|
||||
|
||||
// Group by category, keeping the search order within each one.
|
||||
return HELPER_CATEGORIES.flatMap((category) => {
|
||||
const categoryItems = items.filter((item) => item.category === category);
|
||||
return categoryItems.length
|
||||
? [
|
||||
this.hass.localize(
|
||||
`ui.panel.config.helpers.dialog.categories.${category}`
|
||||
),
|
||||
...categoryItems,
|
||||
]
|
||||
: [];
|
||||
});
|
||||
};
|
||||
|
||||
private _renderHelperRow = (item: PickerComboBoxItem) => {
|
||||
const { id: domain, primary, secondary, loaded } = item as HelperPickerItem;
|
||||
return html`
|
||||
<ha-combo-box-item>
|
||||
<ha-domain-icon
|
||||
slot="start"
|
||||
style="margin: var(--ha-space-1);"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
<span slot="headline">${primary}</span>
|
||||
${
|
||||
secondary
|
||||
? html`<span slot="supporting-text">${secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
loaded
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: html`<ha-svg-icon
|
||||
slot="end"
|
||||
.id="icon-${domain}"
|
||||
.path=${mdiAlertOutline}
|
||||
@click=${stopPropagation}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip slot="end" .for="icon-${domain}">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
{ platform: domain }
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
this._item = ev.detail.value;
|
||||
@@ -498,18 +607,12 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
this._updateDirtyState({ item: this._item, area: this._area });
|
||||
}
|
||||
|
||||
private async _domainPicked(
|
||||
ev: HASSDomEvent<number> & HASSDomCurrentTargetEvent<HaListSelectable>
|
||||
private async _helperPicked(
|
||||
ev: HASSDomEvent<PickerComboBoxIndexSelectedDetail>
|
||||
): Promise<void> {
|
||||
const domain = (
|
||||
ev.currentTarget.items[ev.detail] as HaListItemOption | undefined
|
||||
)?.value;
|
||||
if (!domain) {
|
||||
return;
|
||||
}
|
||||
const isLoaded =
|
||||
!(domain in HELPERS) || isComponentLoaded(this.hass.config, domain);
|
||||
if (!isLoaded) {
|
||||
ev.stopPropagation();
|
||||
const { id: domain, loaded } = ev.detail.item as HelperPickerItem;
|
||||
if (!loaded) {
|
||||
showAlertDialog(this, {
|
||||
text: this.hass.localize(
|
||||
"ui.dialogs.helper_settings.platform_not_loaded",
|
||||
@@ -543,43 +646,35 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private async _goBack() {
|
||||
const search = this._search;
|
||||
this._domain = undefined;
|
||||
this._item = undefined;
|
||||
this._area = undefined;
|
||||
this._error = undefined;
|
||||
await this.updateComplete;
|
||||
await this._focusSearchInput();
|
||||
await this._comboBox?.updateComplete;
|
||||
if (search) {
|
||||
this._comboBox?.setFieldValue(search);
|
||||
}
|
||||
this._focusPicker();
|
||||
}
|
||||
|
||||
private async _focusSearchInput() {
|
||||
const searchInput = this._searchInput;
|
||||
|
||||
if (!searchInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
await searchInput.updateComplete;
|
||||
searchInput.focus();
|
||||
private async _focusPicker() {
|
||||
await this._comboBox?.updateComplete;
|
||||
this._comboBox?.focus();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
ha-icon-next {
|
||||
width: var(--ha-space-6);
|
||||
}
|
||||
ha-list-item-option img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
object-fit: contain;
|
||||
}
|
||||
ha-tooltip {
|
||||
pointer-events: auto;
|
||||
@media all and (min-width: 451px) and (min-height: 501px) {
|
||||
ha-dialog.picker {
|
||||
--ha-dialog-min-height: min(620px, 90vh);
|
||||
}
|
||||
}
|
||||
.form {
|
||||
padding: var(--ha-space-6);
|
||||
@@ -588,23 +683,6 @@ export class DialogHelperDetail extends DirtyStateProviderMixin<
|
||||
display: block;
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
ha-input-search {
|
||||
margin: 0 var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
height: calc(60vh - 184px);
|
||||
}
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
ha-list-selectable::part(base) {
|
||||
height: calc(
|
||||
100vh -
|
||||
184px - var(--safe-area-inset-top, 0px) - var(
|
||||
--safe-area-inset-bottom,
|
||||
0px
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -718,6 +718,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.columns=${this._columns(this.hass.localize, helpers)}
|
||||
.data=${helpers}
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"helpers",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -880,12 +886,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
.hass=${this.hass}
|
||||
slot="toolbar-icon"
|
||||
></ha-integration-overflow-menu>
|
||||
${renderCategoryGroupActions(
|
||||
"helpers",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-button slot="fab" size="l" @click=${this._createHelper}>
|
||||
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.helpers.picker.create_helper")}
|
||||
@@ -931,6 +931,8 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
this._applyFilters();
|
||||
}
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"];
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -535,6 +535,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
.columns=${this._columns(this.hass.localize, scenes)}
|
||||
id="entity_id"
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"scene",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -563,12 +569,6 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
.label=${this.hass.localize("ui.common.help")}
|
||||
.path=${mdiHelpCircleOutline}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"scene",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
|
||||
<ha-filter-floor-areas
|
||||
.type=${"scene"}
|
||||
@@ -795,6 +795,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ import { showToast } from "../../../util/toast";
|
||||
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
|
||||
import { showNewAutomationDialog } from "../automation/show-dialog-new-automation";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { renderCategoryGroupActions } from "../category/render-category-group-actions";
|
||||
import { categoryGroupActions } from "../category/category-group-actions";
|
||||
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
|
||||
import {
|
||||
getAreaTableColumn,
|
||||
@@ -453,6 +453,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
)}
|
||||
has-filters
|
||||
.initialGroupColumn=${this._tableSettings.grouping}
|
||||
.renderGroupActions=${this._groupActions(
|
||||
"script",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
.initialCollapsedGroups=${this._activeCollapsed}
|
||||
.initialSorting=${this._tableSettings.sorting}
|
||||
.columnOrder=${this._activeColumnOrder}
|
||||
@@ -497,12 +503,6 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
.path=${mdiHelpCircleOutline}
|
||||
@click=${this._showHelp}
|
||||
></ha-icon-button>
|
||||
${renderCategoryGroupActions(
|
||||
"script",
|
||||
this._categories,
|
||||
this._tableSettings.grouping,
|
||||
this._categoryDeleted
|
||||
)}
|
||||
<ha-filter-floor-areas
|
||||
.type=${"script"}
|
||||
.value=${this._filters["ha-filter-floor-areas"]?.value}
|
||||
@@ -739,6 +739,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
};
|
||||
|
||||
private _groupActions = memoizeOne(categoryGroupActions);
|
||||
|
||||
private _categoryDeleted = (categoryId: string) => {
|
||||
const value = this._filters["ha-filter-categories"]?.value;
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ import {
|
||||
sortedMarketplaceFilterQueryString,
|
||||
} from "../../../common/url/marketplace-query-params";
|
||||
import { shallowEqual } from "../../../common/util/shallow-equal";
|
||||
import { groupActionsSlot } from "../../../components/data-table/ha-data-table";
|
||||
import type {
|
||||
DataTableColumnContainer,
|
||||
DataTableGroupActions,
|
||||
SelectionChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
@@ -400,6 +400,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
this._tableSettings.grouping,
|
||||
this._i18n.localize
|
||||
)}
|
||||
.renderGroupActions=${this._renderGroupActions}
|
||||
.columnOrder=${this._orderTableColumns}
|
||||
.hiddenColumns=${this._hiddenTableColumns}
|
||||
@columns-changed=${this._handleColumnsChanged}
|
||||
@@ -414,14 +415,6 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
@sorting-changed=${this._tableSettings.sortingChanged}
|
||||
>
|
||||
${this._renderToolbar()}
|
||||
<ha-icon-button
|
||||
slot=${groupActionsSlot(
|
||||
this._i18n.localize("ui.panel.marketplace.repository_status.new")
|
||||
)}
|
||||
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
|
||||
.path=${mdiCheckAll}
|
||||
@click=${this._dismissNew}
|
||||
></ha-icon-button>
|
||||
${
|
||||
this.tab === "installed"
|
||||
? html`<ha-assist-chip
|
||||
@@ -984,7 +977,16 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
showMarketplaceAddFromLink(this, this._i18n.localize, this.marketplace);
|
||||
}
|
||||
|
||||
private _dismissNew() {
|
||||
private _renderGroupActions: DataTableGroupActions = (group) =>
|
||||
group === this._i18n.localize("ui.panel.marketplace.repository_status.new")
|
||||
? html`<ha-icon-button
|
||||
.label=${this._i18n.localize("ui.panel.marketplace.menu.dismiss")}
|
||||
.path=${mdiCheckAll}
|
||||
@click=${this._dismissNew}
|
||||
></ha-icon-button>`
|
||||
: nothing;
|
||||
|
||||
private _dismissNew = () => {
|
||||
showConfirmationDialog(this, {
|
||||
title: this._i18n.localize(
|
||||
"ui.panel.marketplace.dialog.dismiss_new.title"
|
||||
@@ -1021,7 +1023,7 @@ export class HaMarketplaceDashboard extends LitElement {
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private _statusStates = memoize(
|
||||
(localize: LocalizeFunc, tab: MarketplaceTab) =>
|
||||
|
||||
@@ -211,6 +211,10 @@ export const getMyRedirects = (): Redirects => ({
|
||||
tools_states: {
|
||||
redirect: "/config/tools/state",
|
||||
},
|
||||
tools_action_history: {
|
||||
component: "logbook",
|
||||
redirect: "/config/tools/action-history",
|
||||
},
|
||||
tools_actions: {
|
||||
redirect: "/config/tools/action",
|
||||
},
|
||||
|
||||
@@ -2,12 +2,12 @@ import { mdiDelete } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "@home-assistant/components/card";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { relativeTime } from "../../common/datetime/relative_time";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-card";
|
||||
import "../../components/ha-icon-button";
|
||||
import type { HaIconButton } from "../../components/ha-icon-button";
|
||||
import "../../components/ha-settings-row";
|
||||
@@ -37,83 +37,79 @@ class HaLongLivedTokens extends LitElement {
|
||||
const accessTokens = this._accessTokens(this.refreshTokens);
|
||||
|
||||
return html`
|
||||
<ha-card
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.header"
|
||||
)}
|
||||
>
|
||||
<div class="card-content">
|
||||
<hac-card>
|
||||
<h2 slot="header" class="header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.description"
|
||||
"ui.panel.profile.long_lived_access_tokens.header"
|
||||
)}
|
||||
</h2>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.description"
|
||||
)}
|
||||
|
||||
<a
|
||||
href="https://contributors.home-assistant.io/core/internals/auth-api/#make-authenticated-requests"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
|
||||
)}
|
||||
</a>
|
||||
${
|
||||
!accessTokens.length
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.empty_state"
|
||||
)}
|
||||
</p>`
|
||||
: accessTokens.map(
|
||||
(token) =>
|
||||
html`<ha-settings-row two-line>
|
||||
<span slot="heading">${token.client_name}</span>
|
||||
<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.created",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.created_at),
|
||||
this.hass.locale
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
this._hasBeenUsed(token)
|
||||
? html`<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.last_used",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.last_used_at),
|
||||
this.hass.locale
|
||||
),
|
||||
location: token.last_used_ip,
|
||||
}
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button
|
||||
.token=${token}
|
||||
.disabled=${token.is_current}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._deleteToken}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>`
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<ha-button @click=${this._createToken}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
<a
|
||||
href="https://contributors.home-assistant.io/core/internals/auth-api/#make-authenticated-requests"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.learn_auth_requests"
|
||||
)}
|
||||
</a>
|
||||
${
|
||||
!accessTokens.length
|
||||
? html`<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.empty_state"
|
||||
)}
|
||||
</p>`
|
||||
: accessTokens.map(
|
||||
(token) =>
|
||||
html`<ha-settings-row two-line>
|
||||
<span slot="heading">${token.client_name}</span>
|
||||
<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.created",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.created_at),
|
||||
this.hass.locale
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
${
|
||||
this._hasBeenUsed(token)
|
||||
? html`<div slot="description">
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.last_used",
|
||||
{
|
||||
date: relativeTime(
|
||||
new Date(token.last_used_at),
|
||||
this.hass.locale
|
||||
),
|
||||
location: token.last_used_ip,
|
||||
}
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-icon-button
|
||||
.token=${token}
|
||||
.disabled=${token.is_current}
|
||||
.label=${this.hass.localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
@click=${this._deleteToken}
|
||||
></ha-icon-button>
|
||||
</ha-settings-row>`
|
||||
)
|
||||
}
|
||||
<ha-button slot="footer" @click=${this._createToken}>
|
||||
${this.hass.localize(
|
||||
"ui.panel.profile.long_lived_access_tokens.create"
|
||||
)}
|
||||
</ha-button>
|
||||
</hac-card>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -174,6 +170,9 @@ class HaLongLivedTokens extends LitElement {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
hac-card::part(footer) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -183,9 +182,18 @@ class HaLongLivedTokens extends LitElement {
|
||||
ha-icon-button {
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
.header {
|
||||
margin: 0;
|
||||
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-4);
|
||||
color: var(--ha-card-header-color, var(--primary-text-color));
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: -0.012em;
|
||||
/* haStyle truncates h2 to one line, long titles should wrap */
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -4961,7 +4961,42 @@
|
||||
"dialog": {
|
||||
"create": "Create",
|
||||
"create_helper": "Create helper",
|
||||
"create_platform": "Create {platform}"
|
||||
"create_platform": "Create {platform}",
|
||||
"categories": {
|
||||
"input": "Inputs",
|
||||
"time": "Time",
|
||||
"calculation": "Calculations",
|
||||
"other": "Other"
|
||||
},
|
||||
"descriptions": {
|
||||
"input_boolean": "Switches between on and off",
|
||||
"input_button": "Starts automations when pressed",
|
||||
"input_text": "Stores a text value you can change",
|
||||
"input_number": "Stores a number you can set with a slider or input field",
|
||||
"input_datetime": "Stores a date, a time, or both",
|
||||
"input_select": "Lets you choose from a list of options",
|
||||
"counter": "Counts up or down in steps",
|
||||
"schedule": "Turns on during the time blocks you set for each day",
|
||||
"timer": "Counts down from a set duration",
|
||||
"tod": "Turns on between two times of the day",
|
||||
"history_stats": "Measures how long or how often an entity was in a state over a period",
|
||||
"derivative": "Shows how fast a sensor's value changes",
|
||||
"filter": "Smooths out or cleans up the values of a sensor",
|
||||
"integration": "Adds up a sensor's value over time, like power into energy",
|
||||
"min_max": "Combines sensors into their minimum, maximum, mean, median, or sum",
|
||||
"mold_indicator": "Estimates the risk of mold from temperature and humidity",
|
||||
"random": "Creates random values",
|
||||
"statistics": "Calculates statistics, like the average, from a sensor's history",
|
||||
"threshold": "Turns on when a sensor's value is above or below a limit",
|
||||
"trend": "Turns on when a sensor's value is rising or falling",
|
||||
"template": "Creates an entity whose state comes from a template",
|
||||
"utility_meter": "Tracks consumption, like energy or water, per billing period",
|
||||
"generic_hygrostat": "Controls humidity using a switch or fan and a humidity sensor",
|
||||
"generic_thermostat": "Controls temperature using a switch or fan and a temperature sensor",
|
||||
"group": "Combines entities of the same type into one",
|
||||
"otp": "Creates one-time passwords for two-factor authentication",
|
||||
"switch_as_x": "Shows a switch as a light, fan, cover, or another entity type"
|
||||
}
|
||||
}
|
||||
},
|
||||
"core": {
|
||||
|
||||
+7
-1
@@ -8,6 +8,8 @@
|
||||
// Modules
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
// Resolve workspace packages to their sources, see packages/components
|
||||
"customConditions": ["ha-source"],
|
||||
"resolveJsonModule": true,
|
||||
// Babel handles transpiling and no need for declaration files
|
||||
"noEmit": true,
|
||||
@@ -32,6 +34,10 @@
|
||||
{
|
||||
"name": "ts-lit-plugin",
|
||||
"strict": true,
|
||||
// Follows imports of @home-assistant/components entry points (workspace
|
||||
// packages resolve through node_modules) to the component classes
|
||||
// that declare their tags
|
||||
"maxNodeModuleImportDepth": 2,
|
||||
"rules": {
|
||||
// Custom elements
|
||||
"no-unknown-tag-name": "error",
|
||||
@@ -158,5 +164,5 @@
|
||||
}
|
||||
},
|
||||
// Exclude e2e tests — they have their own tsconfig that adds node types.
|
||||
"exclude": ["test/e2e"]
|
||||
"exclude": ["test/e2e", "packages/*/dist"]
|
||||
}
|
||||
Reference in new issue
Block a user