@droans/ha-components 0.1.0 → 0.2.0-b.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,12 +7,15 @@ on:
7
7
  release:
8
8
  types:
9
9
  - published
10
+ branch:
11
+ - main
10
12
  permissions:
11
13
  id-token: write
12
14
  contents: read
13
15
 
14
16
  jobs:
15
17
  publish:
18
+ if: contains(github.event.release.target_commitish, 'main')
16
19
  runs-on: ubuntu-latest
17
20
  steps:
18
21
  - uses: actions/checkout@v4
@@ -21,4 +24,15 @@ jobs:
21
24
  with:
22
25
  node-version: '24'
23
26
  registry-url: 'https://registry.npmjs.org'
24
- - run: npm publish
27
+ - run: npm publish
28
+ publish-prerelease:
29
+ runs-on: ubuntu-latest
30
+ if: contains(github.event.release.target_commitish, 'dev')
31
+ steps:
32
+ - uses: actions/checkout@v4
33
+
34
+ - uses: actions/setup-node@v4
35
+ with:
36
+ node-version: '24'
37
+ registry-url: 'https://registry.npmjs.org'
38
+ - run: npm publish --tag beta
package/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # ha-components
2
2
 
3
- A library of HA components. For reusability, none of the components are exported and must be redefined by the developer.
3
+ A library of HA components. For reusability, the components are only redefined if they have not yet been defined as a custom element.
4
4
 
5
5
  Elements:
6
- * marquee-text
6
+ * ha-control-select-menu (`hac-control-select-menu`)
7
+ * ha-icon (`hac-icon`)
8
+ * ha-marquee-text (`hac-marquee-text`)
9
+ * ha-svg-icon (`hac-svg-icon`)
package/eslint.config.mjs CHANGED
@@ -32,7 +32,9 @@ export default tseslint.config(
32
32
  globals: {
33
33
  ...globals.browser,
34
34
  ...globals.node,
35
+ __SUPERVISOR__: false,
35
36
  },
37
+ // parser: tseslint.parser,
36
38
  },
37
39
  },
38
40
  // individual rule overrides
package/globals.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ // declare global {
2
+ // interface Window {
3
+ // __SUPERVISOR__: boolean;
4
+ // }
5
+ // }
6
+
7
+ declare global {
8
+ let __SUPERVISOR__: boolean;
9
+ }
10
+
11
+ declare let __SUPERVISOR__: boolean;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@droans/ha-components",
3
3
  "description": "Home Assistant Built-In Components re-exported for reuse",
4
- "repository": "https://github.com/droans/mass-queue-types",
4
+ "repository": "https://github.com/droans/ha-components.git",
5
5
  "author": "Michael Carroll",
6
- "version": "0.1.0",
6
+ "version": "0.2.0-b.3",
7
7
  "keywords": [
8
8
  "home-assistant",
9
9
  "homeassistant",
@@ -18,6 +18,7 @@
18
18
  "type": "module",
19
19
  "devDependencies": {
20
20
  "@eslint/js": "9.39.1",
21
+ "@material/mwc-select": "0.27.0",
21
22
  "@rollup/plugin-json": "^6.0.0",
22
23
  "@rollup/plugin-node-resolve": "^16.0.3",
23
24
  "@rollup/plugin-terser": "^0.4.0",
@@ -28,6 +29,8 @@
28
29
  "eslint-config-prettier": "10.1.8",
29
30
  "eslint-plugin-jest": "29.1.0",
30
31
  "eslint-plugin-wc": "^3.0.1",
32
+ "idb-keyval": "6.2.2",
33
+ "memoize-one": "6.0.0",
31
34
  "prettier": "^3.6.2",
32
35
  "rollup": "^4.53.2",
33
36
  "rollup-plugin-commonjs": "^10.1.0",
@@ -0,0 +1,31 @@
1
+ // From: https://davidwalsh.name/javascript-debounce-function
2
+
3
+ // Returns a function, that, as long as it continues to be invoked, will not
4
+ // be triggered. The function will be called after it stops being called for
5
+ // N milliseconds. If `immediate` is passed, trigger the function on the
6
+ // leading edge and on the trailing.
7
+
8
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
9
+ export const debounce = <T extends any[]>(
10
+ func: (...args: T) => void,
11
+ wait: number,
12
+ immediate = false
13
+ ) => {
14
+ let timeout: number | undefined;
15
+ const debouncedFunc = (...args: T): void => {
16
+ const later = () => {
17
+ timeout = undefined;
18
+ func(...args);
19
+ };
20
+ const callNow = immediate && !timeout;
21
+ clearTimeout(timeout);
22
+ timeout = window.setTimeout(later, wait);
23
+ if (callNow) {
24
+ func(...args);
25
+ }
26
+ };
27
+ debouncedFunc.cancel = () => {
28
+ clearTimeout(timeout);
29
+ };
30
+ return debouncedFunc;
31
+ };
@@ -0,0 +1,35 @@
1
+ /* eslint-disable */
2
+ declare global {
3
+ interface ErrorConstructor {
4
+ captureStackTrace(thisArg: any, func: any): void
5
+ }
6
+ }
7
+
8
+ class TimeoutError extends Error {
9
+ public timeout: number;
10
+
11
+ constructor(timeout: number, ...params) {
12
+ super(...params);
13
+
14
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, TimeoutError);
17
+ }
18
+
19
+ this.name = "TimeoutError";
20
+ // Custom debugging information
21
+ this.timeout = timeout;
22
+ this.message = `Timed out in ${timeout} ms.`;
23
+ }
24
+ }
25
+
26
+ export const promiseTimeout = (ms: number, promise: Promise<any> | any) => {
27
+ const timeout = new Promise((_resolve, reject) => {
28
+ setTimeout(() => {
29
+ reject(new TimeoutError(ms));
30
+ }, ms);
31
+ });
32
+
33
+ // Returns a race between our timeout and the passed in promise
34
+ return Promise.race([promise, timeout]);
35
+ };
@@ -0,0 +1,8 @@
1
+ export const afterNextRender = (cb: (value: unknown) => void): void => {
2
+ requestAnimationFrame(() => setTimeout(cb, 0));
3
+ };
4
+
5
+ export const nextRender = () =>
6
+ new Promise((resolve) => {
7
+ afterNextRender(resolve);
8
+ });
@@ -0,0 +1,280 @@
1
+ /* eslint-disable @typescript-eslint/no-misused-promises */
2
+ /* eslint-disable @typescript-eslint/unbound-method */
3
+ import { SelectBase } from "@material/mwc-select/mwc-select-base";
4
+ import { mdiMenuDown } from "@mdi/js";
5
+ import type { PropertyValues } from "lit";
6
+ import type { CSSResultGroup } from '@material/mwc-base/node_modules/@lit/reactive-element/css-tag';
7
+ import { css } from "@material/mwc-base/node_modules/lit";
8
+ import { html, nothing } from "lit";
9
+ import { property, query } from "lit/decorators";
10
+ import { classMap } from "lit/directives/class-map";
11
+ import { ifDefined } from "lit/directives/if-defined";
12
+ import { debounce } from "../common/util/debounce";
13
+ import { nextRender } from "../common/util/render-status";
14
+ import "./ha-icon";
15
+ import type { HaIcon } from "./hac-icon";
16
+ import "./ha-ripple";
17
+ import "./ha-svg-icon";
18
+ import type { HaSvgIcon } from "./hac-svg-icon";
19
+ import "./ha-menu";
20
+ import { customElementOverride } from "../../utils/decorators.js";
21
+
22
+ @customElementOverride("hac-control-select-menu")
23
+ export class HaControlSelectMenu extends SelectBase {
24
+ @query(".select") protected mdcRoot!: HTMLElement;
25
+
26
+ @query(".select-anchor") protected anchorElement!: HTMLDivElement | null;
27
+
28
+ @property({ type: Boolean, attribute: "show-arrow" })
29
+ public showArrow = false;
30
+
31
+ @property({ type: Boolean, attribute: "hide-label" })
32
+ public hideLabel = false;
33
+
34
+ @property() public options;
35
+
36
+ protected updated(changedProps: PropertyValues) {
37
+ super.updated(changedProps);
38
+ if (changedProps.get("options")) {
39
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
40
+ this.layoutOptions();
41
+ this.selectByValue(this.value);
42
+ }
43
+ }
44
+
45
+ public override render() {
46
+ const classes = {
47
+ "select-disabled": this.disabled,
48
+ "select-required": this.required,
49
+ "select-invalid": !this.isUiValid,
50
+ "select-no-value": !this.selectedText,
51
+ };
52
+
53
+ const labelledby = this.label && !this.hideLabel ? "label" : undefined;
54
+ const labelAttribute =
55
+ this.label && this.hideLabel ? this.label : undefined;
56
+
57
+ return html`
58
+ <div class="select ${classMap(classes)}">
59
+ <input
60
+ class="formElement"
61
+ .name=${this.name}
62
+ .value=${this.value}
63
+ hidden
64
+ ?disabled=${this.disabled}
65
+ ?required=${this.required}
66
+ />
67
+ <!-- @ts-ignore -->
68
+ <div
69
+ class="select-anchor"
70
+ aria-autocomplete="none"
71
+ role="combobox"
72
+ aria-expanded=${this.menuOpen}
73
+ aria-invalid=${!this.isUiValid}
74
+ aria-haspopup="listbox"
75
+ aria-labelledby=${ifDefined(labelledby)}
76
+ aria-label=${ifDefined(labelAttribute)}
77
+ aria-required=${this.required}
78
+ aria-controls="listbox"
79
+ @focus=${this.onFocus}
80
+ @blur=${this.onBlur}
81
+ @click=${this.onClick}
82
+ @keydown=${this.onKeydown}
83
+ >
84
+ ${this._renderIcon()}
85
+ <div class="content">
86
+ ${this.hideLabel
87
+ ? nothing
88
+ : html`<p id="label" class="label">${this.label}</p>`}
89
+ ${this.selectedText
90
+ ? html`<p class="value">${this.selectedText}</p>`
91
+ : nothing}
92
+ </div>
93
+ ${this._renderArrow()}
94
+ <ha-ripple .disabled=${this.disabled}></ha-ripple>
95
+ </div>
96
+ ${this.renderMenu()}
97
+ </div>
98
+ `;
99
+ }
100
+
101
+ protected override renderMenu() {
102
+ const classes = this.getMenuClasses();
103
+ return html`<ha-menu
104
+ innerRole="listbox"
105
+ wrapFocus
106
+ class=${classMap(classes)}
107
+ activatable
108
+ .fullwidth=${this.fixedMenuPosition ? false : !this.naturalMenuWidth}
109
+ .open=${this.menuOpen}
110
+ .anchor=${this.anchorElement}
111
+ .fixed=${this.fixedMenuPosition}
112
+ @selected=${this.onSelected}
113
+ @opened=${this.onOpened}
114
+ @closed=${this.onClosed}
115
+ @items-updated=${this.onItemsUpdated}
116
+ @keydown=${this.handleTypeahead}
117
+ >
118
+ ${this.renderMenuContent()}
119
+ </ha-menu>`;
120
+ }
121
+
122
+ private _renderArrow() {
123
+ if (!this.showArrow) return nothing;
124
+
125
+ return html`
126
+ <div class="icon">
127
+ <ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
128
+ </div>
129
+ `;
130
+ }
131
+
132
+ private _renderIcon() {
133
+ const index = this.mdcFoundation?.getSelectedIndex();
134
+ const items = this.menuElement?.items ?? [];
135
+ const item = index != null ? items[index] : undefined;
136
+ const defaultIcon = this.querySelector("[slot='icon']");
137
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
138
+ const icon = (item?.querySelector("[slot='graphic']") ?? null) as
139
+ | HaSvgIcon
140
+ | HaIcon
141
+ | null;
142
+
143
+ if (!defaultIcon && !icon) {
144
+ return null;
145
+ }
146
+
147
+ return html`
148
+ <div class="icon">
149
+ ${icon && icon.localName === "ha-svg-icon" && "path" in icon
150
+ ? html`<ha-svg-icon .path=${icon.path}></ha-svg-icon>`
151
+ : icon && icon.localName === "ha-icon" && "icon" in icon
152
+ ? html`<ha-icon .path=${icon.icon}></ha-icon>`
153
+ : html`<slot name="icon"></slot>`}
154
+ </div>
155
+ `;
156
+ }
157
+
158
+ connectedCallback() {
159
+ super.connectedCallback();
160
+ window.addEventListener("translations-updated", this._translationsUpdated);
161
+ }
162
+
163
+ disconnectedCallback() {
164
+ super.disconnectedCallback();
165
+ window.removeEventListener(
166
+ "translations-updated",
167
+ this._translationsUpdated
168
+ );
169
+ }
170
+
171
+ private _translationsUpdated = debounce(async () => {
172
+ await nextRender();
173
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
174
+ this.layoutOptions();
175
+ }, 500);
176
+
177
+ static override styles: CSSResultGroup =
178
+ css`
179
+ :host {
180
+ display: inline-block;
181
+ --control-select-menu-focus-color: var(--secondary-text-color);
182
+ --control-select-menu-text-color: var(--primary-text-color);
183
+ --control-select-menu-background-color: var(--disabled-color);
184
+ --control-select-menu-background-opacity: 0.2;
185
+ --control-select-menu-border-radius: var(--ha-border-radius-lg);
186
+ --control-select-menu-height: 48px;
187
+ --control-select-menu-padding: 6px 10px;
188
+ --mdc-icon-size: 20px;
189
+ --ha-ripple-color: var(--secondary-text-color);
190
+ font-size: var(--ha-font-size-m);
191
+ line-height: 1.4;
192
+ width: auto;
193
+ color: var(--primary-text-color);
194
+ -webkit-tap-highlight-color: transparent;
195
+ }
196
+ .select-anchor {
197
+ height: var(--control-select-menu-height);
198
+ padding: var(--control-select-menu-padding);
199
+ overflow: hidden;
200
+ position: relative;
201
+ cursor: pointer;
202
+ display: flex;
203
+ flex-direction: row;
204
+ align-items: center;
205
+ border-radius: var(--control-select-menu-border-radius);
206
+ box-sizing: border-box;
207
+ outline: none;
208
+ overflow: hidden;
209
+ background: none;
210
+ /* For safari border-radius overflow */
211
+ z-index: 0;
212
+ transition:
213
+ box-shadow 180ms ease-in-out,
214
+ color 180ms ease-in-out;
215
+ gap: 10px;
216
+ width: 100%;
217
+ user-select: none;
218
+ font-style: normal;
219
+ font-weight: var(--ha-font-weight-normal);
220
+ letter-spacing: 0.25px;
221
+ }
222
+ .content {
223
+ display: flex;
224
+ flex-direction: column;
225
+ align-items: flex-start;
226
+ justify-content: center;
227
+ flex: 1;
228
+ overflow: hidden;
229
+ }
230
+
231
+ .content p {
232
+ overflow: hidden;
233
+ white-space: nowrap;
234
+ text-overflow: ellipsis;
235
+ min-width: 0;
236
+ width: 100%;
237
+ margin: auto;
238
+ }
239
+
240
+ .label {
241
+ font-size: 0.85em;
242
+ letter-spacing: 0.4px;
243
+ }
244
+
245
+ .select-no-value .label {
246
+ font-size: inherit;
247
+ line-height: inherit;
248
+ letter-spacing: inherit;
249
+ }
250
+
251
+ .select-anchor:focus-visible {
252
+ box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
253
+ }
254
+
255
+ .select-anchor::before {
256
+ content: "";
257
+ position: absolute;
258
+ top: 0;
259
+ left: 0;
260
+ height: 100%;
261
+ width: 100%;
262
+ background-color: var(--control-select-menu-background-color);
263
+ transition:
264
+ background-color 180ms ease-in-out,
265
+ opacity 180ms ease-in-out;
266
+ opacity: var(--control-select-menu-background-opacity);
267
+ }
268
+
269
+ .select-disabled .select-anchor {
270
+ cursor: not-allowed;
271
+ color: var(--disabled-color);
272
+ }
273
+ `;
274
+ }
275
+
276
+ declare global {
277
+ interface HTMLElementTagNameMap {
278
+ "ha-control-select-menu": HaControlSelectMenu;
279
+ }
280
+ }
@@ -0,0 +1,199 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
2
+ /* eslint-disable @typescript-eslint/no-floating-promises */
3
+ import type { PropertyValues } from "lit";
4
+ import { LitElement, css, html, nothing } from "lit";
5
+ import { property, state } from "lit/decorators";
6
+ import { debounce } from "../common/util/debounce";
7
+ import type { CustomIcon } from "../data/custom_icons";
8
+ import { customIcons } from "../data/custom_icons";
9
+ import type { Chunks, Icons } from "../data/iconsets";
10
+ import {
11
+ MDI_PREFIXES,
12
+ findIconChunk,
13
+ getIcon,
14
+ writeCache,
15
+ } from "../data/iconsets";
16
+ import "./ha-svg-icon";
17
+ import { customElementOverride } from "../../utils/decorators.js";
18
+
19
+ type DeprecatedIcon = Record<
20
+ string,
21
+ {
22
+ removeIn: string;
23
+ newName?: string;
24
+ }
25
+ >;
26
+
27
+ const mdiDeprecatedIcons: DeprecatedIcon = {};
28
+
29
+ const chunks: Chunks = {};
30
+
31
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
32
+ const debouncedWriteCache = debounce(() => writeCache(chunks), 2000);
33
+
34
+ const cachedIcons: Record<string, string> = {};
35
+
36
+ @customElementOverride("hac-icon")
37
+ export class HaIcon extends LitElement {
38
+ @property() public icon?: string;
39
+
40
+ @state() private _path?: string;
41
+
42
+ @state() private _secondaryPath?: string;
43
+
44
+ @state() private _viewBox?: string;
45
+
46
+ @state() private _legacy = false;
47
+
48
+ public willUpdate(changedProps: PropertyValues) {
49
+ super.willUpdate(changedProps);
50
+ if (changedProps.has("icon")) {
51
+ this._path = undefined;
52
+ this._secondaryPath = undefined;
53
+ this._viewBox = undefined;
54
+ this._loadIcon();
55
+ }
56
+ }
57
+
58
+ protected render() {
59
+ if (!this.icon) {
60
+ return nothing;
61
+ }
62
+ if (this._legacy) {
63
+ return html`<!-- @ts-ignore we don't provide the iron-icon element -->
64
+ <iron-icon .icon=${this.icon}></iron-icon>`;
65
+ }
66
+ return html`<ha-svg-icon
67
+ .path=${this._path}
68
+ .secondaryPath=${this._secondaryPath}
69
+ .viewBox=${this._viewBox}
70
+ ></ha-svg-icon>`;
71
+ }
72
+
73
+ private async _loadIcon() {
74
+ if (!this.icon) {
75
+ return;
76
+ }
77
+ const requestedIcon = this.icon;
78
+ const [iconPrefix, origIconName] = this.icon.split(":", 2);
79
+
80
+ let iconName = origIconName;
81
+
82
+ if (!iconPrefix || !iconName) {
83
+ return;
84
+ }
85
+
86
+ if (!MDI_PREFIXES.includes(iconPrefix)) {
87
+ const customIcon = customIcons[iconPrefix];
88
+ if (customIcon) {
89
+ if (customIcon && typeof customIcon.getIcon === "function") {
90
+ this._setCustomPath(customIcon.getIcon(iconName), requestedIcon);
91
+ }
92
+ return;
93
+ }
94
+ this._legacy = true;
95
+ return;
96
+ }
97
+
98
+ this._legacy = false;
99
+
100
+ if (iconName in mdiDeprecatedIcons) {
101
+ const deprecatedIcon = mdiDeprecatedIcons[iconName];
102
+ let message: string;
103
+
104
+ if (deprecatedIcon.newName) {
105
+ message = `Icon ${iconPrefix}:${iconName} was renamed to ${iconPrefix}:${deprecatedIcon.newName}, please change your config, it will be removed in version ${deprecatedIcon.removeIn}.`;
106
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
107
+ iconName = deprecatedIcon.newName!;
108
+ } else {
109
+ message = `Icon ${iconPrefix}:${iconName} was removed from MDI, please replace this icon with an other icon in your config, it will be removed in version ${deprecatedIcon.removeIn}.`;
110
+ }
111
+ // eslint-disable-next-line no-console
112
+ console.warn(message);
113
+ }
114
+
115
+ if (iconName in cachedIcons) {
116
+ this._path = cachedIcons[iconName];
117
+ return;
118
+ }
119
+
120
+ if (iconName === "home-assistant") {
121
+ const icon = (await import("../resources/home-assistant-logo-svg"))
122
+ .mdiHomeAssistant;
123
+
124
+ if (this.icon === requestedIcon) {
125
+ this._path = icon;
126
+ }
127
+ cachedIcons[iconName] = icon;
128
+ return;
129
+ }
130
+
131
+ let databaseIcon: string | undefined;
132
+ try {
133
+ databaseIcon = await getIcon(iconName);
134
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
135
+ } catch (_err) {
136
+ // Firefox in private mode doesn't support IDB
137
+ // iOS Safari sometimes doesn't open the DB
138
+ databaseIcon = undefined;
139
+ }
140
+
141
+ if (databaseIcon) {
142
+ if (this.icon === requestedIcon) {
143
+ this._path = databaseIcon;
144
+ }
145
+ cachedIcons[iconName] = databaseIcon;
146
+ return;
147
+ }
148
+ const chunk = findIconChunk(iconName);
149
+
150
+ if (chunk in chunks) {
151
+ this._setPath(chunks[chunk], iconName, requestedIcon);
152
+ return;
153
+ }
154
+
155
+ const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
156
+ response.json()
157
+ );
158
+ chunks[chunk] = iconPromise;
159
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
160
+ this._setPath(iconPromise, iconName, requestedIcon);
161
+ debouncedWriteCache();
162
+ }
163
+
164
+ private async _setCustomPath(
165
+ promise: Promise<CustomIcon>,
166
+ requestedIcon: string
167
+ ) {
168
+ const icon = await promise;
169
+ if (this.icon !== requestedIcon) {
170
+ return;
171
+ }
172
+ this._path = icon.path;
173
+ this._secondaryPath = icon.secondaryPath;
174
+ this._viewBox = icon.viewBox;
175
+ }
176
+
177
+ private async _setPath(
178
+ promise: Promise<Icons>,
179
+ iconName: string,
180
+ requestedIcon: string
181
+ ) {
182
+ const iconPack = await promise;
183
+ if (this.icon === requestedIcon) {
184
+ this._path = iconPack[iconName];
185
+ }
186
+ cachedIcons[iconName] = iconPack[iconName];
187
+ }
188
+
189
+ static styles = css`
190
+ :host {
191
+ fill: currentcolor;
192
+ }
193
+ `;
194
+ }
195
+ declare global {
196
+ interface HTMLElementTagNameMap {
197
+ "ha-icon": HaIcon;
198
+ }
199
+ }
@@ -6,7 +6,9 @@ import {
6
6
  type PropertyValues,
7
7
  } from "lit";
8
8
  import { property, query } from "lit/decorators.js";
9
+ import { customElementOverride } from "../../utils/decorators.js";
9
10
 
11
+ @customElementOverride("hac-marquee-text")
10
12
  export class MarqueeText extends LitElement {
11
13
  @property({ type: Number }) speed = 15; // pixels per second
12
14
 
@@ -0,0 +1,69 @@
1
+ import type { SVGTemplateResult } from "lit";
2
+ import { css, LitElement, nothing, svg } from "lit";
3
+ import { customElement, property } from "lit/decorators";
4
+
5
+ @customElement("ha-svg-icon")
6
+ export class HaSvgIcon extends LitElement {
7
+ @property() public path?: string;
8
+
9
+ @property({ attribute: false }) public secondaryPath?: string;
10
+
11
+ @property({ attribute: false }) public viewBox?: string;
12
+
13
+ protected render(): SVGTemplateResult {
14
+ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
15
+ return svg`
16
+ <svg
17
+ viewBox=${this.viewBox || "0 0 24 24"}
18
+ preserveAspectRatio="xMidYMid meet"
19
+ focusable="false"
20
+ role="img"
21
+ aria-hidden="true"
22
+ >
23
+ <g>
24
+ ${
25
+ this.path
26
+ ? svg`<path class="primary-path" d=${this.path}></path>`
27
+ : nothing
28
+ }
29
+ ${
30
+ this.secondaryPath
31
+ ? svg`<path class="secondary-path" d=${this.secondaryPath}></path>`
32
+ : nothing
33
+ }
34
+ </g>
35
+ </svg>`;
36
+ /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
37
+ }
38
+
39
+ static styles = css`
40
+ :host {
41
+ display: var(--ha-icon-display, inline-flex);
42
+ align-items: center;
43
+ justify-content: center;
44
+ position: relative;
45
+ vertical-align: middle;
46
+ fill: var(--icon-primary-color, currentcolor);
47
+ width: var(--mdc-icon-size, 24px);
48
+ height: var(--mdc-icon-size, 24px);
49
+ }
50
+ svg {
51
+ width: 100%;
52
+ height: 100%;
53
+ pointer-events: none;
54
+ display: block;
55
+ }
56
+ path.primary-path {
57
+ opacity: var(--icon-primary-opactity, 1);
58
+ }
59
+ path.secondary-path {
60
+ fill: var(--icon-secondary-color, currentcolor);
61
+ opacity: var(--icon-secondary-opactity, 0.5);
62
+ }
63
+ `;
64
+ }
65
+ declare global {
66
+ interface HTMLElementTagNameMap {
67
+ "ha-svg-icon": HaSvgIcon;
68
+ }
69
+ }
@@ -0,0 +1,39 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+ import { customIconsets } from "./custom_iconsets";
3
+
4
+ export interface CustomIcon {
5
+ path: string;
6
+ secondaryPath?: string;
7
+ viewBox?: string;
8
+ }
9
+
10
+ export interface CustomIconListItem {
11
+ name: string;
12
+ keywords?: string[];
13
+ }
14
+
15
+ export interface CustomIconHelpers {
16
+ getIcon: (name: string) => Promise<CustomIcon>;
17
+ getIconList?: () => Promise<CustomIconListItem[]>;
18
+ }
19
+
20
+ export interface CustomIconsWindow {
21
+ customIcons?: Record<string, CustomIconHelpers>;
22
+ }
23
+
24
+ const customIconsWindow = window as CustomIconsWindow;
25
+
26
+ if (!("customIcons" in customIconsWindow)) {
27
+ customIconsWindow.customIcons = {};
28
+ }
29
+
30
+ // Proxy for backward compatibility with icon sets
31
+ export const customIcons = new Proxy(customIconsWindow.customIcons!, {
32
+ get: (obj, prop: string) =>
33
+ obj[prop] ??
34
+ (customIconsets[prop]
35
+ ? {
36
+ getIcon: customIconsets[prop],
37
+ }
38
+ : undefined),
39
+ });
@@ -0,0 +1,14 @@
1
+ import type { CustomIcon } from "./custom_icons";
2
+
3
+ interface CustomIconsetsWindow {
4
+ customIconsets?: Record<string, (name: string) => Promise<CustomIcon>>;
5
+ }
6
+
7
+ const customIconsetsWindow = window as CustomIconsetsWindow;
8
+
9
+ if (!("customIconsets" in customIconsetsWindow)) {
10
+ customIconsetsWindow.customIconsets = {};
11
+ }
12
+
13
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
14
+ export const customIconsets = customIconsetsWindow.customIconsets!;
@@ -0,0 +1,107 @@
1
+ /* eslint-disable @typescript-eslint/use-unknown-in-catch-callback-variable */
2
+ /* eslint-disable @typescript-eslint/no-floating-promises */
3
+ /* eslint-disable @typescript-eslint/no-confusing-void-expression */
4
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment */
5
+ declare global {
6
+ interface Window {
7
+ __SUPERVISOR__: boolean;
8
+ }
9
+ }
10
+
11
+ import { clear, get, set, createStore, promisifyRequest } from "idb-keyval";
12
+ import memoizeOne from "memoize-one";
13
+ import { promiseTimeout } from "../common/util/promise-timeout";
14
+ import { iconMetadata } from "../resources/icon-metadata";
15
+ import type { IconMeta } from "../types";
16
+
17
+ export type Icons = Record<string, string>;
18
+
19
+ export type Chunks = Record<string, Promise<Icons>>;
20
+
21
+ const getStore = memoizeOne(async () => {
22
+ const iconStore = createStore("hass-icon-db", "mdi-icon-store");
23
+
24
+ // Supervisor doesn't use icons, and should not update/downgrade the icon DB.
25
+ if (!__SUPERVISOR__) {
26
+ const version = await get("_version", iconStore);
27
+
28
+ if (!version) {
29
+ set("_version", iconMetadata.version, iconStore);
30
+ } else if (version !== iconMetadata.version) {
31
+ await clear(iconStore);
32
+ set("_version", iconMetadata.version, iconStore);
33
+ }
34
+ }
35
+
36
+ return iconStore;
37
+ });
38
+
39
+ export const MDI_PREFIXES = ["mdi", "hass", "hassio", "hademo"];
40
+
41
+ let toRead: [
42
+ string,
43
+ (iconPath: string | undefined) => void,
44
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
+ (e: any) => void,
46
+ ][] = [];
47
+
48
+ // Queue up as many icon fetches in 1 transaction
49
+ export const getIcon = (iconName: string) =>
50
+ new Promise<string | undefined>((resolve, reject) => {
51
+ toRead.push([iconName, resolve, reject]);
52
+
53
+ if (toRead.length > 1) {
54
+ return;
55
+ }
56
+
57
+ // Start initializing the store, so it's ready when we need it
58
+ const iconStoreProm = getStore();
59
+ const readIcons = async () => {
60
+ const iconStore = await iconStoreProm;
61
+ iconStore("readonly", (store) => {
62
+ for (const [iconName_, resolve_, reject_] of toRead) {
63
+ promisifyRequest<string | undefined>(store.get(iconName_))
64
+ .then((icon) => resolve_(icon))
65
+ .catch((e) => reject_(e));
66
+ }
67
+ toRead = [];
68
+ });
69
+ };
70
+
71
+ promiseTimeout(1000, readIcons()).catch((e) => {
72
+ // Firefox in private mode doesn't support IDB
73
+ // Safari sometime doesn't open the DB so we time out
74
+ for (const [, , reject_] of toRead) {
75
+ reject_(e);
76
+ }
77
+ toRead = [];
78
+ });
79
+ });
80
+
81
+ export const findIconChunk = (icon: string): string => {
82
+ let lastChunk: IconMeta;
83
+ for (const chunk of iconMetadata.parts) {
84
+ if (chunk.start !== undefined && icon < chunk.start) {
85
+ break;
86
+ }
87
+ lastChunk = chunk;
88
+ }
89
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
90
+ return lastChunk!.file;
91
+ };
92
+
93
+ export const writeCache = async (chunks: Chunks) => {
94
+ const keys = Object.keys(chunks);
95
+ const iconsSets: Icons[] = await Promise.all(Object.values(chunks));
96
+ const iconStore = await getStore();
97
+ // We do a batch opening the store just once, for (considerable) performance
98
+ iconStore("readwrite", (store) => {
99
+ iconsSets.forEach((icons, idx) => {
100
+ Object.entries(icons).forEach(([name, path]) => {
101
+ store.put(path, name);
102
+ });
103
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
104
+ delete chunks[keys[idx]];
105
+ });
106
+ });
107
+ };
@@ -0,0 +1,2 @@
1
+ export const mdiHomeAssistant =
2
+ "m12.151 1.5882c-.3262 0-.6523.1291-.8996.3867l-8.3848 8.7354c-.0619.0644-.1223.1368-.1807.2154-.0588.0789-.1151.1638-.1688.2534-.2593.4325-.4552.9749-.5232 1.4555-.0026.018-.0076.0369-.0094.0548-.0121.0987-.0184.1944-.0184.2857v8.0124a1.2731 1.2731 0 001.2731 1.2731h7.8313l-3.4484-3.593a1.7399 1.7399 0 111.0803-1.125l2.6847 2.7972v-10.248a1.7399 1.7399 0 111.5276-0v7.187l2.6702-2.782a1.7399 1.7399 0 111.0566 1.1505l-3.7269 3.8831v2.7299h8.174a1.2471 1.2471 0 001.2471-1.2471v-8.0375c0-.0912-.0059-.1868-.0184-.2855-.0603-.4935-.2636-1.0617-.5326-1.5105-.0537-.0896-.1101-.1745-.1684-.253-.0588-.079-.1191-.1513-.181-.2158l-8.3848-8.7363c-.2473-.2577-.5735-.3866-.8995-.3864";
@@ -0,0 +1,5 @@
1
+ import * as iconMetadata_ from "./iconMetadata.json";
2
+ import type { IconMetaFile } from "../types";
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
5
+ export const iconMetadata = (iconMetadata_ as any).default as IconMetaFile;
@@ -0,0 +1 @@
1
+ {"version":"7.4.47","parts":[{"file":"7a7139d465f1f41cb26ab851a17caa21a9331234"},{"start":"account-supervisor-circle-","file":"9561286c4c1021d46b9006596812178190a7cc1c"},{"start":"alpha-r-c","file":"eb466b7087fb2b4d23376ea9bc86693c45c500fa"},{"start":"arrow-decision-o","file":"4b3c01b7e0723b702940c5ac46fb9e555646972b"},{"start":"baby-f","file":"2611401d85450b95ab448ad1d02c1a432b409ed2"},{"start":"battery-hi","file":"89bcd31855b34cd9d31ac693fb073277e74f1f6a"},{"start":"blur-r","file":"373709cd5d7e688c2addc9a6c5d26c2d57c02c48"},{"start":"briefcase-account-","file":"a75956cf812ee90ee4f656274426aafac81e1053"},{"start":"calendar-question-","file":"3253f2529b5ebdd110b411917bacfacb5b7063e6"},{"start":"car-lig","file":"74566af3501ad6ae58ad13a8b6921b3cc2ef879d"},{"start":"cellphone-co","file":"7677f1cfb2dd4f5562a2aa6d3ae43a2e6997b21a"},{"start":"circle-slice-2","file":"70d08c50ec4522dd75d11338db57846588263ee2"},{"start":"cloud-co","file":"141d2bfa55ca4c83f4bae2812a5da59a84fec4ff"},{"start":"cog-s","file":"5a640365f8e47c609005d5e098e0e8104286d120"},{"start":"cookie-l","file":"dd85b8eb8581b176d3acf75d1bd82e61ca1ba2fc"},{"start":"currency-eur-","file":"15362279f4ebfc3620ae55f79d2830ad86d5213e"},{"start":"delete-o","file":"239434ab8df61237277d7599ebe066c55806c274"},{"start":"draw-","file":"5605918a592070803ba2ad05a5aba06263da0d70"},{"start":"emoticon-po","file":"a838cfcec34323946237a9f18e66945f55260f78"},{"start":"fan","file":"effd56103b37a8c7f332e22de8e4d67a69b70db7"},{"start":"file-question-","file":"b2424b50bd465ae192593f1c3d086c5eec893af8"},{"start":"flask-off-","file":"3b76295cde006a18f0301dd98eed8c57e1d5a425"},{"start":"food-s","file":"1c6941474cbeb1755faaaf5771440577f4f1f9c6"},{"start":"gamepad-u","file":"c6efe18db6bc9654ae3540c7dee83218a5450263"},{"start":"google-f","file":"df341afe6ad4437457cf188499cb8d2df8ac7b9e"},{"start":"head-c","file":"282121c9e45ed67f033edcc1eafd279334c00f46"},{"start":"home-pl","file":"27e8e38fc7adcacf2a210802f27d841b49c8c508"},{"start":"inbox-","file":"0f0316ec7b1b7f7ce3eaabce26c9ef619b5a1694"},{"start":"key-v","file":"ea33462be7b953ff1eafc5dac2d166b210685a60"},{"start":"leaf-circle-","file":"33db9bbd66ce48a2db3e987fdbd37fb0482145a4"},{"start":"lock-p","file":"b89e27ed39e9d10c44259362a4b57f3c579d3ec8"},{"start":"message-s","file":"7b5ab5a5cadbe06e3113ec148f044aa701eac53a"},{"start":"moti","file":"01024d78c248d36805b565e343dd98033cc3bcaf"},{"start":"newspaper-variant-o","file":"22a6ec4a4fdd0a7c0acaf805f6127b38723c9189"},{"start":"on","file":"c73d55b412f394e64632e2011a59aa05e5a1f50d"},{"start":"paw-ou","file":"3f669bf26d16752dc4a9ea349492df93a13dcfbf"},{"start":"pigg","file":"0c24edb27eb1c90b6e33fc05f34ef3118fa94256"},{"start":"printer-pos-sy","file":"41a55cda866f90b99a64395c3bb18c14983dcf0a"},{"start":"read","file":"c7ed91552a3a64c9be88c85e807404cf705b7edf"},{"start":"robot-vacuum-variant-o","file":"917d2a35d7268c0ea9ad9ecab2778060e19d90e0"},{"start":"sees","file":"6e82d9861d8fac30102bafa212021b819f303bdb"},{"start":"shoe-f","file":"e2fe7ce02b5472301418cc90a0e631f187b9f238"},{"start":"snowflake-m","file":"a28ba9f5309090c8b49a27ca20ff582a944f6e71"},{"start":"st","file":"7e92d03f095ec27e137b708b879dfd273bd735ab"},{"start":"su","file":"61c74913720f9de59a379bdca37f1d2f0dc1f9db"},{"start":"tag-plus-","file":"8f3184156a4f38549cf4c4fffba73a6a941166ae"},{"start":"timer-a","file":"baab470d11cfb3a3cd3b063ee6503a77d12a80d0"},{"start":"transit-d","file":"8561c0d9b1ac03fab360fd8fe9729c96e8693239"},{"start":"vector-arrange-b","file":"c9a3439257d4bab33d3355f1f2e11842e8171141"},{"start":"water-ou","file":"02dbccfb8ca35f39b99f5a085b095fc1275005a0"},{"start":"webc","file":"57bafd4b97341f4f2ac20a609d023719f23a619c"},{"start":"zip","file":"65ae094e8263236fa50486584a08c03497a38d93"}]}
@@ -0,0 +1,10 @@
1
+
2
+ export interface IconMetaFile {
3
+ version: string;
4
+ parts: IconMeta[];
5
+ }
6
+
7
+ export interface IconMeta {
8
+ start: string;
9
+ file: string;
10
+ }
@@ -0,0 +1,40 @@
1
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
2
+ type Constructor<T> = {
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/prefer-function-type
4
+ new (...args: any[]): T;
5
+ };
6
+
7
+
8
+ type CustomElementClass = Omit<typeof HTMLElement, 'new'>;
9
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
10
+ export type CustomElementDecorator = {
11
+ // legacy
12
+ (cls: CustomElementClass): void;
13
+
14
+ // standard
15
+ (
16
+ target: CustomElementClass,
17
+ // eslint-disable-next-line @typescript-eslint/unified-signatures
18
+ context: ClassDecoratorContext<Constructor<HTMLElement>>
19
+ ): void;
20
+ };
21
+
22
+ export const customElementOverride =
23
+ (tagName: string): CustomElementDecorator =>
24
+ (
25
+ classOrTarget: CustomElementClass | Constructor<HTMLElement>,
26
+ context?: ClassDecoratorContext<Constructor<HTMLElement>>
27
+ ) => {
28
+ if (context !== undefined) {
29
+ context.addInitializer(() => {
30
+ customElements.define(
31
+ tagName,
32
+ classOrTarget as CustomElementConstructor
33
+ );
34
+ });
35
+ } else {
36
+ if (!window.customElements.get(tagName)) {
37
+ customElements.define(tagName, classOrTarget as CustomElementConstructor);
38
+ }
39
+ }
40
+ };
package/tsconfig.json CHANGED
@@ -14,5 +14,5 @@
14
14
  "resolveJsonModule": true,
15
15
  "experimentalDecorators": true,
16
16
  },
17
- "include": ["packages"]
17
+ "include": ["packages", "globals.d.ts"]
18
18
  }