Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld fb88d66421 Add My redirect for the action history page (#55705)
Lets my.home-assistant.io link to Settings > Tools > Action history
via tools_action_history.
2026-11-06 16:36:31 +01:00
renovate[bot] 23b161d257 Update dependency @rslib/core to v1.0.5 (#55710)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-06 17:28:14 +02:00
123a32dd81 Add experimental @home-assistant/components package (#55652)
* Add hac-card and hac-header as first components of the hac library

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Move hac components into a publishable workspace package built with Rslib

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Rework hac package: tree-shakable entry points, safe registration, standalone theming

- Separate component classes from registration: the root entry exports
  classes only, ./card, ./header and ./all register elements. Named
  imports from the root are now tree-shaken, and sideEffects lists only
  the registering modules (dist and src, as the repo resolves to src).
- Skip registration when a tag is already defined, so Home Assistant and
  custom cards can ship the library side by side.
- Fix hac-header empty-slot handling: :has(::slotted()) is not supported
  by browsers, so empty icon and action slots added gaps.
- Expose the header title as a heading with a configurable level.
- Add fallbacks for all theme variables so components render outside
  Home Assistant, lay out footer actions, drop overflow clipping.
- Resolve the package to its sources via a hac-source export condition
  instead of hand-written aliases, add it as a workspace dependency and
  include it in lit-analyzer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add hac as a workspace dependency of the frontend

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add global, scoped and dynamic loading modes to hac components

Each component now lives in components/<name>/ with its class, a
version.ts, a CHANGELOG.md and three entry points:

- @home-assistant/hac/<name>: registers globally, as Home Assistant does
- <name>/scoped: registers the bundled copy in a scoped registry
- <name>/dynamic: uses Home Assistant's copy when its version is
  compatible, the bundled copy otherwise

ScopedElementsMixin (@home-assistant/hac/scoped) gives a Lit element its
own registry and supports both the standard API and the older API of the
scoped registry polyfill that Home Assistant loads. Without either, it
falls back to global registration.

Home Assistant publishes its components with versions and lazy loaders
at startup. The HTML creates a pending slot first, so extra modules that
load in parallel with the app wait for it instead of missing it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Support plain custom elements in scoped mode and require scoped elements

- Add createScopedElementRegistry, attachScopedShadow and
  createScopedElement, so plain custom elements can use scoped and
  dynamic components, not just Lit elements.
- ScopedElementsMixin builds on them and now takes the elements as a
  required argument: forgetting them is a type error instead of silently
  rendering unknown elements. Plain JavaScript gets a clear runtime error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Add button group and close option for hac-header

- Add the button group with hac-button and hac-button-close, a plain
  icon button with a close icon.
- hac-header gets show-close and close-label; the close button fires
  hac-close.
- Components declare the hac elements they render as static
  dependencies. global registers them globally, so Home Assistant uses
  no scoped registries; scoped and dynamic use a variant with its own
  registry of bundled dependencies, so a component and its dependencies
  always come from the same copy.
- Exclude generated package output from the root type check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Show a dismissable greeting card on the profile page using hac

Trial use of the hac components inside Home Assistant: a hac-card with
a hac-header that has show-close enabled, localized title and close
label. Dismissing hides it until the page is reloaded.

Using it surfaced two things:
- A card with only a header still rendered the padding of its empty
  content area, so hac-card now omits it.
- lit-analyzer only follows imports one level into node_modules, where
  the workspace package resolves, so files using hac entry points failed
  its missing-import check when linted alone, as the pre-commit hook
  does. Allow one more level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Keep the patches comment next to PATCHES_DIR

The hac alias block was inserted between the comment and the constant it
describes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the hac pages from the gallery

The library is a concept for now and should get its own documentation
later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Give every hac element its own entry points and drop the all export

Groups now only organize elements: a group's directory holds its default
element and variants live in subdirectories, mirroring the import paths.
@home-assistant/hac/button registers hac-button only, and
@home-assistant/hac/button/close registers hac-button-close, each with
its own version, changelog, scoped and dynamic entry points.

The all export registered every bundled copy globally, which a custom
card could use to replace Home Assistant's own components, and nothing
used it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Rename the package to @home-assistant/components

The package name says what it is; hac- stays the prefix of the element
tags and classes. The export condition that resolves workspace packages
to their sources is renamed from hac-source to the neutral ha-source, so
future packages can use it too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Load bundled components in dynamic mode only when they are needed

dynamic entry points now import only the element's version up front and
load the bundled component with a dynamic import when Home Assistant
cannot provide a compatible copy, so bundlers split it into a chunk that
is not downloaded otherwise.

The static version on the component classes is removed: the host
publishes its versions in the provider list, so version.ts is the only
source of truth.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Fall back to the scoped entry point in dynamic mode

Each dynamic entry point loads its bundled element through a named
loadScoped function that imports the element's scoped entry point, so
dynamic uses exactly what scoped mode registers and createDynamicLoader
no longer builds the scoped variant itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Drop the registration note from the component docs

How elements are registered is the same for every component and is
documented once in the package README.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Move the slot checks of hac-card and hac-header into a shared helper

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Group the utils of the components package by purpose

registration/ holds what defines elements (global, scoped, dynamic),
element/ the helpers components use themselves.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Define each element once with its tag name next to its version

- version.ts becomes info.ts with TAG_NAME and VERSION, the cheap module
  that dynamic, global, HA's provider and dependent components read the
  tag name from. Only HTMLElementTagNameMap and Lit templates keep the
  literal, as the tooling needs it there.
- scoped and dynamic entry points export one definition object, e.g.
  hacCard = { tagName, element }, instead of a single-entry map.
  ScopedElementsMixin, createScopedElementRegistry and static
  dependencies take lists of definitions.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Drop the pending provider slot from the HTML

Only extra modules can run before the app publishes its components, and
then they simply use their bundled copies. Not worth the inline script
while the library is a concept: the host now just publishes the list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the root entry point of the components package

Nothing used it and it exposed the registration internals as public API.
Every element, the scoped helpers and the provider have their own entry
points; class types come from an element's scoped entry point with
import type.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Clean up the components package API and naming

- Drop the Hac prefix from library-level names, which are not elements:
  ElementDefinition, ElementLoader, ComponentClass, ProvidedComponent(s),
  PROVIDER_KEY. HA's provider file becomes components-provider.ts.
- /scoped no longer exports scopedRegistrySupport, and the mixin no
  longer adds a static scopedRegistry; both were internals.
- scopedVariant uses the mixin's subclass instead of wrapping it again.
- Rename utils/registration/version.ts to compatibility.ts, as elements
  keep their version in info.ts, and keep ComponentClass with the other
  definition types.
- Update the outdated Rslib config comment: only global.ts registers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Clarify where to create the scoped registry

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the per-element changelogs from the concept

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the dynamic loading mode from the components package

The concept starts with global and scoped only. Dynamic mode, with the
provider list HA publishes, the version compatibility check and loader
support in the registry helper, can be added later.

Elements keep their VERSION in info.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Regenerate the lockfile on top of dev for the components package

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Use the semantic color tokens in the components

The components use --ha-color-* tokens (surface, on-surface, text,
border, primary fill, focus) instead of the legacy theme variables, with
the light values as fallback outside Home Assistant. User theme colors
still apply because HA derives the --ha-color-primary-* palette from
them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Reduce the components package to the card for the concept

Remove the button group and the header. The profile greeting is a plain
hac-card now, as it had no close button to dismiss it anymore. The
dependency mechanism stays, documented with a generic example, for the
first element that renders other elements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Remove the dependency mechanism from the components package

Nothing uses it since the header and buttons are gone. The card now
registers itself with define, and its scoped entry point exports the
class directly. It can come back with the first element that renders
other elements.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* Use hac-card for the long-lived access tokens in the profile

The card header is a heading in the header slot, the content needs no
wrapper, and the create button sits in the footer slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux

* refactor: update pnpm workspace settings and improve profile dashboard layout

- Excluded 'rs-module-lexer' from allowed builds in pnpm workspace configuration.
- Adjusted footer alignment in the long-lived access tokens card for better layout.
- Removed unnecessary 'hac-card' from the profile dashboard, simplifying the structure.
- Updated CSS to target only 'ha-card' for styling, enhancing consistency.
- Removed greeting title translation to streamline user experience.

* fix: update caching paths and improve long-lived tokens card styles

* fix: update theming fallback order and improve hac-card styles

* fix: replace `replace` with `replaceAll` for consistent wildcard handling in component source aliases

* copilot

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-11-06 16:04:12 +01:00
WendelinandClaude Opus 5.5 4dc713abe6 Add categories and descriptions to the create helper dialog (#55701)
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Claude <[email protected]>
2026-11-06 13:55:47 +01:00
WendelinandClaude Opus 5.5 b9182e3ea6 Fix bright card outlines on Shelly Wall Display (#55704)
* Keep rgba() colors in the legacy CSS build

Raising the legacy floor to Chrome 67 lets lightningcss rewrite rgba()
to 8-digit hex (#rrggbbaa), since Chrome 62 supports it. Kiosk webviews
like the Shelly Wall Display get the legacy build but can't parse that,
so var(--divider-color) borders on cards, toggles and buttons fell back
to currentColor and showed as bright outlines, the same symptom as
#52986. Always compile hex alpha colors to rgba() in legacy builds, for
both css`` templates and imported stylesheets. Modern builds are
unchanged.

Also drop the loader's redundant /* global */ header, which ESLint now
rejects because module and require are already built-in globals in
.cjs files.

Fixes #55699

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

* Also keep rgba() colors in the legacy service worker build

The template loader runs with the legacy-sw browserslist env for the
service worker layer, which an exact match on legacy missed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018Kv7eGHbhPo6Q2xNuKcJen

---------

Co-authored-by: Claude <[email protected]>
2026-11-06 12:06:46 +01:00
Christian Wiedel 9bd57fa122 Fix grouped data tables stuttering when scrolling in Safari (#55702)
* Fix grouped data tables stuttering when scrolling in Safari

Group header actions are rendered by a callback instead of a slot per group
header. The virtualizer adds and removes header rows while scrolling, and
WebKit is slow to add and remove slots.

Fixes #55636

* Keep group header actions when the data or the pinned group changes

Group header rows are keyed by their group, so an open category menu stays open when the data refreshes. The sticky header is keyed by the pinned group, so its actions don't act on the next group's category.

* Keep group header keys apart from row keys

A group named like a row's id gave the header and the row the same virtualizer key. Prefix the keys so they can't collide.

* Don't call the group header actions slotted in the styles
2026-11-06 11:31:48 +01:00
renovate[bot] 4af7503112 Update dependency @rspack/core to v2.3.1 (#55703) 2026-11-06 10:02:58 +00:00
39 changed files with 2221 additions and 486 deletions

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 }}-${{
+1
View File
@@ -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,
},
})
)
+38
View File
@@ -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",
+8
View File
@@ -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
View File
@@ -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",
+156
View File
@@ -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(),
],
};
+60
View File
@@ -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"
}
}
+24
View File
@@ -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,
};
+132
View File
@@ -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 }),
};
};
+20
View File
@@ -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);
});
});
+40
View File
@@ -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);
});
});
+20
View File
@@ -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"]
}
+765 -106
View File
File diff suppressed because it is too large. Load diff
+5
View File
@@ -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:
+40 -22
View File
@@ -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)
+7 -2
View File
@@ -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.
+6 -44
View File
@@ -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;
@@ -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;
+51
View File
@@ -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";
+251 -173
View File
@@ -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;
+9 -7
View File
@@ -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) =>
+4
View File
@@ -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;
}
`,
];
+36 -1
View File
@@ -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
View File
@@ -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"]
}