Compare commits

...
Author SHA1 Message Date
Claude 72c435359e 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
2026-10-21 10:23:09 +00:00
Claude 4307d59177 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
2026-10-21 08:28:44 +00:00
Claude 1420885d00 Clarify where to create the scoped registry
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux
2026-10-20 14:56:52 +00:00
Claude e535c04b04 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
2026-10-20 11:57:04 +00:00
Claude e262c9e4b1 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
2026-10-20 11:27:08 +00:00
Claude aa4c86cf22 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
2026-10-20 11:03:26 +00:00
Claude a349ad16ad 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
2026-10-20 09:31:13 +00:00
Claude 88c7ba5ca5 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
2026-10-20 09:16:43 +00:00
Claude b2f31c7cd5 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
2026-10-20 09:13:43 +00:00
Claude 7b1ad2ea81 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
2026-10-20 09:11:25 +00:00
Claude d6b405c9e4 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
2026-10-20 09:07:27 +00:00
Claude 26bb29408a 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
2026-10-20 09:04:20 +00:00
Claude b21432c951 Update the lockfile for the package rename
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux
2026-10-20 09:01:21 +00:00
Claude e961261c97 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
2026-10-20 09:00:47 +00:00
Claude 50d80da0b2 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
2026-10-20 08:48:20 +00:00
Claude 3c6f164f14 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
2026-10-20 08:41:52 +00:00
Claude f91aebf8cf 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
2026-10-20 08:14:07 +00:00
Claude b30024f744 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
2026-10-20 08:09:21 +00:00
Claude caea88e184 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
2026-10-20 08:09:21 +00:00
Claude 052cff9955 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
2026-10-20 08:09:21 +00:00
Claude cb784a6b95 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
2026-10-20 08:09:21 +00:00
Claude bffed854fb 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
2026-10-20 08:09:21 +00:00
Claude 4b215481b8 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
2026-10-20 08:09:20 +00:00
Claude 09a6cb11fa Update lockfile for the hac package and Rslib
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CV5NJdqS5t7iCSfyK7Xsux
2026-10-20 08:09:11 +00:00
Claude 8afb4f69bb 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
2026-10-20 08:08:39 +00:00
Claude 09638965f8 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
2026-10-20 08:08:39 +00:00
37 changed files with 1545 additions and 10 deletions

No files matched your search

+36
View File
@@ -52,8 +52,43 @@ 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 "button" and
// "button/close".
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.replace("*", name) : subpath)}$`,
path.join(
COMPONENTS_PACKAGE_DIR,
name ? target["ha-source"].replace("*", 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");
@@ -380,6 +415,7 @@ const createRspackConfig = ({
resolve: {
extensions: [".ts", ".js", ".json"],
alias: {
...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,
+2 -1
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",
@@ -145,6 +145,7 @@
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@home-assistant/components": "workspace:*",
"@html-eslint/eslint-plugin": "0.67.0",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
+170
View File
@@ -0,0 +1,170 @@
# @home-assistant/components
Home Assistant Components (`hac-*`): framework-agnostic web components built
with [Lit](https://lit.dev).
> Work in progress, not published yet.
## Loading modes
Every element is imported on its own, in one of two ways:
| Import | Registers in | Uses |
| --------------------------------------------- | -------------------- | ------------------------------------------------ |
| `@home-assistant/components/<element>` | Global registry | The bundled copy. Used by Home Assistant itself. |
| `@home-assistant/components/<element>/scoped` | Your scoped registry | The bundled copy, isolated from other copies. |
`<element>` is the group, like `button` for `hac-button`, or the group and a
variant, like `button/close` for `hac-button-close`.
Global registration skips tag names that are already registered, so the first
registration wins and nothing throws.
### Global
```ts
import "@home-assistant/components/button";
import "@home-assistant/components/button/close";
```
### 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
Components read the Home Assistant theme variables when they are available
and fall back to built-in defaults otherwise. Each component also exposes its
own `--hac-*` custom properties, documented in its source.
## Structure
Elements are organized in groups. A group's directory holds its default
element, and variants live in subdirectories, so the directories mirror the
import paths:
```
src/components/button/ <hac-button> -> @home-assistant/components/button
hac-button.ts class, does not register itself
info.ts tag name and version
global.ts registers globally
scoped.ts elements for a scoped registry -> …/scoped
close/ <hac-button-close> -> @home-assistant/components/button/close
hac-button-close.ts
info.ts, global.ts, scoped.ts
```
Shared code lives in `src/utils/`: `registration/` for defining elements
(global and scoped) and `element/` for helpers that components use
themselves, like slot checks.
A variant's tag name is `hac-<group>-<variant>`. `scoped` and `info` are
reserved and cannot be variant names.
Every element has its own version in `info.ts`.
### Dependencies
A component that renders other hac components in its shadow root lists them
in `static dependencies`, with the tag name from its `info.ts`, and imports
only their classes:
```ts
export class HacHeader extends LitElement {
static readonly dependencies = [
{ tagName: BUTTON_CLOSE_TAG_NAME, element: HacButtonClose },
];
}
```
Never register a dependency yourself. The entry point decides where it goes:
- `global` registers dependencies globally, like the component. Inside Home
Assistant everything stays in the global registry.
- `scoped` registers a variant of the component whose shadow root has its own
registry with the bundled dependencies. Its internals never pick up another
copy from the page.
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 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.
+58
View File
@@ -0,0 +1,58 @@
{
"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"
],
"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",
"dev": "rslib build --watch",
"prepublishOnly": "rslib build"
},
"peerDependencies": {
"lit": "^3.3.0"
},
"devDependencies": {
"@rslib/core": "1.0.3",
"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,9 @@
import { defineGlobal } from "../../../utils/registration/dependencies";
import { HacButtonClose } from "./hac-button-close";
import { TAG_NAME } from "./info";
// Registers <hac-button-close> and its dependencies in the global registry, as used by
// Home Assistant
defineGlobal(TAG_NAME, HacButtonClose);
export { HacButtonClose };
@@ -0,0 +1,62 @@
import { css, html } from "lit";
import { HacButton } from "../hac-button";
// mdiClose from Material Design Icons (Apache-2.0)
const CLOSE_ICON =
"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z";
/**
* Icon button with a close icon.
*
* The accessible label defaults to "Close". Set `label` to a translated
* text when the page is not in English.
*
* @csspart base - The native button.
* @cssprop --hac-button-color - Icon color.
* @cssprop --hac-button-height - Width and height.
*/
export class HacButtonClose extends HacButton {
constructor() {
super();
this.appearance = "plain";
}
protected get accessibleLabel() {
return this.label ?? "Close";
}
protected renderContent() {
return html`
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d=${CLOSE_ICON}></path>
</svg>
`;
}
static styles = [
HacButton.styles,
css`
:host([appearance="plain"]) {
--_color: var(--hac-button-color, var(--secondary-text-color, #727272));
}
button {
width: var(--hac-button-height, 40px);
padding: 0;
border-radius: 50%;
}
svg {
width: 24px;
height: 24px;
fill: currentColor;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"hac-button-close": HacButtonClose;
}
}
@@ -0,0 +1,5 @@
/** Tag name of the element. */
export const TAG_NAME = "hac-button-close";
/** Version of <hac-button-close>. */
export const VERSION = "0.1.0";
@@ -0,0 +1,16 @@
import type { ElementDefinition } from "../../../utils/registration/definition";
import { scopedVariant } from "../../../utils/registration/dependencies";
import { HacButtonClose } from "./hac-button-close";
import { TAG_NAME } from "./info";
export { HacButtonClose };
/**
* The bundled <hac-button-close> for a scoped registry:
* `ScopedElementsMixin(LitElement, [hacButtonClose])` or
* `createScopedElementRegistry([hacButtonClose])`.
*/
export const hacButtonClose: ElementDefinition<typeof HacButtonClose> = {
tagName: TAG_NAME,
element: scopedVariant(HacButtonClose),
};
@@ -0,0 +1,9 @@
import { defineGlobal } from "../../utils/registration/dependencies";
import { HacButton } from "./hac-button";
import { TAG_NAME } from "./info";
// Registers <hac-button> and its dependencies in the global registry, as used by
// Home Assistant
defineGlobal(TAG_NAME, HacButton);
export { HacButton };
@@ -0,0 +1,145 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
/**
* Button.
*
* @slot - Label of the button.
* @slot icon - Icon displayed before the label.
*
* @csspart base - The native button.
*
* @cssprop --hac-button-color - Text and icon color.
* @cssprop --hac-button-background - Background of filled buttons.
* @cssprop --hac-button-border-radius - Border radius.
* @cssprop --hac-button-height - Height.
*/
export class HacButton extends LitElement {
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
/** Visual style of the button. */
@property({ reflect: true }) public appearance:
"filled" | "outlined" | "plain" = "filled";
@property({ type: Boolean, reflect: true }) public disabled = false;
/** Accessible label, required for buttons without a visible label. */
@property() public label?: string;
protected render() {
return html`
<button
type="button"
part="base"
?disabled=${this.disabled}
aria-label=${ifDefined(this.accessibleLabel)}
>
${this.renderContent()}
</button>
`;
}
/** Accessible label of the native button, overridden by icon buttons. */
protected get accessibleLabel(): string | undefined {
return this.label;
}
/** Content of the button, overridden by specialized buttons. */
protected renderContent(): TemplateResult {
return html`<slot name="icon"></slot><slot></slot>`;
}
static styles: CSSResultGroup = css`
:host {
display: inline-block;
vertical-align: middle;
--_color: var(--hac-button-color, var(--primary-color, #03a9f4));
}
:host([appearance="filled"]) {
--_color: var(--hac-button-color, var(--text-primary-color, #fff));
}
:host([disabled]) {
pointer-events: none;
}
button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--ha-space-2, 8px);
box-sizing: border-box;
width: 100%;
height: var(--hac-button-height, 40px);
padding: 0 var(--ha-space-4, 16px);
border: 1px solid transparent;
border-radius: var(
--hac-button-border-radius,
var(--ha-border-radius-pill, 9999px)
);
background: transparent;
color: var(--_color);
font: inherit;
font-weight: var(--ha-font-weight-medium, 500);
cursor: pointer;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
:host([appearance="filled"]) button {
background: var(--hac-button-background, var(--primary-color, #03a9f4));
}
:host([appearance="outlined"]) button {
border-color: currentColor;
}
/* State layer for hover and press feedback */
button::before {
content: "";
position: absolute;
inset: 0;
background: currentColor;
opacity: 0;
transition: opacity 0.15s ease-out;
}
@media (hover: hover) {
button:hover::before {
opacity: 0.08;
}
}
button:active::before {
opacity: 0.12;
}
button:focus-visible {
outline: 2px solid var(--primary-color, #03a9f4);
outline-offset: 2px;
}
button:disabled {
cursor: default;
opacity: 0.5;
}
::slotted([slot="icon"]) {
display: flex;
flex: none;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hac-button": HacButton;
}
}
@@ -0,0 +1,5 @@
/** Tag name of the element. */
export const TAG_NAME = "hac-button";
/** Version of <hac-button>. */
export const VERSION = "0.1.0";
@@ -0,0 +1,16 @@
import type { ElementDefinition } from "../../utils/registration/definition";
import { scopedVariant } from "../../utils/registration/dependencies";
import { HacButton } from "./hac-button";
import { TAG_NAME } from "./info";
export { HacButton };
/**
* The bundled <hac-button> for a scoped registry:
* `ScopedElementsMixin(LitElement, [hacButton])` or
* `createScopedElementRegistry([hacButton])`.
*/
export const hacButton: ElementDefinition<typeof HacButton> = {
tagName: TAG_NAME,
element: scopedVariant(HacButton),
};
@@ -0,0 +1,9 @@
import { defineGlobal } from "../../utils/registration/dependencies";
import { HacCard } from "./hac-card";
import { TAG_NAME } from "./info";
// Registers <hac-card> and its dependencies in the global registry, as used by
// Home Assistant
defineGlobal(TAG_NAME, HacCard);
export { HacCard };
@@ -0,0 +1,121 @@
import { css, html, LitElement } from "lit";
import { property, state } from "lit/decorators.js";
import {
hasAssignedContent,
hasAssignedElements,
} from "../../utils/element/slot";
/**
* Container that groups related content.
*
* @slot header - Header of the card, usually a `hac-header`.
* @slot - Content of the card. Its padding is omitted when empty.
* @slot footer - Footer of the card, e.g. actions. Hidden when empty.
*
* @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.
* @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. */
@property({ reflect: true }) public appearance: "outlined" | "raised" =
"outlined";
@state() private _hasHeader = false;
@state() private _hasFooter = false;
@state() private _hasContent = false;
protected render() {
return html`
<slot name="header" @slotchange=${this._headerChanged}></slot>
<div
class="content ${this._hasHeader ? "below-header" : ""}"
?hidden=${!this._hasContent}
>
<slot @slotchange=${this._contentChanged}></slot>
</div>
<div class="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);
}
static styles = css`
:host {
display: block;
position: relative;
box-sizing: border-box;
background: var(
--hac-card-background,
var(--card-background-color, #fff)
);
color: var(--hac-card-color, var(--primary-text-color, #141414));
border: var(--hac-card-border-width, 1px) solid
var(--hac-card-border-color, var(--divider-color, #e0e0e0));
border-radius: var(
--hac-card-border-radius,
var(--ha-border-radius-lg, 12px)
);
box-shadow: var(--hac-card-box-shadow, none);
}
:host([appearance="raised"]) {
border-color: transparent;
box-shadow: var(
--hac-card-box-shadow,
0 2px 1px -1px rgba(0, 0, 0, 0.2),
0 1px 1px 0 rgba(0, 0, 0, 0.14),
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;
justify-content: flex-end;
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(--divider-color, #e0e0e0);
}
.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,16 @@
import type { ElementDefinition } from "../../utils/registration/definition";
import { scopedVariant } from "../../utils/registration/dependencies";
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: scopedVariant(HacCard),
};
@@ -0,0 +1,9 @@
import { defineGlobal } from "../../utils/registration/dependencies";
import { HacHeader } from "./hac-header";
import { TAG_NAME } from "./info";
// Registers <hac-header> and its dependencies in the global registry, as used by
// Home Assistant
defineGlobal(TAG_NAME, HacHeader);
export { HacHeader };
@@ -0,0 +1,164 @@
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { hasAssignedElements } from "../../utils/element/slot";
import { HacButtonClose } from "../button/close/hac-button-close";
import { TAG_NAME as BUTTON_CLOSE_TAG_NAME } from "../button/close/info";
/**
* Header with an optional icon, a title, a subtitle and actions.
*
* Reusable in any container, e.g. `hac-card`. The title is exposed to
* assistive technology as a heading of the given `level`.
*
* @slot - Title of the header.
* @slot icon - Icon displayed before the title.
* @slot subtitle - Secondary text displayed below the title.
* @slot actions - Actions displayed at the end of the header.
*
* @fires hac-close - The close button was clicked, see `showClose`.
*
* @cssprop --hac-header-color - Title color.
* @cssprop --hac-header-secondary-color - Color of the icon and subtitle.
* @cssprop --hac-header-font-family - Title font family.
* @cssprop --hac-header-font-size - Title font size.
* @cssprop --hac-header-padding - Padding of the header.
*/
export class HacHeader extends LitElement {
static readonly dependencies = [
{ tagName: BUTTON_CLOSE_TAG_NAME, element: HacButtonClose },
];
/** Heading level of the title for assistive technology. */
@property({ type: Number }) public level: 1 | 2 | 3 | 4 | 5 | 6 = 2;
/** Shows a close button at the end, which fires `hac-close`. */
@property({ type: Boolean, reflect: true, attribute: "show-close" })
public showClose = false;
/** Accessible label of the close button, "Close" by default. */
@property({ attribute: "close-label" }) public closeLabel?: string;
@state() private _hasIcon = false;
@state() private _hasSubtitle = false;
@state() private _hasActions = false;
protected render() {
return html`
<div class="icon" ?hidden=${!this._hasIcon}>
<slot name="icon" @slotchange=${this._iconChanged}></slot>
</div>
<div class="text">
<div class="title" role="heading" aria-level=${this.level}>
<slot></slot>
</div>
<div class="subtitle" ?hidden=${!this._hasSubtitle}>
<slot name="subtitle" @slotchange=${this._subtitleChanged}></slot>
</div>
</div>
<div class="actions" ?hidden=${!this._hasActions}>
<slot name="actions" @slotchange=${this._actionsChanged}></slot>
</div>
${
this.showClose
? html`<hac-button-close
.label=${this.closeLabel}
@click=${this._close}
></hac-button-close>`
: nothing
}
`;
}
private _close() {
this.dispatchEvent(
new CustomEvent("hac-close", { bubbles: true, composed: true })
);
}
private _iconChanged(ev: Event) {
this._hasIcon = hasAssignedElements(ev);
}
private _subtitleChanged(ev: Event) {
this._hasSubtitle = hasAssignedElements(ev);
}
private _actionsChanged(ev: Event) {
this._hasActions = hasAssignedElements(ev);
}
static styles = css`
:host {
display: flex;
align-items: center;
gap: var(--ha-space-3, 12px);
box-sizing: border-box;
padding: var(
--hac-header-padding,
var(--ha-space-4, 16px) var(--ha-space-4, 16px) var(--ha-space-3, 12px)
);
}
[hidden] {
display: none !important;
}
.icon {
display: flex;
flex: none;
color: var(
--hac-header-secondary-color,
var(--secondary-text-color, #727272)
);
}
.text {
flex: 1;
min-width: 0;
}
.title {
color: var(--hac-header-color, var(--primary-text-color, #141414));
font-family: var(--hac-header-font-family, inherit);
font-size: var(--hac-header-font-size, var(--ha-font-size-2xl, 24px));
font-weight: var(--ha-font-weight-normal, 400);
line-height: var(--ha-line-height-condensed, 1.2);
letter-spacing: -0.012em;
overflow-wrap: anywhere;
}
.subtitle {
margin-block-start: var(--ha-space-1, 4px);
color: var(
--hac-header-secondary-color,
var(--secondary-text-color, #727272)
);
font-size: var(--ha-font-size-m, 14px);
line-height: var(--ha-line-height-normal, 1.6);
}
.actions {
display: flex;
flex: none;
align-items: center;
gap: var(--ha-space-2, 8px);
}
hac-button-close {
flex: none;
margin-inline-end: calc(var(--ha-space-2, 8px) * -1);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hac-header": HacHeader;
}
interface HTMLElementEventMap {
"hac-close": CustomEvent<undefined>;
}
}
@@ -0,0 +1,5 @@
/** Tag name of the element. */
export const TAG_NAME = "hac-header";
/** Version of <hac-header>. */
export const VERSION = "0.1.0";
@@ -0,0 +1,16 @@
import type { ElementDefinition } from "../../utils/registration/definition";
import { scopedVariant } from "../../utils/registration/dependencies";
import { HacHeader } from "./hac-header";
import { TAG_NAME } from "./info";
export { HacHeader };
/**
* The bundled <hac-header> for a scoped registry:
* `ScopedElementsMixin(LitElement, [hacHeader])` or
* `createScopedElementRegistry([hacHeader])`.
*/
export const hacHeader: ElementDefinition<typeof HacHeader> = {
tagName: TAG_NAME,
element: scopedVariant(HacHeader),
};
+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,25 @@
import type { LitElement } from "lit";
/**
* 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;
}
/**
* A hac component class. Components list the elements they render in their
* shadow root in `dependencies`, without registering them. The entry point a
* component is loaded through decides where those are registered:
*
* - global: in the global registry, like the component itself
* - scoped: in a scoped registry of the component's shadow root, so its
* internals always use the bundled, same-version dependencies
*/
export type ComponentClass = (new () => LitElement) & {
readonly dependencies?: readonly ElementDefinition<ComponentClass>[];
};
@@ -0,0 +1,41 @@
import { ScopedElementsMixin } from "../../scoped";
import { define } from "./define";
import type { ComponentClass } from "./definition";
/** Registers a component and its dependencies in the global registry. */
export const defineGlobal = (
tagName: string,
elementClass: ComponentClass
): void => {
for (const dependency of elementClass.dependencies ?? []) {
defineGlobal(dependency.tagName, dependency.element);
}
define(tagName, elementClass);
};
const variants = new WeakMap<ComponentClass, ComponentClass>();
/**
* The class to register in a scoped registry: the component itself if it has
* no dependencies, otherwise a subclass whose shadow root has its own
* registry with scoped variants of its dependencies. Created once per class.
*/
export const scopedVariant = <T extends ComponentClass>(elementClass: T): T => {
const dependencies = elementClass.dependencies ?? [];
if (dependencies.length === 0) {
return elementClass;
}
let variant = variants.get(elementClass);
if (!variant) {
// The mixin returns a new subclass, the variant
variant = ScopedElementsMixin(
elementClass,
dependencies.map(({ tagName, element }) => ({
tagName,
element: scopedVariant(element),
}))
);
variants.set(elementClass, variant);
}
return variant as T;
};
@@ -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);
});
});
@@ -0,0 +1,35 @@
import { LitElement } from "lit";
import { describe, expect, it } from "vitest";
import {
defineGlobal,
scopedVariant,
} from "../src/utils/registration/dependencies";
// Entry points decide where a component's dependencies are registered. These
// cases guard that global mode registers them globally and scoped mode never
// shares classes with the global registry.
class Leaf extends LitElement {}
class Parent extends LitElement {
static readonly dependencies = [{ tagName: "hac-test-leaf", element: Leaf }];
}
describe("defineGlobal", () => {
it("registers the dependencies with the component", () => {
defineGlobal("hac-test-parent", Parent);
expect(customElements.get("hac-test-parent")).toBe(Parent);
expect(customElements.get("hac-test-leaf")).toBe(Leaf);
});
});
describe("scopedVariant", () => {
it("returns components without dependencies unchanged", () => {
expect(scopedVariant(Leaf)).toBe(Leaf);
});
it("returns one subclass per component with dependencies", () => {
const variant = scopedVariant(Parent);
expect(variant).not.toBe(Parent);
expect(variant.prototype).toBeInstanceOf(Parent);
expect(scopedVariant(Parent)).toBe(variant);
});
});
+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"]
}
+186 -6
View File
@@ -490,6 +490,9 @@ importers:
'@gfx/zopfli':
specifier: 1.0.15
version: 1.0.15
'@home-assistant/components':
specifier: workspace:*
version: link:packages/components
'@html-eslint/eslint-plugin':
specifier: 0.67.0
version: 0.67.0([email protected]([email protected]))
@@ -507,7 +510,7 @@ importers:
version: 1.64.1
'@rsdoctor/rspack-plugin':
specifier: 1.6.5
version: 1.6.5(@emnapi/[email protected])(@emnapi/[email protected])(@rspack/[email protected](@swc/[email protected]))([email protected])
version: 1.6.5(@emnapi/[email protected])(@emnapi/[email protected])(@rsbuild/[email protected]([email protected]))(@rspack/[email protected](@swc/[email protected]))([email protected])
'@rspack/core':
specifier: 2.2.9
version: 2.2.9(@swc/[email protected])
@@ -713,6 +716,18 @@ importers:
specifier: 7.4.1
version: 7.4.1(patch_hash=75e6346ad8d8af554c167f77ac7d9b9652f010398efdc6b399925a4795133099)([email protected])
packages/components:
devDependencies:
'@rslib/core':
specifier: 1.0.3
version: 1.0.3([email protected])([email protected])
lit:
specifier: 3.3.3
version: 3.3.3
typescript:
specifier: 6.0.3
version: 6.0.3
packages:
'@ampproject/[email protected]':
@@ -733,6 +748,68 @@ packages:
resolution: {integrity: sha512-3+777rEPsOOjoy5/sRmIzHnoRDaLpm3XWY2dmWn/N7f/XwDG0bcpnFaKL3X9FYJUbNwLmpcJC1bUYexJRxsFlw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@ast-grep/[email protected]':
resolution: {integrity: sha512-rMZJ6frSpR/xSbeV4hiA9WR5hHqk3rVYX8lOqEoUCaUnjEuFXvHdGN4WawJ8pVDq/IqM1UnTtsDSYgUdVmO5DA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-tIclZ5pM+YSgk8NNTBoTkaclbS39cU5lbVfSrFWX4LUnxXfUPG+F+ppxyFjKwJTBELsGHEO4LdX3/Lc+FDwPNA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-Bm8F7/oRHQkDXbjH+gBi9oB5huJ0q12r/AIOMIypf5PWlAVvh206Tsw468phw4Oh6kd4Ad1Z+gflNW1MutgK2g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-yr5xcRerV41GeRxmZ8SKbfGeyZRhQePoNNCgTAb4OrWMelWwbMI80aY7/IlF2bo1sN40BSW4hZdGIO/akTd1RQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-lluRK3hxfHMQdFuVID6seAKVx4FH0kEwF6rCh5/I40Sf3EtNR2UCBXAqd6RBA6hqlSckktvBxCfw+h4aescDmg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-09wJQKq86LqILqQtw0+X09SzhcPwUnMVFKyia7S3XLbLqSeQpAMF55Y+zll53JRQ1dCzFEZJyvTT/7j9vwfE3w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-/HiVgw2cdtzXLDLoG4eBBcauekNXewfagy7qiVbjkVu/KcCH8dJ9lpj8DzeWwuBNtZxuD8jpiwP0M1pz3Bc3uw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-/gwJrJQUw65uNtAFcG2rtSTr117Gqqy3bfjaljCPKsa65dTeRcMMnjKLlZ6V9/g8z+4Xf78kdhp5ECbir4yfSw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-CEZAMAsaDoQoVE3MT2c/ry3/ydfyaqlTjxv8DK5PG355SrdAYPgevGC2Me6tz4dhphJin89Y20VsnrS5UrYV5Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@ast-grep/[email protected]':
resolution: {integrity: sha512-bjJsUt0g3UCwbn9bjv7MnoR15rszSm79yPCXn4DWWK0NuYUUqBU/srtNMR4WnpfR7TnfBRdYFoqSNx0pGCNWJg==}
engines: {node: '>= 10'}
'@babel/[email protected]':
resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
engines: {node: '>=6.9.0'}
@@ -2850,6 +2927,16 @@ packages:
cpu: [x64]
os: [win32]
'@rsbuild/[email protected]':
resolution: {integrity: sha512-UDSDrokgG70tmx+0bz9kuc64SkPQ5A1/Zhqsp8pD0L3Cko/xEnukAbrjouGihUTk55imhC4zx62DnQN/1VJ+GQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
core-js: '>= 3.0.0'
peerDependenciesMeta:
core-js:
optional: true
'@rsbuild/[email protected]':
resolution: {integrity: sha512-26xtEYN0QjZYoyt0lWnvIztBWjEZJvcfw7MN4f5B4SpNggmnF7F7aNPrgkY3EccXVFx1VGQBhnCkBV//OoS07Q==}
peerDependencies:
@@ -2892,6 +2979,19 @@ packages:
'@rsdoctor/[email protected]':
resolution: {integrity: sha512-/Ybsk9krXapFwn0LBS7iKqjVBreRbv2siySAKxEhMJiAiHmaWP8vFsgfyaQHK+TnjqQwiNlN/JeOnh8NF7+tyQ==}
'@rslib/[email protected]':
resolution: {integrity: sha512-a5IZiyf9sNeeE9jcTsTmz16vxcThpbOSJg0BZXzJAxrUFOZX5xG25PJua9hr0LDWGryBX2UQTruVsLQ8BCpqng==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@microsoft/api-extractor': ^7
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true
typescript:
optional: true
'@rspack/[email protected]':
resolution: {integrity: sha512-aIHkk/wngpwgWkwVQFz2AIkmG9WpWtWlkqy4BlYhs5a7l9z0BrB0kXnUb6VSbqZ7Tc3cRepKw7ipkGjKa8KKXw==}
cpu: [arm64]
@@ -6682,6 +6782,19 @@ packages:
[email protected]:
resolution: {integrity: sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==}
[email protected]:
resolution: {integrity: sha512-wVxMYvWmVPam6SeZsz8OixHm+4JPfLJAAMTD0Q2te2SxksvjuHC3wuvU12teSmloQ3nFX1YC6guNzuYqDYw+vQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@microsoft/api-extractor': ^7
'@rsbuild/core': ^2.0.0
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
'@microsoft/api-extractor':
optional: true
typescript:
optional: true
[email protected]:
resolution: {integrity: sha512-g2wW/ermwzGTLlzGdJBDRc4YKz2/yPBjf57w9g5CvhtpZ91Xq95OaWku0oNHYi+XsuV6v8QrCo2oJJR/pCUR8g==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -7864,6 +7977,45 @@ snapshots:
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.3
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optional: true
'@ast-grep/[email protected]':
optionalDependencies:
'@ast-grep/napi-darwin-arm64': 0.45.3
'@ast-grep/napi-darwin-x64': 0.45.3
'@ast-grep/napi-linux-arm64-gnu': 0.45.3
'@ast-grep/napi-linux-arm64-musl': 0.45.3
'@ast-grep/napi-linux-x64-gnu': 0.45.3
'@ast-grep/napi-linux-x64-musl': 0.45.3
'@ast-grep/napi-win32-arm64-msvc': 0.45.3
'@ast-grep/napi-win32-ia32-msvc': 0.45.3
'@ast-grep/napi-win32-x64-msvc': 0.45.3
'@babel/[email protected]':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -10485,19 +10637,30 @@ snapshots:
'@rollup/[email protected]':
optional: true
'@rsbuild/[email protected]':
'@rsbuild/[email protected]([email protected])':
dependencies:
'@rspack/core': 2.2.9(@swc/[email protected])
'@swc/helpers': 0.5.23
optionalDependencies:
core-js: 3.50.0
transitivePeerDependencies:
- '@module-federation/runtime-tools'
'@rsbuild/[email protected](@rsbuild/[email protected]([email protected]))':
dependencies:
acorn: 8.18.0
browserslist-to-es-version: 1.4.2
htmlparser2: 10.0.0
picocolors: 1.1.1
source-map: 0.7.6
optionalDependencies:
'@rsbuild/core': 2.2.13([email protected])
'@rsdoctor/[email protected]': {}
'@rsdoctor/[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@rspack/[email protected](@swc/[email protected]))([email protected])':
'@rsdoctor/[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@rsbuild/[email protected]([email protected]))(@rspack/[email protected](@swc/[email protected]))([email protected])':
dependencies:
'@rsbuild/plugin-check-syntax': 1.6.1
'@rsbuild/plugin-check-syntax': 1.6.1(@rsbuild/[email protected]([email protected]))
'@rsdoctor/graph': 1.6.5(@rspack/[email protected](@swc/[email protected]))
'@rsdoctor/sdk': 1.6.5(@rspack/[email protected](@swc/[email protected]))([email protected])
'@rsdoctor/types': 1.6.5(@rspack/[email protected](@swc/[email protected]))
@@ -10531,9 +10694,9 @@ snapshots:
- '@rspack/core'
- webpack
'@rsdoctor/[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@rspack/[email protected](@swc/[email protected]))([email protected])':
'@rsdoctor/[email protected](@emnapi/[email protected])(@emnapi/[email protected])(@rsbuild/[email protected]([email protected]))(@rspack/[email protected](@swc/[email protected]))([email protected])':
dependencies:
'@rsdoctor/core': 1.6.5(@emnapi/[email protected])(@emnapi/[email protected])(@rspack/[email protected](@swc/[email protected]))([email protected])
'@rsdoctor/core': 1.6.5(@emnapi/[email protected])(@emnapi/[email protected])(@rsbuild/[email protected]([email protected]))(@rspack/[email protected](@swc/[email protected]))([email protected])
'@rsdoctor/graph': 1.6.5(@rspack/[email protected](@swc/[email protected]))
'@rsdoctor/sdk': 1.6.5(@rspack/[email protected](@swc/[email protected]))([email protected])
'@rsdoctor/types': 1.6.5(@rspack/[email protected](@swc/[email protected]))
@@ -10596,6 +10759,16 @@ snapshots:
- '@rspack/core'
- webpack
'@rslib/[email protected]([email protected])([email protected])':
dependencies:
'@rsbuild/core': 2.2.13([email protected])
rsbuild-plugin-dts: 1.0.3(@rsbuild/[email protected]([email protected]))([email protected])
optionalDependencies:
typescript: 6.0.3
transitivePeerDependencies:
- '@module-federation/runtime-tools'
- core-js
'@rspack/[email protected]':
optional: true
@@ -14525,6 +14698,13 @@ snapshots:
dependencies:
tslib: 2.8.1
[email protected](@rsbuild/[email protected]([email protected]))([email protected]):
dependencies:
'@ast-grep/napi': 0.45.3
'@rsbuild/core': 2.2.13([email protected])
optionalDependencies:
typescript: 6.0.3
[email protected]: {}
[email protected](@rspack/[email protected](@swc/[email protected])):
+4
View File
@@ -1,3 +1,7 @@
packages:
- "."
- "packages/*"
savePrefix: ""
autoInstallPeers: false
+25 -2
View File
@@ -1,7 +1,9 @@
import { mdiCellphone, mdiEarth, mdiLock, mdiMonitor, mdiTune } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import "@home-assistant/components/card";
import "@home-assistant/components/header";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
@@ -25,6 +27,8 @@ class HaProfileDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _greetingDismissed = false;
protected render(): TemplateResult {
const pages: PageNavigation[] = [
{
@@ -71,6 +75,20 @@ class HaProfileDashboard extends LitElement {
.header=${this.hass.localize("panel.profile")}
>
<div class="container">
${
this._greetingDismissed
? nothing
: html`<hac-card>
<hac-header
slot="header"
show-close
.closeLabel=${this.hass.localize("ui.common.close")}
@hac-close=${this._dismissGreeting}
>
${this.hass.localize("ui.panel.profile.greeting_title")}
</hac-header>
</hac-card>`
}
<ha-card>
<div class="card-content">
<div class="heading">
@@ -145,6 +163,10 @@ class HaProfileDashboard extends LitElement {
});
}
private _dismissGreeting() {
this._greetingDismissed = true;
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -158,7 +180,8 @@ class HaProfileDashboard extends LitElement {
calc(var(--ha-space-4) + var(--safe-area-inset-bottom));
}
ha-card {
ha-card,
hac-card {
margin: 0 auto var(--ha-space-4);
max-width: 600px;
}
+1
View File
@@ -11899,6 +11899,7 @@
"security": "Security"
},
"is_owner": "You are an owner.",
"greeting_title": "Have a great day!",
"user_preferences_header": "Appearance",
"user_preferences_description": "Default dashboard and sidebar",
"user_preferences_detail": "The following settings are tied to your account and will persist across all sessions and devices.",
+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",
@@ -161,5 +167,5 @@
}
},
// Exclude e2e tests — they have their own tsconfig that adds node types.
"exclude": ["test/e2e"]
"exclude": ["test/e2e", "packages/*/dist"]
}