Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26690e06b0 | ||
|
|
e7a89e0ac0 | ||
|
|
eb90a50ca1 | ||
|
|
e221ea2b19 | ||
|
|
abd5f9e985 | ||
|
|
2f9a4f99be | ||
|
|
0cf9fcef97 | ||
|
|
3f7f4613d6 | ||
|
|
4a8501a6e5 | ||
|
|
4ae1775644 | ||
|
|
80cd338e77 | ||
|
|
cd8f785ce6 | ||
|
|
4f4f7bd633 | ||
|
|
c2413fd7fa | ||
|
|
ed596fe700 | ||
|
|
e580fb8b5c | ||
|
|
5ba7a906d4 | ||
|
|
c6159615d7 | ||
|
|
b2f63cf345 | ||
|
|
fbd7bb073e | ||
|
|
42fec4fb9c | ||
|
|
bcd7f0dc88 | ||
|
|
44b5dc9446 | ||
|
|
4fd8732160 | ||
|
|
51675e0cd2 | ||
|
|
36bddbff06 | ||
|
|
e8c15f10e2 | ||
|
|
8f870146c6 | ||
|
|
91c0d052df | ||
|
|
dfb5e7d768 | ||
|
|
03fb6f5c29 | ||
|
|
11176a7593 | ||
|
|
8a7b8ff2ac | ||
|
|
f61e1ab509 | ||
|
|
3e75d97b8a | ||
|
|
8588d34fbe | ||
|
|
f4952764d4 | ||
|
|
52d38105ae | ||
|
|
8fa6c1d027 | ||
|
|
972b999195 | ||
|
|
fe6bc5530e | ||
|
|
d5d7093ca3 | ||
|
|
52d30d5b6e | ||
|
|
2a25673659 | ||
|
|
94838cf7d1 | ||
|
|
9214bcac94 | ||
|
|
3be6887df6 | ||
|
|
33374693c9 | ||
|
|
80516f1f00 | ||
|
|
9253af6a19 | ||
|
|
58f1d51c7f | ||
|
|
05d6f614bc | ||
|
|
6e2bb1b2f5 | ||
|
|
f3a6e1028e | ||
|
|
f182b4600d | ||
|
|
b7983e0906 | ||
|
|
6c988150e4 | ||
|
|
b88a39e273 | ||
|
|
ec6b258750 | ||
|
|
ea1fabdfdd | ||
|
|
af4f86743b | ||
|
|
ed492d5b94 | ||
|
|
db00a6b7e7 | ||
|
|
864f0877f6 | ||
|
|
d7062f3d9d | ||
|
|
3051032360 | ||
|
|
54c575efd1 | ||
|
|
e6bc47e6ba | ||
|
|
6244a8db04 | ||
|
|
3f2c1ea538 | ||
|
|
aac947ee4d | ||
|
|
594d9be859 | ||
|
|
80ae2543e5 | ||
|
|
94d176c3d1 | ||
|
|
192d9f785f | ||
|
|
398ffa045b | ||
|
|
e67538fb7c | ||
|
|
025db7ffe7 | ||
|
|
f9c958c80c | ||
|
|
652897a3cb | ||
|
|
3426449315 | ||
|
|
8e19b0249d | ||
|
|
bba034eeca | ||
|
|
31f2767038 | ||
|
|
e0571044ae | ||
|
|
0f00286641 | ||
|
|
9cc166d3cf | ||
|
|
3ad1ed5aa6 | ||
|
|
8ee84c2462 | ||
|
|
eca108032f | ||
|
|
3bfe67f784 | ||
|
|
c27191de6b | ||
|
|
31f626c877 | ||
|
|
b081db7020 |
No files matched your search
@@ -16,7 +16,7 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
## Core Files
|
||||
|
||||
- Context definitions: `src/data/context/index.ts`
|
||||
- `consume` decorator, `ContextSubscriptionController`, and `consumeContext` / `ContextController` for reactive controllers: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- `consume` decorator and `ContextSubscriptionController`: `src/common/decorators/consume.ts` (use instead of `@lit/context`'s `consume` and `ContextConsumer`, enforced by ESLint)
|
||||
- Entity-scoped consume helpers: `src/common/decorators/consume-context-entry.ts`
|
||||
- Transform decorator: `src/common/decorators/transform.ts`
|
||||
- Canonical migration example: `src/panels/lovelace/cards/hui-button-card.ts`
|
||||
@@ -37,7 +37,6 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
| `reducedMotionContext` | `prefers-reduced-motion` media query |
|
||||
|
||||
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
|
||||
|
||||
@@ -51,9 +50,7 @@ Import `consume` from `src/common/decorators/consume`, not from `@lit/context`.
|
||||
- `@state()` plus `@consume` plus `@transform`: rerenders only when the transformed value changes.
|
||||
- `@consume` alone: the field stays current but never triggers a render. Use this for values only read in event handlers or callbacks, such as `apiContext`, or for data read on demand, such as picker item callbacks.
|
||||
|
||||
In reactive controllers, extend `ContextController` and decorate fields with `consumeContext({ context, subscribe, transform })` from the same file. `transform` stores one part of the context value, and `contextUpdated()` runs only when that part changes. `@state()` and `@transform` do not work on controller fields; call `host.requestUpdate()` when the host needs to rerender. Example: `src/data/service-info-controller.ts`.
|
||||
|
||||
For lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
In controllers or for lazily created subscriptions, use `ContextSubscriptionController` from the same file instead of `@lit/context`'s `ContextConsumer`, which also forces a host update. Its callback must store the value in a reactive field or call `host.requestUpdate()` itself when the host needs to rerender.
|
||||
|
||||
Use entity-scoped helpers when the component watches an entity ID held on the host:
|
||||
|
||||
|
||||
@@ -1,23 +1,3 @@
|
||||
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
|
||||
# every pull request. A label is added when a pull request changes a matching
|
||||
# file, and removed again when it no longer does, including one added by hand.
|
||||
#
|
||||
# Do not add these labels here. .github/scripts/label-content.mts applies and
|
||||
# removes them from paths and code. A rule here would remove them whenever its
|
||||
# paths don't match, even when the script's code check does:
|
||||
#
|
||||
# - Companion App
|
||||
# - Home Assistant Link
|
||||
# - Labs
|
||||
# - Supervisor
|
||||
|
||||
Activity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/logbook/**
|
||||
- src/panels/lovelace/**/*logbook*
|
||||
- src/data/logbook.ts
|
||||
|
||||
Agents:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -36,36 +16,6 @@ Agents:
|
||||
- .github/skills/**
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
AI:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
|
||||
- src/components/ha-suggest-with-ai-button.ts
|
||||
- "src/data/{ai_task,llm}.ts"
|
||||
|
||||
Areas:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/areas/**
|
||||
- src/data/area/**
|
||||
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
|
||||
|
||||
Automations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{automation,script,scene,blueprint}/**"
|
||||
- src/components/automation/**
|
||||
- src/components/trace/**
|
||||
- src/data/trace*.ts
|
||||
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
|
||||
|
||||
Backups:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/backup/**
|
||||
- src/data/backup*.ts
|
||||
- src/onboarding/onboarding-restore-backup.ts
|
||||
|
||||
Build:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -73,37 +23,12 @@ Build:
|
||||
- .browserslistrc
|
||||
- gulpfile.js
|
||||
|
||||
Calendar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/calendar/**
|
||||
- src/panels/lovelace/**/*calendar*
|
||||
- src/data/calendar.ts
|
||||
|
||||
Cast:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cast/src/**
|
||||
- src/cast/**
|
||||
|
||||
Connectivity:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/connectivity/**
|
||||
- src/panels/config/tags/**
|
||||
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
|
||||
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
|
||||
|
||||
Dashboards:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/lovelace/**
|
||||
- "src/panels/{home,climate,light,security,maintenance,map}/**"
|
||||
- src/panels/config/lovelace/**
|
||||
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
|
||||
- src/data/lovelace/**
|
||||
- src/data/lovelace*.ts
|
||||
|
||||
Demo:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -132,29 +57,6 @@ Dependencies:
|
||||
- "^renovate/"
|
||||
- "^dependabot/"
|
||||
|
||||
Devices:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/devices/**
|
||||
- src/data/device/device_registry.ts
|
||||
- src/data/ws-device_registry.ts
|
||||
|
||||
Energy:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/energy/**
|
||||
- src/panels/config/energy/**
|
||||
- src/panels/lovelace/cards/energy/**
|
||||
- src/panels/lovelace/**/*energy*
|
||||
- src/data/energy*.ts
|
||||
|
||||
Entities:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/entities/**
|
||||
- src/data/entity/entity_registry*.ts
|
||||
- src/data/ws-entity_registry_display.ts
|
||||
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -162,100 +64,6 @@ GitHub Actions:
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
Helpers:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/helpers/**
|
||||
- src/data/helpers_crud.ts
|
||||
|
||||
History:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/history/**
|
||||
- src/panels/lovelace/**/*history*
|
||||
- src/data/history.ts
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/integrations/**
|
||||
- src/panels/config/application_credentials/**
|
||||
- src/dialogs/config-flow/**
|
||||
- src/data/application_credential.ts
|
||||
- src/data/config_entries.ts
|
||||
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
|
||||
|
||||
Labels:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/labels/**
|
||||
- src/data/label/**
|
||||
|
||||
Landing Page:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- landing-page/**
|
||||
|
||||
Marketplace:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/marketplace/**
|
||||
- src/data/marketplace/**
|
||||
- src/panels/config/integrations/offer-marketplace-uninstall.ts
|
||||
|
||||
Media:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/media-browser/**
|
||||
- src/components/media-player/**
|
||||
- src/data/media_source.ts
|
||||
|
||||
Notifications:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/notifications/**
|
||||
- src/data/persistent_notification.ts
|
||||
|
||||
Onboarding:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/onboarding/**
|
||||
- src/auth/**
|
||||
- src/data/onboarding.ts
|
||||
- src/data/auth.ts
|
||||
|
||||
People:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{person,users}/**"
|
||||
- "src/data/{person,user,ws-user}.ts"
|
||||
|
||||
Profile:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/profile/**
|
||||
|
||||
Search:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/dialogs/quick-bar/**
|
||||
- src/state/quick-bar-mixin.ts
|
||||
- "src/data/{quick_bar,search}.ts"
|
||||
|
||||
Sidebar:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/components/ha-sidebar.ts
|
||||
- src/dialogs/sidebar/**
|
||||
|
||||
System:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
|
||||
- src/panels/config/dashboard/ha-config-updates.ts
|
||||
- "src/dialogs/{repairs-flow,restart}/**"
|
||||
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@@ -285,37 +93,3 @@ System:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
Theming:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/resources/theme/**
|
||||
- src/state/themes-mixin.ts
|
||||
- src/common/dom/apply_themes_on_element.ts
|
||||
- "src/data/{theme,ws-themes}.ts"
|
||||
|
||||
To-do:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/todo/**
|
||||
- src/panels/lovelace/**/*todo*
|
||||
- src/data/todo.ts
|
||||
|
||||
Tools:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/tools/**
|
||||
|
||||
Voice:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/voice-assistants/**
|
||||
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
|
||||
- src/components/ha-assist-chat.ts
|
||||
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
|
||||
|
||||
Zones:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- src/panels/config/zone/**
|
||||
- src/data/zone.ts
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env node
|
||||
// Fails the check when a pull request carries a label that blocks merging, and
|
||||
// writes the outcome to the job summary. Invoked from the `check` job in
|
||||
// .github/workflows/blocking-labels.yaml via actions/github-script:
|
||||
//
|
||||
// const { default: checkBlockingLabels } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
|
||||
// await checkBlockingLabels({ github, context, core });
|
||||
|
||||
export default async function checkBlockingLabels({ context, core }) {
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(
|
||||
":white_check_mark: Pull Request is clear to merge after review",
|
||||
2
|
||||
)
|
||||
.addRaw(
|
||||
"This Pull Request is not blocked by any labels which prevent it from being merged."
|
||||
)
|
||||
.write();
|
||||
}
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks npm registry metadata for the direct dependencies in package.json:
|
||||
// deprecation, source repository and provenance. On pull requests only newly
|
||||
// added packages are checked, and a deprecated one fails the check. Scheduled
|
||||
// and manual runs report on every direct dependency without failing. Invoked
|
||||
// from the `npm-metadata` job in .github/workflows/dependency-review.yaml via
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkNpmMetadata } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`);
|
||||
// await checkNpmMetadata({ github, context, core });
|
||||
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
import type {
|
||||
Context,
|
||||
Core,
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
// Scheduled and manual runs have no pull request
|
||||
type Payload = Partial<PullRequestPayload>;
|
||||
|
||||
type Section =
|
||||
| "dependencies"
|
||||
| "devDependencies"
|
||||
| "optionalDependencies"
|
||||
| "peerDependencies";
|
||||
|
||||
type PackageJson = Partial<Record<Section, Record<string, string>>>;
|
||||
|
||||
interface Dependency {
|
||||
name: string;
|
||||
// The package npm installs, which differs from name for npm: aliases
|
||||
registryName: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
}
|
||||
|
||||
// The fields read from a version document on registry.npmjs.org
|
||||
interface NpmManifest {
|
||||
deprecated?: string;
|
||||
repository?: string | { url?: string };
|
||||
dist?: { attestations?: object };
|
||||
}
|
||||
|
||||
interface Result {
|
||||
name: string;
|
||||
version: string;
|
||||
section: Section;
|
||||
skipped?: string;
|
||||
deprecated?: string;
|
||||
repository?: string;
|
||||
provenance?: boolean;
|
||||
}
|
||||
|
||||
const SECTIONS: Section[] = [
|
||||
"dependencies",
|
||||
"devDependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
];
|
||||
|
||||
const EXACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
||||
|
||||
const NPM_ALIAS = /^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/;
|
||||
|
||||
const CONCURRENCY = 8;
|
||||
|
||||
// Returns undefined when the registry reports 404, and throws on any other
|
||||
// failure so a registry outage fails the job instead of skipping the check.
|
||||
const fetchJson = async <T,>(url: string): Promise<T | undefined> => {
|
||||
const response = await fetch(url);
|
||||
|
||||
if (response.status === 404) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Request to ${url} failed: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(await response.text());
|
||||
};
|
||||
|
||||
const readPackageJson = async (
|
||||
github: Octokit,
|
||||
context: Context<Payload>,
|
||||
ref: string
|
||||
): Promise<PackageJson> => {
|
||||
const { data } = await withRetry(`package.json lookup at ${ref}`, () =>
|
||||
github.rest.repos.getContent({
|
||||
...context.repo,
|
||||
path: "package.json",
|
||||
ref,
|
||||
mediaType: { format: "raw" },
|
||||
})
|
||||
);
|
||||
|
||||
// The raw media type returns the file contents as a string, but Octokit
|
||||
// types the response as the JSON form
|
||||
if (typeof data !== "string") {
|
||||
throw new Error("Expected raw package.json contents");
|
||||
}
|
||||
|
||||
return JSON.parse(data);
|
||||
};
|
||||
|
||||
const listDependencies = (pkg: PackageJson): Dependency[] =>
|
||||
SECTIONS.flatMap((section) =>
|
||||
Object.entries(pkg[section] ?? {}).map(([name, spec]) => {
|
||||
// npm: aliases install another package, e.g. npm:[email protected]
|
||||
const alias = spec.match(NPM_ALIAS);
|
||||
|
||||
return {
|
||||
name,
|
||||
registryName: alias?.[1] ?? name,
|
||||
version: alias?.[2] ?? spec,
|
||||
section,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const repositoryUrl = (repository: NpmManifest["repository"]) => {
|
||||
const url = typeof repository === "string" ? repository : repository?.url;
|
||||
|
||||
return url?.replace(/^git\+/, "").replace(/\.git$/, "");
|
||||
};
|
||||
|
||||
const checkPackage = async ({
|
||||
name,
|
||||
registryName,
|
||||
version,
|
||||
section,
|
||||
}: Dependency): Promise<Result> => {
|
||||
const result = {
|
||||
name: registryName === name ? name : `${name} (npm:${registryName})`,
|
||||
version,
|
||||
section,
|
||||
};
|
||||
|
||||
if (!EXACT_VERSION.test(version)) {
|
||||
return { ...result, skipped: "not an exact version" };
|
||||
}
|
||||
|
||||
const manifest = await fetchJson<NpmManifest>(
|
||||
`https://registry.npmjs.org/${encodeURIComponent(registryName)}/${version}`
|
||||
);
|
||||
|
||||
if (!manifest) {
|
||||
return { ...result, skipped: "not found on the npm registry" };
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
deprecated: manifest.deprecated,
|
||||
repository: repositoryUrl(manifest.repository),
|
||||
provenance: Boolean(manifest.dist?.attestations),
|
||||
};
|
||||
};
|
||||
|
||||
const checkAll = async (dependencies: Dependency[]) => {
|
||||
const results: Result[] = [];
|
||||
|
||||
for (let i = 0; i < dependencies.length; i += CONCURRENCY) {
|
||||
results.push(
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
...(await Promise.all(
|
||||
dependencies.slice(i, i + CONCURRENCY).map(checkPackage)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
const writeSummary = async (core: Core, heading: string, results: Result[]) => {
|
||||
await core.summary
|
||||
.addHeading(heading, 2)
|
||||
.addTable([
|
||||
[
|
||||
"Package",
|
||||
"Version",
|
||||
"Section",
|
||||
"Deprecated",
|
||||
"Provenance",
|
||||
"Repository",
|
||||
].map((data) => ({ data, header: true })),
|
||||
...results.map((result) =>
|
||||
result.skipped
|
||||
? [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
`skipped: ${result.skipped}`,
|
||||
"-",
|
||||
"-",
|
||||
]
|
||||
: [
|
||||
result.name,
|
||||
result.version,
|
||||
result.section,
|
||||
result.deprecated ? "yes" : "no",
|
||||
result.provenance ? "yes" : "no",
|
||||
result.repository ?? "none",
|
||||
]
|
||||
),
|
||||
])
|
||||
.write();
|
||||
};
|
||||
|
||||
export default async function checkNpmMetadata({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<Payload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
if (!pr) {
|
||||
const pkg = await readPackageJson(github, context, context.sha);
|
||||
const results = await checkAll(listDependencies(pkg));
|
||||
|
||||
const flagged = (result: Result) =>
|
||||
Number(
|
||||
Boolean(result.skipped || result.deprecated || !result.repository)
|
||||
);
|
||||
|
||||
results.sort(
|
||||
(a, b) => flagged(b) - flagged(a) || a.name.localeCompare(b.name)
|
||||
);
|
||||
await writeSummary(core, "Direct dependencies", results);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const [base, head] = await Promise.all([
|
||||
readPackageJson(github, context, pr.base.sha),
|
||||
readPackageJson(github, context, pr.head.sha),
|
||||
]);
|
||||
|
||||
// Compare the packages npm installs, so pointing an npm: alias at another
|
||||
// package counts as new, but moving a package between sections does not
|
||||
const existing = new Set(
|
||||
listDependencies(base).map(({ registryName }) => registryName)
|
||||
);
|
||||
|
||||
const added = listDependencies(head).filter(
|
||||
({ registryName }) => !existing.has(registryName)
|
||||
);
|
||||
|
||||
if (added.length === 0) {
|
||||
core.info("No new dependencies");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await checkAll(added);
|
||||
const annotation = { file: "package.json" };
|
||||
|
||||
for (const result of results) {
|
||||
const label = `${result.name}@${result.version}`;
|
||||
|
||||
if (result.skipped) {
|
||||
core.warning(`${label}: ${result.skipped}, not checked`, annotation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.deprecated) {
|
||||
core.error(`${label} is deprecated: ${result.deprecated}`, annotation);
|
||||
}
|
||||
|
||||
if (!result.repository) {
|
||||
core.warning(`${label} has no source repository`, annotation);
|
||||
}
|
||||
|
||||
if (!result.provenance) {
|
||||
core.notice(`${label} was published without provenance`, annotation);
|
||||
}
|
||||
}
|
||||
|
||||
await writeSummary(core, "New dependencies", results);
|
||||
|
||||
if (results.some((result) => result.deprecated)) {
|
||||
core.setFailed("New dependencies must not be deprecated on npm");
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,14 @@
|
||||
// via actions/github-script:
|
||||
//
|
||||
// const { default: checkStandards } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
|
||||
// await checkStandards({ github, context, core });
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkPullRequestStandards({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
}) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
@@ -32,12 +26,10 @@ export default async function checkPullRequestStandards({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
})
|
||||
);
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (_error) {
|
||||
@@ -60,16 +52,11 @@ export default async function checkPullRequestStandards({
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async <T,>(fn: () => Promise<T>) => {
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"status" in error &&
|
||||
error.status === 404
|
||||
) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +65,7 @@ export default async function checkPullRequestStandards({
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId: string, minimized: boolean) => {
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
@@ -91,18 +78,16 @@ export default async function checkPullRequestStandards({
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await withRetry(`comment ${minimized ? "minimize" : "restore"}`, () =>
|
||||
github.graphql(mutation, { id: subjectId })
|
||||
);
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error instanceof Error ? error.message : error}`
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name: string) => {
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
@@ -137,16 +122,13 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await withRetry("comment list", () =>
|
||||
github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
})
|
||||
);
|
||||
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
@@ -154,14 +136,12 @@ export default async function checkPullRequestStandards({
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
)
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
@@ -173,14 +153,12 @@ export default async function checkPullRequestStandards({
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
@@ -196,17 +174,14 @@ export default async function checkPullRequestStandards({
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await withRetry("comment update", () =>
|
||||
github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
})
|
||||
);
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
// Not retried, as a retry after GitHub created the comment would post it twice
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
@@ -5,35 +5,29 @@
|
||||
// actions/github-script:
|
||||
//
|
||||
// const { default: checkTaskAuthorization } =
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`);
|
||||
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
|
||||
// await checkTaskAuthorization({ github, context, core });
|
||||
|
||||
import type { GitHubScriptArgs, IssuePayload } from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
|
||||
export default async function checkTaskAuthorization({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<IssuePayload>) {
|
||||
}) {
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
// Check if user is an organization member
|
||||
try {
|
||||
await withRetry("organization membership check", () =>
|
||||
github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
})
|
||||
);
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: issueAuthor,
|
||||
});
|
||||
core.info(`✅ ${issueAuthor} is an organization member`);
|
||||
return; // Authorized
|
||||
} catch (_error) {
|
||||
core.info(`❌ ${issueAuthor} is not authorized to create Task issues`);
|
||||
}
|
||||
|
||||
// Close the issue with a comment. Not retried, as a retry after GitHub
|
||||
// created the comment would post it twice.
|
||||
// Close the issue with a comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
@@ -47,22 +41,18 @@ export default async function checkTaskAuthorization({
|
||||
`If you believe you should have access to create Task issues, please contact the maintainers.`,
|
||||
});
|
||||
|
||||
await withRetry("issue close", () =>
|
||||
github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
})
|
||||
);
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
// Add a label to indicate this was auto-closed
|
||||
await withRetry("auto-closed label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ["auto-closed"],
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Retries GitHub API requests with jittered exponential backoff. Shared by the
|
||||
// workflow scripts in this directory, test/e2e/post-report-comment.mjs and the
|
||||
// fetch-nightly-translations gulp task. Only wrap requests that are safe to
|
||||
// repeat: a create that fails after GitHub has applied it would run twice.
|
||||
//
|
||||
// This replaces @octokit/plugin-retry, which only retries HTTP error statuses
|
||||
// on a fixed 1s, 4s, 9s delay with no jitter or logging. GitHub can also
|
||||
// answer 200 with briefly stale data, such as the nightly translations lookup
|
||||
// returning an old run with no artifact, and the plugin cannot retry that.
|
||||
// github-script also leaves its built-in retries off by default.
|
||||
|
||||
const RETRIES = 3;
|
||||
|
||||
const BASE_DELAY = 1000; // ms
|
||||
|
||||
const MAX_DELAY = 10000; // ms
|
||||
|
||||
const TRANSIENT_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
|
||||
|
||||
// Throw from a request to retry a response GitHub may answer differently on
|
||||
// the next attempt, such as briefly stale data
|
||||
export class RetryableError extends Error {}
|
||||
|
||||
// Octokit reports network failures as status 500
|
||||
const isRetryable = (err: unknown) => {
|
||||
if (err instanceof RetryableError) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!(err instanceof Error) || !("status" in err)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const status = Number(err.status);
|
||||
|
||||
return (
|
||||
TRANSIENT_STATUSES.has(status) ||
|
||||
(status === 403 && /rate limit/i.test(err.message))
|
||||
);
|
||||
};
|
||||
|
||||
export async function withRetry<T>(
|
||||
label: string,
|
||||
request: () => Promise<T>
|
||||
): Promise<T> {
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
return await request();
|
||||
} catch (err) {
|
||||
if (attempt > RETRIES || !isRetryable(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const delay =
|
||||
Math.min(BASE_DELAY * 2 ** (attempt - 1), MAX_DELAY) *
|
||||
(0.8 + Math.random() * 0.4);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Retrying ${label} after ${(delay / 1000).toFixed(1)}s (attempt ${attempt}/${RETRIES}): ${err instanceof Error ? err.message : err}`
|
||||
);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// The arguments actions/github-script passes to the scripts in this directory,
|
||||
// limited to the parts they use.
|
||||
import type { Octokit } from "@octokit/rest";
|
||||
|
||||
export type SummaryTableCell = string | { data: string; header?: boolean };
|
||||
|
||||
export interface Summary {
|
||||
addHeading(text: string, level?: number): Summary;
|
||||
addRaw(text: string): Summary;
|
||||
addTable(rows: SummaryTableCell[][]): Summary;
|
||||
write(): Promise<Summary>;
|
||||
}
|
||||
|
||||
export interface AnnotationProperties {
|
||||
file?: string;
|
||||
}
|
||||
|
||||
export interface Core {
|
||||
info(message: string): void;
|
||||
notice(message: string, properties?: AnnotationProperties): void;
|
||||
warning(message: string, properties?: AnnotationProperties): void;
|
||||
error(message: string, properties?: AnnotationProperties): void;
|
||||
setFailed(message: string): void;
|
||||
summary: Summary;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
login: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface PullRequestPayload {
|
||||
pull_request: {
|
||||
number: number;
|
||||
body: string | null;
|
||||
draft: boolean;
|
||||
user: User;
|
||||
labels: { name: string }[];
|
||||
base: { sha: string };
|
||||
head: { sha: string };
|
||||
};
|
||||
}
|
||||
|
||||
export interface IssuePayload {
|
||||
issue: {
|
||||
user: User;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Context<Payload> {
|
||||
sha: string;
|
||||
repo: { owner: string; repo: string };
|
||||
issue: { owner: string; repo: string; number: number };
|
||||
payload: Payload;
|
||||
}
|
||||
|
||||
export interface GitHubScriptArgs<Payload> {
|
||||
github: Octokit;
|
||||
context: Context<Payload>;
|
||||
core: Core;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Builds a section of a workflow run's summary: a heading, a table of rows and
|
||||
* the warnings raised along the way.
|
||||
*
|
||||
* Warnings are also logged as annotations, so a script can report problems
|
||||
* without failing its step.
|
||||
*/
|
||||
|
||||
import type { AnnotationProperties, Core } from "./github-script.d.ts";
|
||||
|
||||
interface SummaryOptions {
|
||||
heading: string;
|
||||
columns: string[];
|
||||
// Shown instead of the table when no rows were added
|
||||
empty: string;
|
||||
}
|
||||
|
||||
export const createSummary = (
|
||||
core: Core,
|
||||
{ heading, columns, empty }: SummaryOptions
|
||||
) => {
|
||||
const rows: string[][] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
return {
|
||||
addRow: (...cells: string[]) => {
|
||||
rows.push(cells);
|
||||
},
|
||||
|
||||
warn: (message: string, properties?: AnnotationProperties) => {
|
||||
core.warning(message, properties);
|
||||
warnings.push(message);
|
||||
},
|
||||
|
||||
write: async () => {
|
||||
core.summary.addHeading(heading, 2);
|
||||
|
||||
if (rows.length > 0) {
|
||||
core.summary.addTable([
|
||||
columns.map((data) => ({ data, header: true })),
|
||||
...rows,
|
||||
]);
|
||||
} else {
|
||||
core.summary.addRaw(`${empty}\n`);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
core.summary
|
||||
.addHeading("Warnings", 3)
|
||||
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
|
||||
}
|
||||
|
||||
await core.summary.write();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,324 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
|
||||
* to pull requests.
|
||||
*
|
||||
* These areas are often changed from code outside their folders, which
|
||||
* actions/labeler can't see, so this script owns them and .github/labeler.yml
|
||||
* leaves them out.
|
||||
*
|
||||
* A label matches when the pull request:
|
||||
* - changes a file in one of the area's paths, or
|
||||
* - adds or removes a line that uses the area's API, such as gating a feature
|
||||
* behind Labs or only showing it on Supervisor installs.
|
||||
*
|
||||
* Like actions/labeler, a label is removed once the pull request no longer
|
||||
* matches, including one added by hand.
|
||||
*
|
||||
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
|
||||
*/
|
||||
|
||||
import type {
|
||||
GitHubScriptArgs,
|
||||
PullRequestPayload,
|
||||
} from "./github-script.d.ts";
|
||||
import { withRetry } from "./github-retry.mts";
|
||||
import { createSummary } from "./github-summary.mts";
|
||||
|
||||
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
|
||||
{
|
||||
label: "Companion App",
|
||||
paths: [
|
||||
/^src\/external_app\//,
|
||||
/^src\/data\/external\.ts$/,
|
||||
/^src\/util\/is_ios\.ts$/,
|
||||
],
|
||||
// Reads or messages the companion app the frontend is running in, or
|
||||
// imports its modules. Matches imports of data/external rather than
|
||||
// isExternal, which is also a common local name.
|
||||
pattern:
|
||||
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
|
||||
},
|
||||
{
|
||||
label: "Home Assistant Link",
|
||||
paths: [
|
||||
/^src\/panels\/config\/cloud\//,
|
||||
/^src\/data\/cloud\//,
|
||||
/^src\/data\/cloud\.ts$/,
|
||||
],
|
||||
// Only runs when Home Assistant Link is set up
|
||||
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
|
||||
},
|
||||
{
|
||||
label: "Labs",
|
||||
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
|
||||
// Gates a feature behind, or toggles, a Labs preview feature
|
||||
pattern:
|
||||
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
|
||||
},
|
||||
{
|
||||
label: "Supervisor",
|
||||
paths: [
|
||||
/^src\/panels\/config\/apps\//,
|
||||
/^src\/panels\/app\//,
|
||||
/^src\/data\/hassio\//,
|
||||
/^src\/data\/supervisor\//,
|
||||
],
|
||||
// Only runs on installs with the Supervisor
|
||||
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
|
||||
},
|
||||
];
|
||||
|
||||
interface HunkSide {
|
||||
lines: string[];
|
||||
changed: boolean[];
|
||||
}
|
||||
|
||||
interface CodeChange {
|
||||
code: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
const parseHunks = (patch: string) => {
|
||||
const hunks: { before: HunkSide; after: HunkSide }[] = [];
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("@@")) {
|
||||
hunks.push({
|
||||
before: { lines: [], changed: [] },
|
||||
after: { lines: [], changed: [] },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunk = hunks.at(-1);
|
||||
|
||||
if (!hunk || line.startsWith("\\")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = line.slice(1);
|
||||
|
||||
if (!line.startsWith("+")) {
|
||||
hunk.before.lines.push(text);
|
||||
hunk.before.changed.push(line.startsWith("-"));
|
||||
}
|
||||
|
||||
if (!line.startsWith("-")) {
|
||||
hunk.after.lines.push(text);
|
||||
hunk.after.changed.push(line.startsWith("+"));
|
||||
}
|
||||
}
|
||||
|
||||
return hunks;
|
||||
};
|
||||
|
||||
// Ignores whitespace and trailing commas outside strings, as Prettier changes
|
||||
// them when wrapping
|
||||
const normaliseCode = (code: string) => {
|
||||
const parts = code.split(
|
||||
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
|
||||
);
|
||||
|
||||
return parts
|
||||
.map((part, i) => {
|
||||
if (i % 2 === 1) {
|
||||
return part;
|
||||
}
|
||||
|
||||
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
|
||||
|
||||
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
// Matches whole calls, which can span lines, that touch a changed line
|
||||
const changedMatches = (side: HunkSide, pattern: RegExp) => {
|
||||
const text = side.lines.join("\n");
|
||||
const matches: string[] = [];
|
||||
|
||||
for (const match of text.matchAll(pattern)) {
|
||||
const first = text.slice(0, match.index).split("\n").length - 1;
|
||||
const last = first + match[0].split("\n").length - 1;
|
||||
|
||||
if (side.changed.slice(first, last + 1).some(Boolean)) {
|
||||
matches.push(normaliseCode(match[0]));
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
// Matches that were only moved, reindented or reformatted cancel out in pairs,
|
||||
// returning a file with a match that doesn't
|
||||
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
|
||||
const remaining = [...removed];
|
||||
|
||||
for (const change of added) {
|
||||
const index = remaining.findIndex(({ code }) => code === change.code);
|
||||
|
||||
if (index === -1) {
|
||||
return change.filename;
|
||||
}
|
||||
|
||||
remaining.splice(index, 1);
|
||||
}
|
||||
|
||||
return remaining[0]?.filename;
|
||||
};
|
||||
|
||||
const describeError = (cause: unknown) =>
|
||||
cause instanceof Error ? cause.message : String(cause);
|
||||
|
||||
const isNotFound = (cause: unknown) =>
|
||||
cause instanceof Error && "status" in cause && cause.status === 404;
|
||||
|
||||
export default async function labelContent({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
}: GitHubScriptArgs<PullRequestPayload>) {
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
const summary = createSummary(core, {
|
||||
heading: "Content labels",
|
||||
columns: ["Label", "Change", "Reason"],
|
||||
empty: "No content labels changed.",
|
||||
});
|
||||
|
||||
if (process.env.LABELER_OUTCOME === "failure") {
|
||||
summary.warn(
|
||||
"Applying labels from .github/labeler.yml failed, see its step"
|
||||
);
|
||||
}
|
||||
|
||||
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
|
||||
const checkCode = pr.user.type !== "Bot";
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let files;
|
||||
let existing: Set<string>;
|
||||
|
||||
try {
|
||||
// Read the current labels, as the event's snapshot can be stale by now
|
||||
const [fileList, labels] = await Promise.all([
|
||||
withRetry("pull request files", () =>
|
||||
github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
withRetry("pull request labels", () =>
|
||||
github.paginate(github.rest.issues.listLabelsOnIssue, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
per_page: 100,
|
||||
})
|
||||
),
|
||||
]);
|
||||
|
||||
files = fileList;
|
||||
existing = new Set(labels.map((l) => l.name));
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not read the pull request's files and labels: ${describeError(error)}`
|
||||
);
|
||||
await summary.write();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const add: { label: string; reason: string }[] = [];
|
||||
const remove: string[] = [];
|
||||
|
||||
for (const rule of RULES) {
|
||||
const added: CodeChange[] = [];
|
||||
const removed: CodeChange[] = [];
|
||||
|
||||
if (checkCode) {
|
||||
for (const { filename, patch } of files) {
|
||||
for (const { before, after } of parseHunks(patch ?? "")) {
|
||||
for (const code of changedMatches(after, rule.pattern)) {
|
||||
added.push({ code, filename });
|
||||
}
|
||||
|
||||
for (const code of changedMatches(before, rule.pattern)) {
|
||||
removed.push({ code, filename });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renames count for both locations, as moving a file out of an area changes it
|
||||
const match =
|
||||
files.find(({ filename, previous_filename }) =>
|
||||
rule.paths.some(
|
||||
(path) =>
|
||||
path.test(filename) ||
|
||||
(previous_filename !== undefined && path.test(previous_filename))
|
||||
)
|
||||
)?.filename ?? unmatchedChange(added, removed);
|
||||
|
||||
if (match && !existing.has(rule.label)) {
|
||||
core.info(`Adding ${rule.label} for ${match}`);
|
||||
add.push({ label: rule.label, reason: match });
|
||||
} else if (!match && existing.has(rule.label)) {
|
||||
core.info(`Removing ${rule.label}, no longer matched`);
|
||||
remove.push(rule.label);
|
||||
}
|
||||
}
|
||||
|
||||
const removals = await Promise.allSettled(
|
||||
remove.map((name) =>
|
||||
withRetry("label removal", () =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
name,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
removals.forEach((removal, i) => {
|
||||
const name = remove[i];
|
||||
|
||||
// Already removed, such as by hand or a run that raced this one
|
||||
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
|
||||
summary.addRow(name, "Removed", "No longer matched");
|
||||
} else {
|
||||
summary.warn(
|
||||
`Could not remove ${name}: ${describeError(removal.reason)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (add.length > 0) {
|
||||
try {
|
||||
await withRetry("label addition", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: add.map(({ label }) => label),
|
||||
})
|
||||
);
|
||||
|
||||
for (const { label, reason } of add) {
|
||||
summary.addRow(label, "Added", reason);
|
||||
}
|
||||
} catch (error) {
|
||||
summary.warn(
|
||||
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await summary.write();
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"lib": ["ES2024"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
// actions/github-script imports these directly, relying on Node's type stripping
|
||||
"erasableSyntaxOnly": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["*.mts", "*.d.ts"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
@@ -100,9 +100,12 @@ jobs:
|
||||
run: pnpm run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -148,3 +151,54 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/init@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
uses: github/codeql-action/analyze@1c5b675653bb5c22dbe9b12b556ec555138e09fd # v4.38.1
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Dependency review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- package.json
|
||||
- pnpm-lock.yaml
|
||||
- pnpm-workspace.yaml
|
||||
schedule:
|
||||
- cron: "0 6 1 * *" # Monthly report on all direct dependencies
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
name: Review dependency changes
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To compare the dependency graph between base and head
|
||||
steps:
|
||||
- name: Dependency review
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
with:
|
||||
license-check: false # Covered by pnpm lint:licenses
|
||||
fail-on-severity: high
|
||||
show-openssf-scorecard: true
|
||||
warn-on-openssf-scorecard-level: 3
|
||||
|
||||
npm-metadata:
|
||||
name: Check npm metadata
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To read package.json
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check npm metadata
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkNpmMetadata } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-npm-metadata.mts`
|
||||
);
|
||||
await checkNpmMetadata({ github, context, core });
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
image: mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
|
||||
|
||||
# Runs one at a time per pull request, so an older run can't undo a newer one
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
@@ -15,24 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
id: labeler
|
||||
continue-on-error: true
|
||||
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||
with:
|
||||
sync-labels: true
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Apply content labels
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
|
||||
with:
|
||||
script: |
|
||||
const { default: labelContent } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
|
||||
);
|
||||
await labelContent({ github, context, core });
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -19,6 +19,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
|
||||
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -15,33 +15,22 @@ jobs:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { withRetry } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/github-retry.mts`
|
||||
);
|
||||
await withRetry("no-stale label", () =>
|
||||
github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
})
|
||||
);
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
@@ -62,6 +51,6 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mts`
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -15,12 +15,11 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale policy
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-stale: 90
|
||||
days-before-pr-stale: 60
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
operations-per-run: 25
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -14,7 +14,6 @@ dist/
|
||||
# Node
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
.pnpm-store/
|
||||
|
||||
# Python stuff
|
||||
*.py[cod]
|
||||
|
||||
@@ -1,201 +1,194 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
Apache License
|
||||
==============
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
_Version 2.0, January 2004_
|
||||
_<<http://www.apache.org/licenses/>>_
|
||||
|
||||
1. Definitions.
|
||||
### Terms and Conditions for use, reproduction, and distribution
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
#### 1. Definitions
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
“License” shall mean the terms and conditions for use, reproduction, and
|
||||
distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
“Licensor” shall mean the copyright owner or entity authorized by the copyright
|
||||
owner that is granting the License.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
“Legal Entity” shall mean the union of the acting entity and all other entities
|
||||
that control, are controlled by, or are under common control with that entity.
|
||||
For the purposes of this definition, “control” means **(i)** the power, direct or
|
||||
indirect, to cause the direction or management of such entity, whether by
|
||||
contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or **(iii)** beneficial ownership of such entity.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
“You” (or “Your”) shall mean an individual or Legal Entity exercising
|
||||
permissions granted by this License.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
“Source” form shall mean the preferred form for making modifications, including
|
||||
but not limited to software source code, documentation source, and configuration
|
||||
files.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
“Object” form shall mean any form resulting from mechanical transformation or
|
||||
translation of a Source form, including but not limited to compiled object code,
|
||||
generated documentation, and conversions to other media types.
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
“Work” shall mean the work of authorship, whether in Source or Object form, made
|
||||
available under the License, as indicated by a copyright notice that is included
|
||||
in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
“Derivative Works” shall mean any work, whether in Source or Object form, that
|
||||
is based on (or derived from) the Work and for which the editorial revisions,
|
||||
annotations, elaborations, or other modifications represent, as a whole, an
|
||||
original work of authorship. For the purposes of this License, Derivative Works
|
||||
shall not include works that remain separable from, or merely link (or bind by
|
||||
name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
“Contribution” shall mean any work of authorship, including the original version
|
||||
of the Work and any modifications or additions to that Work or Derivative Works
|
||||
thereof, that is intentionally submitted to Licensor for inclusion in the Work
|
||||
by the copyright owner or by an individual or Legal Entity authorized to submit
|
||||
on behalf of the copyright owner. For the purposes of this definition,
|
||||
“submitted” means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems, and
|
||||
issue tracking systems that are managed by, or on behalf of, the Licensor for
|
||||
the purpose of discussing and improving the Work, but excluding communication
|
||||
that is conspicuously marked or otherwise designated in writing by the copyright
|
||||
owner as “Not a Contribution.”
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
|
||||
of whom a Contribution has been received by Licensor and subsequently
|
||||
incorporated within the Work.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
#### 2. Grant of Copyright License
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the Work and such
|
||||
Derivative Works in Source or Object form.
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
#### 3. Grant of Patent License
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
Subject to the terms and conditions of this License, each Contributor hereby
|
||||
grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
|
||||
irrevocable (except as stated in this section) patent license to make, have
|
||||
made, use, offer to sell, sell, import, and otherwise transfer the Work, where
|
||||
such license applies only to those patent claims licensable by such Contributor
|
||||
that are necessarily infringed by their Contribution(s) alone or by combination
|
||||
of their Contribution(s) with the Work to which such Contribution(s) was
|
||||
submitted. If You institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work or a
|
||||
Contribution incorporated within the Work constitutes direct or contributory
|
||||
patent infringement, then any patent licenses granted to You under this License
|
||||
for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
#### 4. Redistribution
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
You may reproduce and distribute copies of the Work or Derivative Works thereof
|
||||
in any medium, with or without modifications, and in Source or Object form,
|
||||
provided that You meet the following conditions:
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
* **(a)** You must give any other recipients of the Work or Derivative Works a copy of
|
||||
this License; and
|
||||
* **(b)** You must cause any modified files to carry prominent notices stating that You
|
||||
changed the files; and
|
||||
* **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
|
||||
all copyright, patent, trademark, and attribution notices from the Source form
|
||||
of the Work, excluding those notices that do not pertain to any part of the
|
||||
Derivative Works; and
|
||||
* **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
|
||||
Derivative Works that You distribute must include a readable copy of the
|
||||
attribution notices contained within such NOTICE file, excluding those notices
|
||||
that do not pertain to any part of the Derivative Works, in at least one of the
|
||||
following places: within a NOTICE text file distributed as part of the
|
||||
Derivative Works; within the Source form or documentation, if provided along
|
||||
with the Derivative Works; or, within a display generated by the Derivative
|
||||
Works, if and wherever such third-party notices normally appear. The contents of
|
||||
the NOTICE file are for informational purposes only and do not modify the
|
||||
License. You may add Your own attribution notices within Derivative Works that
|
||||
You distribute, alongside or as an addendum to the NOTICE text from the Work,
|
||||
provided that such additional attribution notices cannot be construed as
|
||||
modifying the License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
You may add Your own copyright statement to Your modifications and may provide
|
||||
additional or different license terms and conditions for use, reproduction, or
|
||||
distribution of Your modifications, or for any such Derivative Works as a whole,
|
||||
provided Your use, reproduction, and distribution of the Work otherwise complies
|
||||
with the conditions stated in this License.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
#### 5. Submission of Contributions
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
Unless You explicitly state otherwise, any Contribution intentionally submitted
|
||||
for inclusion in the Work by You to the Licensor shall be under the terms and
|
||||
conditions of this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify the terms of
|
||||
any separate license agreement you may have executed with Licensor regarding
|
||||
such Contributions.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
#### 6. Trademarks
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
This License does not grant permission to use the trade names, trademarks,
|
||||
service marks, or product names of the Licensor, except as required for
|
||||
reasonable and customary use in describing the origin of the Work and
|
||||
reproducing the content of the NOTICE file.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
#### 7. Disclaimer of Warranty
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
Unless required by applicable law or agreed to in writing, Licensor provides the
|
||||
Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
|
||||
including, without limitation, any warranties or conditions of TITLE,
|
||||
NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
|
||||
solely responsible for determining the appropriateness of using or
|
||||
redistributing the Work and assume any risks associated with Your exercise of
|
||||
permissions under this License.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
#### 8. Limitation of Liability
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
In no event and under no legal theory, whether in tort (including negligence),
|
||||
contract, or otherwise, unless required by applicable law (such as deliberate
|
||||
and grossly negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special, incidental,
|
||||
or consequential damages of any character arising as a result of this License or
|
||||
out of the use or inability to use the Work (including but not limited to
|
||||
damages for loss of goodwill, work stoppage, computer failure or malfunction, or
|
||||
any and all other commercial damages or losses), even if such Contributor has
|
||||
been advised of the possibility of such damages.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
#### 9. Accepting Warranty or Additional Liability
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
While redistributing the Work or Derivative Works thereof, You may choose to
|
||||
offer, and charge a fee for, acceptance of support, warranty, indemnity, or
|
||||
other liability obligations and/or rights consistent with this License. However,
|
||||
in accepting such obligations, You may act only on Your own behalf and on Your
|
||||
sole responsibility, not on behalf of any other Contributor, and only if You
|
||||
agree to indemnify, defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason of your
|
||||
accepting any such warranty or additional liability.
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
_END OF TERMS AND CONDITIONS_
|
||||
|
||||
### APPENDIX: How to apply the Apache License to your work
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate
|
||||
notice, with the fields enclosed by brackets `[]` replaced with your own
|
||||
identifying information. (Don't include the brackets!) The text should be
|
||||
enclosed in the appropriate comment syntax for the file format. We also
|
||||
recommend that a file or class name and description of purpose be included on
|
||||
the same “printed page” as the copyright notice for easier identification within
|
||||
third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
"app": 593517,
|
||||
"core": 54884,
|
||||
"authorize": 557490,
|
||||
"onboarding": 676131
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
"app": 738388,
|
||||
"core": 188119,
|
||||
"authorize": 716748,
|
||||
"onboarding": 840680
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import { noUnusedElementImport } from "./no-unused-element-import.mjs";
|
||||
|
||||
export default {
|
||||
meta: { name: "eslint-plugin-ha" },
|
||||
rules: {
|
||||
"no-unused-element-import": noUnusedElementImport,
|
||||
},
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const TAG_PATTERN =
|
||||
/@customElement\(\s*["'`]([a-z0-9_-]+)["'`]|customElements\.define\(\s*["'`]([a-z0-9_-]+)["'`]/g;
|
||||
|
||||
const EXTENSIONS = /\.(ts|js)$/;
|
||||
|
||||
// Entry points register the root element that the served HTML renders.
|
||||
const ENTRY_POINT = /(^|[\\/])(entrypoints[\\/]|entrypoint\.(ts|js)$)/;
|
||||
|
||||
// These resolve a tag from config at runtime, so they register every candidate
|
||||
// they may be asked for.
|
||||
const RUNTIME_REGISTRY =
|
||||
/[\\/]create-element[\\/]|createLovelaceElement\(|getLovelaceElementClass\(/;
|
||||
|
||||
/** @type {Map<string, string[]>} */
|
||||
const tagCache = new Map();
|
||||
|
||||
/** Tags the module at `file` registers when imported. */
|
||||
const registeredTags = (file) => {
|
||||
let tags = tagCache.get(file);
|
||||
if (!tags) {
|
||||
try {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
tags = [...source.matchAll(TAG_PATTERN)].map((m) => m[1] || m[2]);
|
||||
} catch {
|
||||
tags = [];
|
||||
}
|
||||
tagCache.set(file, tags);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
|
||||
const isFile = (candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveModule = (fromFile, request) => {
|
||||
const base = path.resolve(path.dirname(fromFile), request);
|
||||
return [
|
||||
base,
|
||||
`${base}.ts`,
|
||||
`${base}.js`,
|
||||
base.replace(/\.js$/, ".ts"),
|
||||
path.join(base, "index.ts"),
|
||||
path.join(base, "index.js"),
|
||||
].find((candidate) => EXTENSIONS.test(candidate) && isFile(candidate));
|
||||
};
|
||||
|
||||
// `hui-${type}-card` or "state-card-" + domain never spell the tag out.
|
||||
const buildsTagAtRuntime = (source, tag) => {
|
||||
const segments = tag.split("-");
|
||||
for (let end = 1; end < segments.length; end++) {
|
||||
const prefix = `${segments.slice(0, end).join("-")}-`;
|
||||
if (
|
||||
source.includes(`\`${prefix}\${`) ||
|
||||
source.includes(`"${prefix}" +`) ||
|
||||
source.includes(`'${prefix}' +`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Plain substring matching, so <ha-icon-button> counts as a use of ha-icon.
|
||||
// That direction is deliberate: it misses dead imports rather than asking for
|
||||
// a removal that breaks registration.
|
||||
const usesTag = (body, tag) => body.includes(tag);
|
||||
|
||||
// A gallery page registers the elements its sibling markdown demos render, so
|
||||
// that markdown is part of what the module is used by.
|
||||
const siblingMarkup = (file) => {
|
||||
const markdown = file.replace(EXTENSIONS, ".markdown");
|
||||
try {
|
||||
return fs.readFileSync(markdown, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
/** Blanks out the given ranges so an import path cannot count as a usage. */
|
||||
const withoutRanges = (source, ranges) => {
|
||||
let result = source;
|
||||
for (const [start, end] of ranges) {
|
||||
result =
|
||||
result.slice(0, start) + " ".repeat(end - start) + result.slice(end);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Side-effect imports register a custom element. ESLint's unused-import rules
|
||||
* cannot judge them — there is no binding to be unused — so a registration
|
||||
* outlives the template that needed it and nothing reports the dead import.
|
||||
*
|
||||
* Deliberately not fixable: removing the import is only safe once every file
|
||||
* that renders the element imports it itself, which this rule cannot see.
|
||||
*
|
||||
* Off in eslint.config.mjs while a backlog of those paired cases remains;
|
||||
* `pnpm lint:element-imports` enables it as a warning to list them. Flip it to
|
||||
* "error" there once the list is empty.
|
||||
*
|
||||
* No disable comments while it is off: ESLint reports them as unused
|
||||
* directives, which fails lint:eslint. Imports that must stay get theirs in
|
||||
* the change that turns the rule on.
|
||||
*/
|
||||
export const noUnusedElementImport = {
|
||||
meta: {
|
||||
type: "suggestion",
|
||||
docs: {
|
||||
description:
|
||||
"Disallow side-effect imports of a custom element the importing file never uses",
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unused:
|
||||
'"{{request}}" only registers <{{tags}}>, which this file never uses. Remove the import, adding it to whichever file renders the element if that file relies on this registration.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const filename = context.filename;
|
||||
if (!EXTENSIONS.test(filename) || ENTRY_POINT.test(filename)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
Program(program) {
|
||||
const imports = program.body.filter(
|
||||
(node) => node.type === "ImportDeclaration"
|
||||
);
|
||||
const sideEffectImports = imports.filter(
|
||||
(node) =>
|
||||
node.specifiers.length === 0 &&
|
||||
typeof node.source.value === "string" &&
|
||||
node.source.value.startsWith(".")
|
||||
);
|
||||
if (!sideEffectImports.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = context.sourceCode.getText();
|
||||
if (RUNTIME_REGISTRY.test(filename) || RUNTIME_REGISTRY.test(source)) {
|
||||
return;
|
||||
}
|
||||
// Every import declaration, not just the side-effect ones: a module
|
||||
// path is never a usage, and `import type { HaCard } from "./ha-card"`
|
||||
// would otherwise pass off its own path as one.
|
||||
const body =
|
||||
withoutRanges(
|
||||
source,
|
||||
imports.map((node) => node.range)
|
||||
) + siblingMarkup(filename);
|
||||
|
||||
for (const node of sideEffectImports) {
|
||||
const request = node.source.value;
|
||||
const target = resolveModule(filename, request);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
// No tags means no registration to judge: styles, polyfills, mocks.
|
||||
const tags = registeredTags(target);
|
||||
if (
|
||||
!tags.length ||
|
||||
tags.some(
|
||||
(tag) => usesTag(body, tag) || buildsTagAtRuntime(body, tag)
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
messageId: "unused",
|
||||
data: { request, tags: tags.join(">, <") },
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// Task to download the latest Lokalise translations from the nightly workflow artifacts
|
||||
|
||||
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
|
||||
import { retry } from "@octokit/plugin-retry";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { deleteAsync } from "del";
|
||||
import { mkdir, readFile, writeFile } from "fs/promises";
|
||||
@@ -9,20 +10,11 @@ import jszip from "jszip";
|
||||
import path from "path";
|
||||
import process from "process";
|
||||
import { extract } from "tar";
|
||||
import {
|
||||
RetryableError,
|
||||
withRetry,
|
||||
} from "../../.github/scripts/github-retry.mts";
|
||||
|
||||
type Artifact = Awaited<
|
||||
ReturnType<Octokit["rest"]["actions"]["listArtifactsForRepo"]>
|
||||
>["data"]["artifacts"][number];
|
||||
|
||||
const MAX_AGE = 24; // hours
|
||||
const OWNER = "home-assistant";
|
||||
const REPO = "frontend";
|
||||
|
||||
const BRANCH = "dev";
|
||||
const WORKFLOW_NAME = "nightly.yaml";
|
||||
const ARTIFACT_NAME = "translations";
|
||||
const CLIENT_ID = "Iv1.3914e28cb27834d1";
|
||||
const EXTRACT_DIR = "translations";
|
||||
@@ -44,16 +36,11 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
|
||||
// Read current translations artifact info if it exists,
|
||||
// and stop if they are not old enough
|
||||
let currentArtifact: Artifact | null;
|
||||
let currentArtifact;
|
||||
try {
|
||||
const artifact: Artifact = JSON.parse(
|
||||
await readFile(ARTIFACT_FILE, "utf-8")
|
||||
);
|
||||
|
||||
currentArtifact = artifact;
|
||||
|
||||
currentArtifact = JSON.parse(await readFile(ARTIFACT_FILE, "utf-8"));
|
||||
const currentAge =
|
||||
(Date.now() - Date.parse(artifact.created_at ?? "")) / 3600000;
|
||||
(Date.now() - Date.parse(currentArtifact.created_at)) / 3600000;
|
||||
if (currentAge < MAX_AGE) {
|
||||
console.log(
|
||||
"Keeping current translations (only %s hours old)",
|
||||
@@ -76,19 +63,19 @@ gulp.task("fetch-nightly-translations", async function () {
|
||||
}
|
||||
console.warn(
|
||||
"Failed to fetch nightly translations, continuing with English only:",
|
||||
err instanceof Error ? err.message : err
|
||||
err?.message || err
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchTranslations(currentArtifact: Artifact | null) {
|
||||
async function fetchTranslations(currentArtifact) {
|
||||
// To store file writing promises
|
||||
const createExtractDir = mkdir(EXTRACT_DIR, { recursive: true });
|
||||
const writings: Promise<void>[] = [];
|
||||
const writings = [];
|
||||
|
||||
// Authenticate to GitHub using GitHub action token if it exists,
|
||||
// otherwise look for a saved user token or generate a new one if none
|
||||
let tokenAuth: { token: string };
|
||||
let tokenAuth;
|
||||
if (process.env.GITHUB_TOKEN) {
|
||||
tokenAuth = { token: process.env.GITHUB_TOKEN };
|
||||
} else {
|
||||
@@ -117,86 +104,69 @@ async function fetchTranslations(currentArtifact: Artifact | null) {
|
||||
});
|
||||
tokenAuth = await auth({ type: "oauth" });
|
||||
writings.push(
|
||||
createExtractDir.then(() =>
|
||||
createExtractDir.then(
|
||||
writeFile(TOKEN_FILE, JSON.stringify(tokenAuth, null, 2))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate with token and request workflow runs from GitHub
|
||||
console.log("Fetching new translations...");
|
||||
|
||||
// Authenticate with token and find the newest translations artifact
|
||||
const octokit = new Octokit({
|
||||
const octokit = new (Octokit.plugin(retry))({
|
||||
userAgent: "Fetch Nightly Translations",
|
||||
auth: tokenAuth.token,
|
||||
});
|
||||
|
||||
// Only the nightly workflow uploads this artifact. Requiring a run from this
|
||||
// repository's own branch excludes artifacts uploaded by fork pull requests.
|
||||
const latestArtifact = await withRetry(
|
||||
"translations artifact lookup",
|
||||
async () => {
|
||||
const { data } = await octokit.rest.actions.listArtifactsForRepo({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
name: ARTIFACT_NAME,
|
||||
per_page: 10,
|
||||
});
|
||||
const workflowRunsResponse = await octokit.rest.actions.listWorkflowRuns({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
workflow_id: WORKFLOW_NAME,
|
||||
status: "success",
|
||||
event: "schedule",
|
||||
per_page: 1,
|
||||
exclude_pull_requests: true,
|
||||
});
|
||||
if (workflowRunsResponse.data.total_count === 0) {
|
||||
throw Error("No successful nightly workflow runs found");
|
||||
}
|
||||
const latestNightlyRun = workflowRunsResponse.data.workflow_runs[0];
|
||||
|
||||
const artifact = data.artifacts.find(
|
||||
({ expired, workflow_run: run }) =>
|
||||
!expired &&
|
||||
run?.head_branch === BRANCH &&
|
||||
run.head_repository_id === run.repository_id
|
||||
);
|
||||
|
||||
if (!artifact) {
|
||||
throw new RetryableError(
|
||||
`No ${ARTIFACT_NAME} artifact found from ${BRANCH}`
|
||||
);
|
||||
}
|
||||
|
||||
return artifact;
|
||||
}
|
||||
);
|
||||
|
||||
console.log(
|
||||
"Latest translations artifact is %s from workflow run %s (%s)",
|
||||
latestArtifact.id,
|
||||
latestArtifact.workflow_run?.id,
|
||||
latestArtifact.created_at
|
||||
);
|
||||
|
||||
// Stop if current is already the latest
|
||||
if (currentArtifact?.id === latestArtifact.id) {
|
||||
// Stop if current is already the latest, otherwise Find the translations artifact
|
||||
if (currentArtifact?.workflow_run.id === latestNightlyRun.id) {
|
||||
console.log("Stopping because current translations are still the latest");
|
||||
return;
|
||||
}
|
||||
const latestArtifact = (
|
||||
await octokit.actions.listWorkflowRunArtifacts({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
run_id: latestNightlyRun.id,
|
||||
})
|
||||
).data.artifacts.find((artifact) => artifact.name === ARTIFACT_NAME);
|
||||
if (!latestArtifact) {
|
||||
throw Error("Latest nightly workflow run has no translations artifact");
|
||||
}
|
||||
|
||||
// Remove the current translations
|
||||
const deleteCurrent = Promise.all(writings).then(() =>
|
||||
const deleteCurrent = Promise.all(writings).then(
|
||||
deleteAsync([`${EXTRACT_DIR}/*`, `!${ARTIFACT_FILE}`, `!${TOKEN_FILE}`])
|
||||
);
|
||||
|
||||
// Get the download URL and follow the redirect to download (stored as ArrayBuffer)
|
||||
const downloadResponse = await withRetry("translations download", () =>
|
||||
octokit.rest.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
})
|
||||
);
|
||||
|
||||
// Octokit types the redirect, but fetch follows it to the archive
|
||||
if ((downloadResponse.status as number) !== 200) {
|
||||
const downloadResponse = await octokit.actions.downloadArtifact({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
artifact_id: latestArtifact.id,
|
||||
archive_format: "zip",
|
||||
});
|
||||
if (downloadResponse.status !== 200) {
|
||||
throw Error("Failure downloading translations artifact");
|
||||
}
|
||||
|
||||
// Artifact is a tarball, but GitHub adds it to a zip file
|
||||
console.log("Unpacking downloaded translations...");
|
||||
const zip = await jszip.loadAsync(downloadResponse.data as ArrayBuffer);
|
||||
const zip = await jszip.loadAsync(downloadResponse.data);
|
||||
await deleteCurrent;
|
||||
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -77,6 +77,10 @@ async function copyMapPanel(staticDir) {
|
||||
// Style, glyphs and sprites for the vector base map
|
||||
await ensureMapAssets();
|
||||
fs.copySync(mapAssetsDir, staticPath("map/"));
|
||||
copyFileDir(
|
||||
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
|
||||
staticPath("map/")
|
||||
);
|
||||
// Controls and popups of the native MapLibre engine
|
||||
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export const SENSOR_STATE_CLASSES: string[] = [
|
||||
${stateClasses.map((stateClass) => ` "${stateClass}",`).join("\n")}
|
||||
];
|
||||
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, (string | null)[]> = {
|
||||
export const SENSOR_STATE_CLASS_UNITS: Record<string, string[]> = {
|
||||
${Object.entries(stateClassUnits)
|
||||
.map(
|
||||
([stateClass, units]) =>
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-demo-core-data.js";
|
||||
|
||||
@@ -29,7 +29,7 @@ const LICENSE_OVERRIDES = [
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.10.0",
|
||||
version: "5.8.0",
|
||||
licenseFile: "license-mit",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { finished } from "node:stream/promises";
|
||||
import env from "../env.cjs";
|
||||
import paths from "../paths.cjs";
|
||||
import { mergeTranslations } from "./merge-translations.js";
|
||||
import "./fetch-nightly-translations.mts";
|
||||
import "./fetch-nightly-translations.js";
|
||||
|
||||
const inFrontendDir = "translations/frontend";
|
||||
const inBackendDir = "translations/backend";
|
||||
|
||||
@@ -54,9 +54,9 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// Yarn 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");
|
||||
const YARN_PATCHES_DIR = path.join(paths.root_dir, ".yarn", "patches");
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
@@ -69,12 +69,12 @@ const cacheVersion = () => {
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
...(existsSync(PATCHES_DIR) ? fs.readdirSync(PATCHES_DIR) : [])
|
||||
...(existsSync(YARN_PATCHES_DIR) ? fs.readdirSync(YARN_PATCHES_DIR) : [])
|
||||
.filter((file) => file.endsWith(".patch"))
|
||||
.sort()
|
||||
.map(
|
||||
(file) =>
|
||||
`${file}:${fs.readFileSync(path.join(PATCHES_DIR, file), "utf8")}`
|
||||
`${file}:${fs.readFileSync(path.join(YARN_PATCHES_DIR, file), "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
@@ -115,10 +115,7 @@ const createRspackConfig = ({
|
||||
dontHash = new Set();
|
||||
}
|
||||
const ignorePackages = bundle.ignorePackages({ latestBuild });
|
||||
// pnpm links node_modules/lit-html into its store; rspack compares real paths
|
||||
const litHtmlRoot = fs.realpathSync(
|
||||
path.resolve(__dirname, "../node_modules/lit-html")
|
||||
);
|
||||
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
|
||||
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
|
||||
const litDisableDevModeLoader = path.join(
|
||||
__dirname,
|
||||
@@ -492,7 +489,7 @@ const createRspackConfig = ({
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files + patches). Everything is path-independent, so
|
||||
// build files + Yarn patches). Everything is path-independent, so
|
||||
// the cache stays valid when reused on another machine/checkout.
|
||||
// Runtime deps are deliberately absent — rspack's node_modules
|
||||
// snapshot invalidates their modules per-package, so a single
|
||||
|
||||
@@ -27,7 +27,6 @@ import type {
|
||||
LovelaceConfig,
|
||||
LovelaceDashboardStrategyConfig,
|
||||
} from "../../../../src/data/lovelace/config/types";
|
||||
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
|
||||
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
|
||||
import { fetchResources } from "../../../../src/data/lovelace/resource";
|
||||
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
|
||||
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
|
||||
import { castContext } from "../cast_context";
|
||||
import "./hc-launch-screen";
|
||||
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
|
||||
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
|
||||
|
||||
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
|
||||
strategy: {
|
||||
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
|
||||
this._urlPath || "lovelace"
|
||||
);
|
||||
castContext.setApplicationState(title || "");
|
||||
this._lovelaceConfig = normalizeLovelaceConfig(
|
||||
this._lovelaceConfig = checkLovelaceConfig(
|
||||
lovelaceConfig
|
||||
) as LovelaceConfig;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"assist_pipeline/",
|
||||
"config/entity_registry/settings/",
|
||||
"slugify",
|
||||
"marketplace/",
|
||||
...connectivityCommands,
|
||||
];
|
||||
|
||||
@@ -115,23 +114,11 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
"assist_pipeline",
|
||||
"hassio",
|
||||
"hardware",
|
||||
"marketplace",
|
||||
...connectivityComponents,
|
||||
...automationPlatformDomains,
|
||||
]),
|
||||
],
|
||||
},
|
||||
panels: {
|
||||
...hass.panels,
|
||||
// Opened from the settings page, not listed in the sidebar
|
||||
marketplace: {
|
||||
component_name: "marketplace",
|
||||
icon: null,
|
||||
title: null,
|
||||
config: null,
|
||||
url_path: "marketplace",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const localizePromise =
|
||||
|
||||
@@ -39,9 +39,6 @@
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
|
||||
@@ -8,21 +8,17 @@ export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
path: msg.path,
|
||||
}));
|
||||
|
||||
let name: string | undefined;
|
||||
let user = hass.user!;
|
||||
// The profile dialog refreshes the user subscription after saving, which
|
||||
// writes the updated user back to hass.user. Read the current user on each
|
||||
// call so later changes to it, such as a non-admin user, are kept.
|
||||
hass.mockWS("auth/current_user", (_msg, currentHass: MockHomeAssistant) =>
|
||||
name && currentHass.user ? { ...currentHass.user, name } : currentHass.user
|
||||
);
|
||||
// writes the updated user back to hass.user.
|
||||
hass.mockWS("auth/current_user", () => user);
|
||||
hass.mockWS(
|
||||
"person/update_own_profile",
|
||||
(msg: Partial<OwnProfileMutableParams>, currentHass: MockHomeAssistant) => {
|
||||
(msg: Partial<OwnProfileMutableParams>) => {
|
||||
if (msg.name) {
|
||||
name = msg.name;
|
||||
user = { ...user, name: msg.name };
|
||||
}
|
||||
|
||||
return { user_name: name ?? currentHass.user?.name, person: null };
|
||||
return { user_name: user.name, person: null };
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
|
||||
},
|
||||
],
|
||||
agent_errors: {},
|
||||
agent_error_translations: {},
|
||||
last_attempted_automatic_backup: recent,
|
||||
last_completed_automatic_backup: recent,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
|
||||
@@ -12,7 +12,6 @@ import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntityRegistrySettings } from "./entity_registry_settings";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockMarketplace } from "./marketplace";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
@@ -46,7 +45,6 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockAssist(hass);
|
||||
mockEntityRegistrySettings(hass);
|
||||
mockSlugify(hass);
|
||||
mockMarketplace(hass);
|
||||
hass.mockWS("llm/api/list", () => ({
|
||||
apis: [
|
||||
{ id: "assist", name: "Assist" },
|
||||
|
||||
@@ -91,15 +91,6 @@ export const demoConfigEntries: {
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-marketplace",
|
||||
domain: "marketplace",
|
||||
title: "Marketplace",
|
||||
},
|
||||
},
|
||||
...connectivityConfigEntries,
|
||||
];
|
||||
|
||||
|
||||
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
|
||||
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
|
||||
person.timeline.map(([hoursAgo, state]) => {
|
||||
const time = (now - hoursAgo * HOUR) / 1000;
|
||||
const zoneId =
|
||||
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
|
||||
return {
|
||||
s: state,
|
||||
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
|
||||
lu: time,
|
||||
lc: time,
|
||||
};
|
||||
return { s: state, a: {}, lu: time, lc: time };
|
||||
});
|
||||
|
||||
export const mockMap = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { MarketplaceInfo } from "../../../src/data/marketplace/marketplace";
|
||||
import type {
|
||||
MarketplaceRelease,
|
||||
RepositoryBase,
|
||||
RepositoryInfo,
|
||||
} from "../../../src/data/marketplace/repository";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const baseRepository = {
|
||||
can_install: true,
|
||||
config_flow: false,
|
||||
custom: false,
|
||||
domain: null,
|
||||
file_name: "",
|
||||
hide: false,
|
||||
homeassistant: null,
|
||||
installed: false,
|
||||
installed_version: "",
|
||||
local_path: "",
|
||||
new: false,
|
||||
pending_upgrade: false,
|
||||
status: "default" as const,
|
||||
};
|
||||
|
||||
const repositories: RepositoryBase[] = [
|
||||
{
|
||||
...baseRepository,
|
||||
id: "1",
|
||||
category: "integration",
|
||||
full_name: "AlexxIT/WebRTC",
|
||||
name: "WebRTC Camera",
|
||||
description: "Watch camera streams with low latency through WebRTC.",
|
||||
authors: ["@AlexxIT"],
|
||||
domain: "webrtc",
|
||||
config_flow: true,
|
||||
available_version: "v3.6.1",
|
||||
downloads: 98000,
|
||||
stars: 1900,
|
||||
last_updated: "2026-09-12T08:21:00Z",
|
||||
topics: ["camera", "webrtc"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "2",
|
||||
category: "integration",
|
||||
full_name: "basnijholt/adaptive-lighting",
|
||||
name: "Adaptive Lighting",
|
||||
description:
|
||||
"Change the brightness and color temperature of your lights over the day.",
|
||||
authors: ["@basnijholt"],
|
||||
domain: "adaptive_lighting",
|
||||
config_flow: true,
|
||||
available_version: "v1.26.0",
|
||||
installed: true,
|
||||
installed_version: "v1.26.0",
|
||||
status: "installed",
|
||||
downloads: 120000,
|
||||
stars: 2300,
|
||||
last_updated: "2026-08-30T17:02:00Z",
|
||||
topics: ["lights", "circadian-rhythm"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "3",
|
||||
category: "integration",
|
||||
full_name: "blakeblackshear/frigate-hass-integration",
|
||||
name: "Frigate",
|
||||
description: "Use the cameras and events of a Frigate NVR.",
|
||||
authors: ["@blakeblackshear", "@dermotduffy", "@NickM-27"],
|
||||
domain: "frigate",
|
||||
config_flow: true,
|
||||
available_version: "v5.9.0",
|
||||
installed: true,
|
||||
installed_version: "v5.8.0",
|
||||
pending_upgrade: true,
|
||||
status: "pending-upgrade",
|
||||
downloads: 150000,
|
||||
stars: 900,
|
||||
last_updated: "2026-09-25T11:45:00Z",
|
||||
topics: ["camera", "nvr"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "4",
|
||||
category: "plugin",
|
||||
full_name: "piitaya/lovelace-mushroom",
|
||||
name: "Mushroom",
|
||||
description: "A collection of cards to build a clean dashboard.",
|
||||
authors: ["@piitaya"],
|
||||
file_name: "mushroom.js",
|
||||
available_version: "v5.1.0",
|
||||
installed: true,
|
||||
installed_version: "v5.1.0",
|
||||
status: "installed",
|
||||
downloads: 310000,
|
||||
stars: 4400,
|
||||
last_updated: "2026-09-02T09:10:00Z",
|
||||
topics: ["cards", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "5",
|
||||
category: "plugin",
|
||||
full_name: "thomasloven/lovelace-card-mod",
|
||||
name: "card-mod",
|
||||
description: "Add CSS styles to any card of your dashboard.",
|
||||
authors: ["@thomasloven"],
|
||||
file_name: "card-mod.js",
|
||||
available_version: "v4.0.0",
|
||||
downloads: 280000,
|
||||
stars: 1500,
|
||||
last_updated: "2026-07-19T14:33:00Z",
|
||||
topics: ["css", "dashboard"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "6",
|
||||
category: "plugin",
|
||||
full_name: "custom-cards/button-card",
|
||||
name: "button-card",
|
||||
description: "A button card that you can change in almost every way.",
|
||||
authors: ["@RomRider"],
|
||||
file_name: "button-card.js",
|
||||
available_version: "v5.0.0",
|
||||
new: true,
|
||||
status: "new",
|
||||
downloads: 240000,
|
||||
stars: 2100,
|
||||
last_updated: "2026-09-28T19:05:00Z",
|
||||
topics: ["button", "cards"],
|
||||
},
|
||||
{
|
||||
...baseRepository,
|
||||
id: "7",
|
||||
category: "theme",
|
||||
full_name: "catppuccin/home-assistant",
|
||||
name: "Catppuccin",
|
||||
description: "Soothing pastel themes for Home Assistant.",
|
||||
authors: ["@catppuccin"],
|
||||
file_name: "catppuccin.yaml",
|
||||
available_version: "v2.1.0",
|
||||
downloads: 45000,
|
||||
stars: 300,
|
||||
last_updated: "2026-06-11T07:48:00Z",
|
||||
topics: ["theme"],
|
||||
},
|
||||
];
|
||||
|
||||
const repositoryInfo = (repository: RepositoryBase): RepositoryInfo => ({
|
||||
...repository,
|
||||
additional_info: `# ${repository.name}\n\n${repository.description}\n\nThis is a demo. Installing is not available.`,
|
||||
default_branch: "main",
|
||||
issues: 12,
|
||||
releases: [repository.available_version],
|
||||
ref: repository.available_version,
|
||||
replaces_built_in: false,
|
||||
selected_tag: null,
|
||||
update_entity_id: null,
|
||||
version_or_commit: "version",
|
||||
});
|
||||
|
||||
const info: MarketplaceInfo = {
|
||||
categories: ["integration", "plugin", "theme"],
|
||||
disabled_reason: null,
|
||||
github_connected: false,
|
||||
has_pending_tasks: false,
|
||||
lovelace_mode: "storage",
|
||||
stage: "running",
|
||||
startup: false,
|
||||
version: "2.0.0",
|
||||
warning_accepted: true,
|
||||
};
|
||||
|
||||
// Commands that change the Marketplace are not mocked, so the data never
|
||||
// changes and no signal fires.
|
||||
export const mockMarketplace = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("marketplace/info", () => info);
|
||||
|
||||
hass.mockWS("marketplace/repositories/list", () => repositories);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/info",
|
||||
({ repository_id }: { repository_id: string }) => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? repositoryInfo(repository)
|
||||
: Promise.reject({ code: "not_found", message: "Unknown repository" });
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"marketplace/repository/releases",
|
||||
({ repository_id }: { repository_id: string }): MarketplaceRelease[] => {
|
||||
const repository = repositories.find((repo) => repo.id === repository_id);
|
||||
return repository
|
||||
? [
|
||||
{
|
||||
tag: repository.available_version,
|
||||
name: repository.available_version,
|
||||
published_at: String(repository.last_updated),
|
||||
prerelease: false,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS("marketplace/subscribe", () => () => undefined);
|
||||
};
|
||||
@@ -60,7 +60,7 @@ const generateSumStatistics = (
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = new Date(start);
|
||||
@@ -69,9 +69,7 @@ const generateSumStatistics = (
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const bound =
|
||||
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
|
||||
const add = Math.random() * bound;
|
||||
const add = Math.random() * maxDiff;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
@@ -138,15 +136,6 @@ const statisticsFunctions: Record<
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
) => StatisticValue[]
|
||||
> = {
|
||||
"sensor.energy_water": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
4000,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
@@ -312,11 +301,6 @@ const statisticsFunctions: Record<
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
};
|
||||
// Prices are per source unit; water uses litres (4 currency units per m³).
|
||||
const derivedCosts: Record<string, { source: string; price: number }> = {
|
||||
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
|
||||
};
|
||||
|
||||
export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/get_statistics_metadata",
|
||||
@@ -333,23 +317,10 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
const getStats = (id: string) => {
|
||||
cache[id] ??= statisticsFunctions[id](id, start, end, period);
|
||||
return cache[id];
|
||||
};
|
||||
|
||||
statistic_ids.forEach((id: string) => {
|
||||
if (id in derivedCosts) {
|
||||
const { source, price } = derivedCosts[id];
|
||||
let cost = 0;
|
||||
statistics[id] = getStats(source).map((statistic) => {
|
||||
const change = statistic.change! * price;
|
||||
cost += change;
|
||||
return { ...statistic, change, state: cost, sum: cost };
|
||||
});
|
||||
} else if (id in statisticsFunctions) {
|
||||
statistics[id] = getStats(id);
|
||||
if (id in statisticsFunctions) {
|
||||
statistics[id] = statisticsFunctions[id](id, start, end, period);
|
||||
} else {
|
||||
const entityState = hass.states[id];
|
||||
const state = entityState ? Number(entityState.state) : 1;
|
||||
|
||||
@@ -12,7 +12,6 @@ import { configs as wcConfigs } from "eslint-plugin-wc";
|
||||
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
import ha from "./build-scripts/eslint-rules/index.mjs";
|
||||
|
||||
const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
@@ -34,7 +33,6 @@ export default tseslint.config(
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
ha,
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
@@ -233,10 +231,6 @@ export default tseslint.config(
|
||||
],
|
||||
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
// Off because the existing backlog would fail lint:eslint's
|
||||
// --max-warnings=0; run lint:element-imports to see it. Disable
|
||||
// comments for it would be reported as unused, so none until it is on.
|
||||
"ha/no-unused-element-import": "off",
|
||||
"lit/attribute-names": "error",
|
||||
"lit/attribute-value-entities": "off",
|
||||
"lit/no-template-map": "off",
|
||||
@@ -296,7 +290,7 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mts"],
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
|
||||
@@ -40,7 +40,6 @@ export default [
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
"ha-picker-combo-box",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -53,7 +52,6 @@ export default [
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-scrubber",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "./demo-card";
|
||||
@@ -17,7 +18,9 @@ class DemoCards extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -48,7 +51,7 @@ class DemoCards extends LitElement {
|
||||
demo-card {
|
||||
margin: 16px 16px 32px;
|
||||
}
|
||||
ha-switch {
|
||||
ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
#container {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../src/components/ha-formfield";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import "../ha-demo-options";
|
||||
@@ -16,7 +17,9 @@ class DemoMoreInfos extends LitElement {
|
||||
render() {
|
||||
return html`
|
||||
<ha-demo-options>
|
||||
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
|
||||
<ha-formfield label="Show config">
|
||||
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
|
||||
</ha-formfield>
|
||||
</ha-demo-options>
|
||||
<div id="container">
|
||||
<div class="cards">
|
||||
@@ -46,7 +49,7 @@ class DemoMoreInfos extends LitElement {
|
||||
demo-more-info {
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
|
||||
}
|
||||
ha-switch {
|
||||
ha-formfield {
|
||||
margin-right: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import "../../../src/components/ha-theme-settings";
|
||||
import type { ThemeSettings } from "../../../src/types";
|
||||
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
|
||||
|
||||
@customElement("gallery-settings")
|
||||
class GallerySettings extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public themeSettings!: ThemeSettings;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
|
||||
Configure how the gallery renders component previews and examples.
|
||||
</div>
|
||||
<ha-theme-settings
|
||||
.hass=${this.hass}
|
||||
.selectedTheme=${this.themeSettings}
|
||||
.narrow=${this.narrow}
|
||||
.heading=${"Theme"}
|
||||
|
||||
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_off_assumed",
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "TV Off (assumed state, cannot be switched on)",
|
||||
assumed_state: true,
|
||||
// Off + Next + Pause
|
||||
supported_features: 289,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "media_player.theater_on_static",
|
||||
state: "on",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../src/common/dom/media_query";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
floorsContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
reducedMotionContext,
|
||||
registriesContext,
|
||||
servicesContext,
|
||||
statesContext,
|
||||
@@ -163,13 +161,6 @@ class HaGallery extends LitElement {
|
||||
floors: new ContextProvider(this, { context: floorsContext }),
|
||||
};
|
||||
|
||||
private _reducedMotionProvider = new ContextProvider(this, {
|
||||
context: reducedMotionContext,
|
||||
initialValue: false,
|
||||
});
|
||||
|
||||
private _unsubReducedMotion?: () => void;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
// Refresh the fallback contexts before each render so theme/page changes in
|
||||
@@ -242,6 +233,7 @@ class HaGallery extends LitElement {
|
||||
${
|
||||
isSettingsPage
|
||||
? html`<gallery-settings
|
||||
.hass=${this._galleryHass}
|
||||
.themeSettings=${this._themeSettings}
|
||||
.narrow=${this._narrow}
|
||||
.rtl=${this._rtl}
|
||||
@@ -275,10 +267,6 @@ class HaGallery extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
mql.addEventListener("change", this._systemDarkChanged);
|
||||
this._unsubReducedMotion = listenMediaQuery(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
(matches) => this._reducedMotionProvider.setValue(matches)
|
||||
);
|
||||
}
|
||||
|
||||
firstUpdated(changedProps: PropertyValues<this>) {
|
||||
@@ -312,8 +300,6 @@ class HaGallery extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
mql.removeEventListener("change", this._systemDarkChanged);
|
||||
window.removeEventListener("hashchange", this._hashChanged);
|
||||
this._unsubReducedMotion?.();
|
||||
this._unsubReducedMotion = undefined;
|
||||
}
|
||||
|
||||
private _hashChanged = () => {
|
||||
|
||||
@@ -12,11 +12,6 @@
|
||||
<script type="module" src="<%= entry %>"></script>
|
||||
<% } %>
|
||||
<style>
|
||||
html {
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
margin: 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import "../../components/demo-black-white-row";
|
||||
@@ -63,12 +64,13 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -109,7 +111,7 @@ class DemoHaAutomationEditorAction extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import type { ConditionWithShorthand } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
|
||||
@@ -105,12 +106,13 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -151,7 +153,7 @@ export class DemoAutomationEditorCondition extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
|
||||
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockTags } from "../../../../demo/src/stubs/tags";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import type { Trigger } from "../../../../src/data/automation";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
|
||||
@@ -145,12 +146,13 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map(
|
||||
(info, sampleIdx) => html`
|
||||
@@ -191,7 +193,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
{ type: "string", name: "Name", default: "", autofocus: true },
|
||||
@@ -30,8 +31,11 @@ type DialogType =
|
||||
export class DemoHaAdaptiveDialog extends LitElement {
|
||||
@state() private _openDialog: DialogType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
protected firstUpdated() {
|
||||
provideHass(this);
|
||||
const hass = provideHass(this);
|
||||
this._hass = hass;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic"}
|
||||
header-title="Basic adaptive dialog"
|
||||
@closed=${this._handleClosed}
|
||||
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-below"}
|
||||
header-title="Adaptive dialog with subtitle"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle below"
|
||||
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "basic-subtitle-above"}
|
||||
header-title="Adaptive dialog with subtitle above"
|
||||
header-subtitle="This is an adaptive dialog with a subtitle above"
|
||||
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "small"}
|
||||
width="small"
|
||||
header-title="Small adaptive dialog"
|
||||
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "large"}
|
||||
width="large"
|
||||
header-title="Large adaptive dialog"
|
||||
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "form"}
|
||||
header-title="Adaptive dialog with form"
|
||||
header-subtitle="This is an adaptive dialog with a form"
|
||||
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.allowModeChange=${this._openDialog === "allow-mode-change"}
|
||||
header-title="Adaptive dialog with allow mode change"
|
||||
header-subtitle="Resize the window while this dialog is open"
|
||||
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
</ha-adaptive-dialog>
|
||||
|
||||
<ha-adaptive-dialog
|
||||
.hass=${this._hass}
|
||||
.open=${this._openDialog === "actions"}
|
||||
header-title="Adaptive dialog with actions"
|
||||
header-subtitle="This is an adaptive dialog with header actions"
|
||||
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
<h3>Example usage</h3>
|
||||
|
||||
<pre><code><ha-adaptive-dialog
|
||||
.hass=\${this.hass}
|
||||
open
|
||||
header-title="Dialog title"
|
||||
header-subtitle="Dialog subtitle"
|
||||
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
|
||||
|
||||
<p>Example:</p>
|
||||
|
||||
<pre><code><ha-adaptive-dialog open>
|
||||
<pre><code><ha-adaptive-dialog .hass=\${this.hass} open>
|
||||
<ha-form autofocus .schema=\${schema}></ha-form>
|
||||
</ha-adaptive-dialog></code></pre>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
|
||||
import "../../../../src/components/ha-icon-button";
|
||||
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
|
||||
|
||||
const SCHEMA: HaFormSchema[] = [
|
||||
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
|
||||
export class DemoHaAdaptivePopover extends LitElement {
|
||||
@state() private _openPopover: PopoverType = false;
|
||||
|
||||
@state() private _hass?: HomeAssistant;
|
||||
|
||||
@state() private _dialogAnchor?: HTMLElement;
|
||||
|
||||
protected firstUpdated() {
|
||||
provideHass(this);
|
||||
this._hass = provideHass(this);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-card>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "basic"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Basic adaptive popover"
|
||||
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "small"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="small"
|
||||
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "large"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
width="large"
|
||||
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "form"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with form"
|
||||
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
|
||||
</ha-adaptive-popover>
|
||||
|
||||
<ha-adaptive-popover
|
||||
.hass=${this._hass}
|
||||
.open=${this._openPopover === "actions"}
|
||||
.dialogAnchor=${this._dialogAnchor}
|
||||
header-title="Adaptive popover with actions"
|
||||
|
||||
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
title: Control scrubber
|
||||
---
|
||||
|
||||
A horizontal control where the value sits under a fixed window in the middle and the background strip moves. Drag the strip to change the value, or tap a point of the strip to bring it under the window.
|
||||
|
||||
Set `--control-scrubber-track-width` to make the strip wider than the control, so only a part of the range is visible at a time and each pixel of drag changes the value less. Set `wrap` for cyclic ranges such as hue, where the strip repeats and the value wraps around at the bounds. Set `show-handle` to draw the window, and leave it off when there is no current value to point at, such as a light that is off.
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-control-scrubber";
|
||||
|
||||
@customElement("demo-components-ha-control-scrubber")
|
||||
export class DemoHaControlScrubber extends LitElement {
|
||||
@state() private hue = 200;
|
||||
|
||||
@state() private position?: number;
|
||||
|
||||
handleHueChanged(e: CustomEvent) {
|
||||
this.hue = e.detail.value as number;
|
||||
}
|
||||
|
||||
handleMoved(e: CustomEvent) {
|
||||
this.position = e.detail.value as number;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p><b>Scrubber values</b></p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>position</td>
|
||||
<td>${this.position ?? "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>value</td>
|
||||
<td>${this.hue}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ha-card>
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<label id="scrubber-hue">Hue (wrap)</label>
|
||||
<pre>Config: {"wrap":true,"min":0,"max":360}</pre>
|
||||
<ha-control-scrubber
|
||||
wrap
|
||||
show-handle
|
||||
min="0"
|
||||
max="360"
|
||||
unit="°"
|
||||
.value=${this.hue}
|
||||
@value-changed=${this.handleHueChanged}
|
||||
@slider-moved=${this.handleMoved}
|
||||
label="Hue"
|
||||
>
|
||||
</ha-control-scrubber>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-card {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
pre {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
label {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
ha-control-scrubber {
|
||||
--control-scrubber-track-width: 200%;
|
||||
--control-scrubber-background: linear-gradient(
|
||||
to right,
|
||||
hsl(0 100% 50%),
|
||||
hsl(60 100% 50%),
|
||||
hsl(120 100% 50%),
|
||||
hsl(180 100% 50%),
|
||||
hsl(240 100% 50%),
|
||||
hsl(300 100% 50%),
|
||||
hsl(360 100% 50%)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-control-scrubber": DemoHaControlScrubber;
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,12 @@ A styled container with roving-tabindex keyboard navigation. Host role is
|
||||
Home/End jump to the first/last enabled item; Enter/Space activates the
|
||||
focused item.
|
||||
|
||||
With `virtual-focus`, DOM focus stays outside the list, for example in the
|
||||
search field of a picker. The list then ignores key presses. The element that
|
||||
owns the focus calls `moveActiveItem()`, and the list marks the row it points at
|
||||
with the `active` attribute and scrolls it into view instead of focusing it.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| --------------- | ------- | ------- | ---------------------------------------------------------------- |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `virtual-focus` | Boolean | `false` | Keep DOM focus outside the list and mark the active row instead. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
| Name | Type | Default | Description |
|
||||
| ------------ | ------- | ------- | ------------------------------ |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
|
||||
**Events**
|
||||
|
||||
@@ -42,10 +36,6 @@ with the `active` attribute and scrolls it into view instead of focusing it.
|
||||
- `getActiveItemIndex()` — current active index, or `-1`.
|
||||
- `setActiveItemIndex(index, focusItem?)` — move the active index without
|
||||
necessarily focusing.
|
||||
- `moveActiveItem(target)` — move the active row to `"next"`, `"previous"`,
|
||||
`"first"`, `"last"`, `"next-page"` or `"previous-page"`, skipping rows that
|
||||
are not interactive or disabled. Meant for `virtual-focus`.
|
||||
- `clearActiveItem()` — clear the active row.
|
||||
- `updateListItems()` — re-discover slotted items (called automatically on
|
||||
slotchange).
|
||||
|
||||
@@ -144,8 +134,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
- `interactive` (Boolean, default `false`) — opt this row into the parent
|
||||
list's roving tabindex. Useful for sortable rows that need keyboard focus
|
||||
but no click action. Interactive subclasses set this automatically.
|
||||
- `active` (Boolean) — set by a parent list with `virtual-focus` on the row
|
||||
that keyboard navigation points at.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
@@ -155,8 +143,6 @@ Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
the focus-in animation.
|
||||
- `--ha-list-item-focus-offset` — focus outline offset.
|
||||
- `--ha-list-item-focus-background` — background color on keyboard focus.
|
||||
- `--ha-list-item-active-background` — background color of the `active` row.
|
||||
Defaults to `--ha-list-item-focus-background`.
|
||||
|
||||
### `<ha-list-item-button>`
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
---
|
||||
title: Picker combo box
|
||||
---
|
||||
|
||||
# Picker combo box `<ha-picker-combo-box>`
|
||||
|
||||
The searchable list used inside pickers. `ha-generic-picker` renders it in a
|
||||
popover or dialog; entity, area, floor, label and device pickers all reach it
|
||||
that way. It is shown directly here so the list and its keyboard model can be
|
||||
inspected on their own.
|
||||
|
||||
It takes no `hass`. Items come from a `getItems` callback and locale comes from
|
||||
`internationalizationContext`.
|
||||
|
||||
## Two render paths
|
||||
|
||||
The item count picks the renderer, and the two behave differently enough that
|
||||
list changes need checking against both.
|
||||
|
||||
| Items | Renderer | Rows in the DOM |
|
||||
| ------------------------------------------- | ----------------- | --------------------- |
|
||||
| 12 or fewer rendered rows, no sections | `repeat()` | all of them |
|
||||
| more than 12 rendered rows, or any sections | `lit-virtualizer` | only the visible ones |
|
||||
|
||||
The threshold is `MAX_PLAIN_LIST_ITEMS`, counted against the initial rendered
|
||||
row set, including additional items and dialog padding. Filtering does not switch renderers.
|
||||
|
||||
## Two highlights
|
||||
|
||||
The list has two row backgrounds.
|
||||
|
||||
- **`selected`** — the row matching the `value` property, rendered onto its
|
||||
`ha-list-item-option`.
|
||||
- **`active`** — the keyboard cursor, and the row <kbd>Enter</kbd> picks. The
|
||||
list runs in `virtual-focus` mode: DOM focus stays in the search field, and
|
||||
the list owns the active row, skips disabled rows and section titles, and
|
||||
scrolls it into view.
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Behavior |
|
||||
| ------------------------------------- | ----------------------------------------------------- |
|
||||
| <kbd>↑</kbd> <kbd>↓</kbd> | Move the cursor |
|
||||
| <kbd>Home</kbd> <kbd>End</kbd> | Jump to the first or last item |
|
||||
| <kbd>PageUp</kbd> <kbd>PageDown</kbd> | Move the cursor a page |
|
||||
| <kbd>Enter</kbd> | Pick the row under the cursor |
|
||||
| <kbd>Ctrl/⌘</kbd> + <kbd>Enter</kbd> | Pick it and report `newTab: true` in `index-selected` |
|
||||
|
||||
With no cursor yet, <kbd>↓</kbd> and <kbd>Enter</kbd> start from the `value`
|
||||
row, or from the top match while searching. With one item in the list,
|
||||
<kbd>Enter</kbd> picks it whether or not the cursor has moved.
|
||||
|
||||
## Implementation
|
||||
|
||||
Without `sections`, the component filters the items from `getItems` itself as the
|
||||
user types, so `getItems` can ignore its search argument. With sections,
|
||||
`getItems` receives the search string and selected section and must return the
|
||||
matching rows.
|
||||
|
||||
The host is `display: flex` with `flex: 1` and expects a parent with a bounded
|
||||
height. In the app that bound comes from the popover; embedding it directly
|
||||
means supplying one.
|
||||
|
||||
### Properties/Attributes
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
|
||||
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
|
||||
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
|
||||
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
|
||||
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
|
||||
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
|
||||
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
|
||||
| selectedSection | String | - | Section filter selected when the list is initialized. |
|
||||
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
|
||||
| searchFn | Function | - | Post-processes filtered results. |
|
||||
| rowRenderer | Function | - | Replaces the default row template. |
|
||||
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
|
||||
| customValueLabel | String | - | Label for that custom item. |
|
||||
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
|
||||
| emptyLabel | String | - | Shown when there are no items at all. |
|
||||
| noSort | Boolean | false | Keeps the order `getItems` returned. |
|
||||
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Detail | Description |
|
||||
| ---------------- | ------------------------- | -------------------------------------------------------------- |
|
||||
| `value-changed` | `{ value }` | An item was picked. |
|
||||
| `index-selected` | `{ index, item, newTab }` | Which row was picked, and whether it should open in a new tab. |
|
||||
@@ -1,273 +0,0 @@
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-picker-combo-box";
|
||||
import type { PickerComboBoxItem } from "../../../../src/components/ha-picker-combo-box";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../../../src/data/translation";
|
||||
import type {
|
||||
HomeAssistantInternationalization,
|
||||
ValueChangedEvent,
|
||||
} from "../../../../src/types";
|
||||
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
|
||||
|
||||
const LOCALIZE_KEYS: Record<string, string> = {
|
||||
"ui.common.search": "Search",
|
||||
"ui.components.combo-box.no_match": "No matching items found",
|
||||
"ui.components.combo-box.no_items": "No items available",
|
||||
};
|
||||
|
||||
const localize = (key: string) => LOCALIZE_KEYS[key] ?? key;
|
||||
|
||||
const DEMO_I18N: HomeAssistantInternationalization = {
|
||||
localize,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {
|
||||
language: "en",
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
first_weekday: FirstWeekday.language,
|
||||
time_zone: TimeZone.local,
|
||||
},
|
||||
translationMetadata: { fragments: [], translations: {} },
|
||||
loadBackendTranslation: async () => localize,
|
||||
loadFragmentTranslation: async () => localize,
|
||||
};
|
||||
|
||||
const PLAIN_ITEMS: PickerComboBoxItem[] = [
|
||||
{ id: "light.desk", primary: "Desk", secondary: "Office" },
|
||||
{ id: "light.dining_table", primary: "Dining table", secondary: "Kitchen" },
|
||||
{ id: "light.doorway", primary: "Doorway", secondary: "Hallway" },
|
||||
{ id: "light.hallway", primary: "Hallway", secondary: "Hallway" },
|
||||
{
|
||||
id: "light.kitchen_counter",
|
||||
primary: "Kitchen counter",
|
||||
secondary: "Kitchen",
|
||||
},
|
||||
{ id: "light.porch", primary: "Porch", secondary: "Outside" },
|
||||
{ id: "light.reading_lamp", primary: "Reading lamp", secondary: "Bedroom" },
|
||||
{
|
||||
id: "light.shed",
|
||||
primary: "Shed (disabled)",
|
||||
secondary: "Outside",
|
||||
disabled: true,
|
||||
},
|
||||
{ id: "light.workbench", primary: "Workbench", secondary: "Garage" },
|
||||
];
|
||||
|
||||
const AREAS = [
|
||||
"Attic",
|
||||
"Basement",
|
||||
"Bedroom",
|
||||
"Dining room",
|
||||
"Garage",
|
||||
"Hallway",
|
||||
"Kitchen",
|
||||
"Living room",
|
||||
"Office",
|
||||
"Outside",
|
||||
];
|
||||
|
||||
const FIXTURES = [
|
||||
"Ceiling",
|
||||
"Corner lamp",
|
||||
"Cupboard",
|
||||
"Desk lamp",
|
||||
"Downlight",
|
||||
"Floor lamp",
|
||||
"Shelf",
|
||||
"Spotlight",
|
||||
"Strip",
|
||||
"Wall sconce",
|
||||
];
|
||||
|
||||
const VIRTUALIZED_ITEMS: PickerComboBoxItem[] = AREAS.flatMap((area, a) =>
|
||||
FIXTURES.flatMap((fixture) =>
|
||||
[1, 2].map((offset) => {
|
||||
const n = a * 2 + offset;
|
||||
return {
|
||||
id: `light.${fixture.toLowerCase().replace(/ /g, "_")}_${n}`,
|
||||
primary: `${fixture} ${n}`,
|
||||
secondary: area,
|
||||
};
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@customElement("demo-components-ha-picker-combo-box")
|
||||
export class DemoHaPickerComboBox extends LitElement {
|
||||
@state() private _plainValue?: string;
|
||||
|
||||
@state() private _virtualizedValue?: string;
|
||||
|
||||
@state() private _preselectedValue = "light.spotlight_13";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Provides internationalizationContext for ha-picker-combo-box and ha-input-search
|
||||
new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
initialValue: DEMO_I18N,
|
||||
});
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<demo-theme-comparison>
|
||||
${THEME_COMPARISON_PANELS.map(
|
||||
({ slot }) => html`
|
||||
<div slot=${slot} class="panel-content">
|
||||
<ha-card header="Plain list (9 items)">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
At or below 12 items the list renders with
|
||||
<code>repeat()</code>, so every row is in the DOM. One item
|
||||
is disabled, so neither a click nor <kbd>Enter</kbd>
|
||||
picks it.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getPlainItems}
|
||||
.value=${this._plainValue}
|
||||
@value-changed=${this._plainValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Picked: <code>${this._plainValue ?? "—"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Virtualized list (200 items)">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
Above 12 items the list switches to
|
||||
<code>lit-virtualizer</code> and only the visible rows
|
||||
exist.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getVirtualizedItems}
|
||||
.value=${this._virtualizedValue}
|
||||
@value-changed=${this._virtualizedValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Picked: <code>${this._virtualizedValue ?? "—"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Empty">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
With no items the list shows
|
||||
<code>emptyLabel</code>, or a localized fallback. Searching
|
||||
a non-empty list that matches nothing shows
|
||||
<code>notFoundLabel</code> instead.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getNoItems}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="With a current value">
|
||||
<div class="card-content">
|
||||
<p class="note">
|
||||
A set <code>value</code> marks its row
|
||||
<code>selected</code>. The keyboard cursor is a separate
|
||||
highlight, and <kbd>↓</kbd> starts it on this row.
|
||||
</p>
|
||||
<div class="picker">
|
||||
<ha-picker-combo-box
|
||||
.getItems=${this._getVirtualizedItems}
|
||||
.value=${this._preselectedValue}
|
||||
@value-changed=${this._preselectedValueChanged}
|
||||
></ha-picker-combo-box>
|
||||
</div>
|
||||
<p class="value">
|
||||
Current: <code>${this._preselectedValue}</code>
|
||||
</p>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</demo-theme-comparison>
|
||||
`;
|
||||
}
|
||||
|
||||
private _getPlainItems = () => PLAIN_ITEMS;
|
||||
|
||||
private _getVirtualizedItems = () => VIRTUALIZED_ITEMS;
|
||||
|
||||
private _getNoItems = (): PickerComboBoxItem[] => [];
|
||||
|
||||
private _plainValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._plainValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _virtualizedValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._virtualizedValue = ev.detail.value;
|
||||
}
|
||||
|
||||
private _preselectedValueChanged(ev: ValueChangedEvent<string>) {
|
||||
this._preselectedValue = ev.detail.value;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
ha-card {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
.value {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-picker-combo-box": DemoHaPickerComboBox;
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,13 @@ import {
|
||||
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
|
||||
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { resolveSelectorContext } from "../../../../src/data/selector";
|
||||
import type { SerialPort } from "../../../../src/data/usb";
|
||||
import {
|
||||
showDialog,
|
||||
@@ -384,14 +384,6 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
signed_duration: {
|
||||
name: "Signed duration",
|
||||
selector: { duration: { mode: "signed" } },
|
||||
},
|
||||
offset_duration: {
|
||||
name: "Offset",
|
||||
selector: { duration: { mode: "offset", enable_day: true } },
|
||||
},
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
@@ -679,77 +671,6 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Unit of measurement",
|
||||
input: {
|
||||
sensor_device_class: {
|
||||
name: "Device class",
|
||||
selector: { device_class: { domain: "sensor" } },
|
||||
},
|
||||
sensor_state_class: {
|
||||
name: "State class",
|
||||
selector: { state_class: {} },
|
||||
},
|
||||
unit_linked: {
|
||||
name: "Unit of the device class and state class above",
|
||||
selector: {
|
||||
unit_of_measurement: {
|
||||
context: {
|
||||
filter_device_class: "sensor_device_class",
|
||||
filter_state_class: "sensor_state_class",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
unit: {
|
||||
name: "Any unit (custom allowed)",
|
||||
selector: { unit_of_measurement: {} },
|
||||
},
|
||||
unit_temperature: {
|
||||
name: "Temperature",
|
||||
selector: { unit_of_measurement: { device_classes: ["temperature"] } },
|
||||
},
|
||||
unit_battery_humidity: {
|
||||
name: "Battery or humidity",
|
||||
selector: {
|
||||
unit_of_measurement: { device_classes: ["battery", "humidity"] },
|
||||
},
|
||||
},
|
||||
unit_aqi: {
|
||||
name: "Air quality index (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["aqi"] } },
|
||||
},
|
||||
unit_enum: {
|
||||
name: "Enum (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["enum"] } },
|
||||
},
|
||||
unit_timestamp: {
|
||||
name: "Timestamp (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["timestamp"] } },
|
||||
},
|
||||
unit_uptime: {
|
||||
name: "Uptime (no unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["uptime"] } },
|
||||
},
|
||||
unit_monetary: {
|
||||
name: "Monetary (custom unit)",
|
||||
selector: { unit_of_measurement: { device_classes: ["monetary"] } },
|
||||
},
|
||||
unit_angle: {
|
||||
name: "Measurement angle",
|
||||
selector: {
|
||||
unit_of_measurement: { state_classes: ["measurement_angle"] },
|
||||
},
|
||||
},
|
||||
unit_context: {
|
||||
name: "Temperature or humidity, narrowed by context to humidity",
|
||||
selector: {
|
||||
unit_of_measurement: { device_classes: ["temperature", "humidity"] },
|
||||
},
|
||||
context: { filter_device_class: "humidity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiples",
|
||||
input: {
|
||||
@@ -1062,30 +983,34 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="options">
|
||||
<ha-switch
|
||||
.name=${"label"}
|
||||
.checked=${this._label}
|
||||
@change=${this._handleOptionChange}
|
||||
>Labels</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"required"}
|
||||
.checked=${this._required}
|
||||
@change=${this._handleOptionChange}
|
||||
>Required</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
>Disabled</ha-switch
|
||||
>
|
||||
<ha-switch
|
||||
.name=${"helper"}
|
||||
.checked=${this._helper}
|
||||
@change=${this._handleOptionChange}
|
||||
>Helper text</ha-switch
|
||||
>
|
||||
<ha-formfield label="Labels">
|
||||
<ha-switch
|
||||
.name=${"label"}
|
||||
.checked=${this._label}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Required">
|
||||
<ha-switch
|
||||
.name=${"required"}
|
||||
.checked=${this._required}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Disabled">
|
||||
<ha-switch
|
||||
.name=${"disabled"}
|
||||
.checked=${this._disabled}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Helper text">
|
||||
<ha-switch
|
||||
.name=${"helper"}
|
||||
.checked=${this._helper}
|
||||
@change=${this._handleOptionChange}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
${SCHEMAS.map((info, idx) => {
|
||||
const data = this.data[idx];
|
||||
@@ -1106,7 +1031,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
.context=${this._context(value!, data)}
|
||||
.context=${value!.context}
|
||||
.key=${key}
|
||||
.label=${this._label ? value!.name : undefined}
|
||||
.value=${data[key] ?? value!.default}
|
||||
@@ -1126,19 +1051,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _context(
|
||||
input: (typeof SCHEMAS)[number]["input"][string],
|
||||
data: Record<string, unknown>
|
||||
): Record<string, unknown> | undefined {
|
||||
const selectorContext = input?.selector
|
||||
? resolveSelectorContext(input.selector, data)
|
||||
: undefined;
|
||||
if (!selectorContext) {
|
||||
return input?.context;
|
||||
}
|
||||
return { ...input?.context, ...selectorContext };
|
||||
}
|
||||
|
||||
private _handleValueChanged(ev) {
|
||||
const idx = ev.target.sampleIdx;
|
||||
this.data[idx] = {
|
||||
@@ -1157,7 +1069,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
max-width: 800px;
|
||||
margin: 16px auto;
|
||||
}
|
||||
.options ha-switch {
|
||||
.options ha-formfield {
|
||||
margin-right: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -102,13 +102,6 @@ const CONFIGS = [
|
||||
entity: "media_player.theater_off_static",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player Off (assumed state, cannot be switched on)",
|
||||
config: {
|
||||
type: "media-control",
|
||||
entity: "media_player.theater_off_assumed",
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Player On (cannot be switched off)",
|
||||
config: {
|
||||
|
||||
@@ -32,8 +32,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Bed Light",
|
||||
supported_color_modes: [LightColorMode.HS, LightColorMode.COLOR_TEMP],
|
||||
color_mode: LightColorMode.HS,
|
||||
hs_color: [210, 60],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -277,22 +275,6 @@ const CONFIGS = [
|
||||
features: [{ type: "light-brightness" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color feature with hue and saturation",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "light.bed_light",
|
||||
features: [{ type: "light-color", controls: "hue_saturation" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Light color temperature feature",
|
||||
config: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-select";
|
||||
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
|
||||
import "../../../../src/components/ha-switch";
|
||||
@@ -387,12 +388,13 @@ export class DemoMiscCloudAccount
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
>${label}</ha-switch
|
||||
>
|
||||
<ha-formfield .label=${label}>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._switchChanged}
|
||||
></ha-switch>
|
||||
</ha-formfield>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import "../../../../src/components/ha-formfield";
|
||||
import "../../../../src/components/ha-switch";
|
||||
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -257,10 +258,12 @@ export class DemoIntegrationCard extends LitElement {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="filters">
|
||||
<ha-switch @change=${this._toggleCustomIntegration}
|
||||
>Custom Integration</ha-switch
|
||||
>
|
||||
<ha-switch @change=${this._toggleCloud}>Relies on cloud</ha-switch>
|
||||
<ha-formfield label="Custom Integration">
|
||||
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
|
||||
</ha-formfield>
|
||||
<ha-formfield label="Relies on cloud">
|
||||
<ha-switch @change=${this._toggleCloud}></ha-switch>
|
||||
</ha-formfield>
|
||||
</div>
|
||||
|
||||
<ha-ignored-config-entry-card
|
||||
@@ -290,6 +293,7 @@ export class DemoIntegrationCard extends LitElement {
|
||||
class=${classMap({
|
||||
highlight: info.highlight !== undefined,
|
||||
})}
|
||||
.hass=${this.hass}
|
||||
domain="esphome"
|
||||
.items=${info.items}
|
||||
.manifest=${createManifest(
|
||||
@@ -312,6 +316,7 @@ export class DemoIntegrationCard extends LitElement {
|
||||
<!-- One that is standalone to see how it increases height if height
|
||||
not defined by other cards. -->
|
||||
<ha-integration-card
|
||||
.hass=${this.hass}
|
||||
domain="esphome"
|
||||
.items=${[
|
||||
loadedEntry,
|
||||
@@ -364,9 +369,9 @@ export class DemoIntegrationCard extends LitElement {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
ha-switch {
|
||||
ha-formfield {
|
||||
margin: 8px 0;
|
||||
display: flex;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
"format:eslint": "eslint \"**/src/**/*.{js,ts,html}\" --cache --cache-strategy=content --cache-location=node_modules/.cache/eslint/.eslintcache --ignore-pattern=.gitignore --fix",
|
||||
"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:types": "node ./node_modules/@typescript/native/bin/tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/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",
|
||||
"format": "pnpm run format:eslint && pnpm run format:prettier",
|
||||
@@ -40,20 +39,20 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@codemirror/view": "6.43.13",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.1",
|
||||
"@formatjs/intl-datetimeformat": "7.8.0",
|
||||
"@formatjs/intl-displaynames": "7.3.15",
|
||||
"@formatjs/intl-durationformat": "0.11.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
@@ -68,33 +67,36 @@
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.0",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@lezer/common": "1.5.3",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lezer/highlight": "1.2.4",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-formfield": "0.27.0",
|
||||
"@material/mwc-list": "0.27.0",
|
||||
"@material/web": "2.5.0",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@versatiles/style": "6.0.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.50.0",
|
||||
"cropperjs": "1.7.0",
|
||||
"cropperjs": "1.6.3",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
@@ -106,17 +108,17 @@
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.4.3",
|
||||
"intl-messageformat": "12.1.2",
|
||||
"js-yaml": "5.4.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.0",
|
||||
"maplibre-gl": "6.11.2",
|
||||
"marked": "18.0.14",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"nanoid": "6.0.1",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
"punycode": "2.3.1",
|
||||
@@ -124,10 +126,10 @@
|
||||
"qrcode": "1.5.4",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"rrule": "2.8.1",
|
||||
"sortablejs": "1.15.7",
|
||||
"sortablejs": "1.15.6",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "4.0.1",
|
||||
"tinykeys": "4.0.0",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
@@ -137,25 +139,26 @@
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.5"
|
||||
"zxing-wasm": "3.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/core": "8.0.6",
|
||||
"@babel/helper-compilation-targets": "8.0.6",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@babel/preset-env": "8.0.6",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.66.1",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.7",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -167,20 +170,19 @@
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/luxon": "3.7.5",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"@vitest/coverage-v8": "5.0.2",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.3",
|
||||
"browserslist": "4.29.1",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.50.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.12.0",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.11.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
@@ -192,13 +194,13 @@
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.13.0",
|
||||
"globals": "17.12.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.2",
|
||||
"jsdom": "30.1.1",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -209,7 +211,7 @@
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.2.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.10",
|
||||
"prettier": "3.9.9",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.1.0",
|
||||
@@ -217,15 +219,15 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"typescript-eslint": "8.70.1",
|
||||
"vite": "8.3.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.4",
|
||||
"vitest": "5.0.2",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@12.11.2",
|
||||
"packageManager": "[email protected]8.0",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
|
||||
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
|
||||
--- a/mwc-formfield-base.js
|
||||
+++ b/mwc-formfield-base.js
|
||||
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
|
||||
import { FormElement } from '@material/mwc-base/form-element.js';
|
||||
import { observer } from '@material/mwc-base/observer.js';
|
||||
import { html } from 'lit';
|
||||
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
|
||||
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
export class FormfieldBase extends BaseElement {
|
||||
constructor() {
|
||||
@@ -96,7 +96,7 @@ __decorate([
|
||||
query('.mdc-form-field')
|
||||
], FormfieldBase.prototype, "mdcRoot", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '*')
|
||||
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
|
||||
], FormfieldBase.prototype, "slottedInputs", void 0);
|
||||
__decorate([
|
||||
query('label')
|
||||
@@ -4,9 +4,8 @@ autoInstallPeers: false
|
||||
# Supply-chain protection
|
||||
minimumReleaseAge: 4320 # 3 days, in minutes
|
||||
minimumReleaseAgeExclude:
|
||||
# Our own packages, published from our own CI. Keep in sync with renovate.json
|
||||
# Our own packages, published from our own CI
|
||||
- home-assistant-js-websocket
|
||||
- "@home-assistant/*"
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyExclude:
|
||||
# Backports published after provenance-signed 7.x releases
|
||||
@@ -30,8 +29,9 @@ overrides:
|
||||
tslib: 2.8.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected].7: patches/[email protected].7.patch
|
||||
[email protected].1: patches/[email protected].1.patch
|
||||
[email protected].6: patches/[email protected].6.patch
|
||||
[email protected].0: patches/[email protected].0.patch
|
||||
[email protected]: patches/[email protected]
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
|
||||
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
|
||||
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 691 B |
@@ -1,9 +0,0 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
|
||||
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
|
||||
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 693 B |
@@ -1,7 +0,0 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 478 B |
@@ -1,7 +0,0 @@
|
||||
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
|
||||
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 482 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,6 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 223 KiB After Width: | Height: | Size: 223 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260930.0"
|
||||
version = "20260930.4"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||