Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ A **visual editor** is available: when editing a `custom:multiple-entity-row` ro
| unit | string/bool | `unit_of_measurement` | Override entity unit of measurement |
| icon | string | `icon` | Override entity icon or image |
| icon_color | string | | CSS color for the entity icon |
| name_gap | string/number | `16px` | Gap between the main icon and the name (see [Theming](#theming)) |
| state_icon | object | | Map of state value → icon override |
| image | string | | Show an image instead of icon |
| toggle | bool | `false` | Display a toggle (if supported) instead of state |
Expand Down Expand Up @@ -428,6 +429,21 @@ entities:
'--multiple-entity-row-header-color': red
```

The gap between the main icon and the row name (HA core's default is `16px`) can be tightened or
widened with `name_gap` — a CSS length or a number (px):

```yaml
type: custom:multiple-entity-row
entity: light.living_room
name_gap: 8px
```

This gap is core `hui-generic-entity-row`'s `.info` `padding-inline-start`, which lives inside that
element's own shadow DOM and is otherwise unreachable (it's not a CSS variable, `padding` isn't
inherited, and outside styles — including a theme or card-mod — can't cross the shadow boundary).
Setting `name_gap` makes the card inject a scoped override into that shadow, driven by the
`--multiple-entity-row-name-gap` variable; rows without `name_gap` are left completely untouched.

## Development

```bash
Expand Down
2 changes: 2 additions & 0 deletions src/editor_schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const MAIN_TAB_SCHEMA = [
],
},
{ name: 'icon_color', selector: { text: {} } },
{ name: 'name_gap', selector: { text: {} } },
{
type: 'grid',
schema: [
Expand Down Expand Up @@ -132,6 +133,7 @@ export const LABELS: Record<string, string> = {
unit: 'Unit',
icon: 'Icon',
icon_color: 'Icon color (CSS value, e.g. red, #ff0000, var(--my-color))',
name_gap: 'Icon → name gap (CSS length, e.g. 8px; default 16px)',
image: 'Image URL',
format: 'Format',
show_state: 'Show main entity state',
Expand Down
8 changes: 8 additions & 0 deletions src/entity.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ const stringTransform = (format, value) => {
export const iconColorCss = (color) =>
color ? `--paper-item-icon-color: ${color}; --mdc-icon-color: ${color}; --state-icon-color: ${color};` : '';

// A configured name_gap as the --multiple-entity-row-name-gap custom property, set on the
// hui-generic-entity-row host so it cascades into that element's shadow, where index.js's injected
// override reads it (core hardcodes the .info icon→name padding at 16px with no variable). A number
// is treated as px; a string passes through ('8px', '0.5em', 'var(--x)'). `0` is a valid gap, so the
// guard is `!= null` (and rejects only the empty string), not truthiness.
export const nameGapCss = (gap) =>
gap != null && gap !== '' ? `--multiple-entity-row-name-gap: ${typeof gap === 'number' ? `${gap}px` : gap};` : '';

// The state_icon map's icon for the current state, or undefined (see #197).
export const stateIcon = (stateObj, config) =>
isObject(config.state_icon) ? config.state_icon[stateObj.state] : undefined;
Expand Down
40 changes: 38 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { css, html, LitElement } from 'lit';

import { LAST_CHANGED, LAST_UPDATED, TIMESTAMP_FORMATS } from './lib/constants';
import { createGestureHandlers } from './lib/gesture_handler';
import { checkEntity, entityName, entityStateDisplay, entityStyles, iconColorCss, stateIcon } from './entity';
import {
checkEntity,
entityName,
entityStateDisplay,
entityStyles,
iconColorCss,
nameGapCss,
stateIcon,
} from './entity';
import { fireEvent, getEntityIds, hasConfigOrEntitiesChanged, hasGenericSecondaryInfo, hideIf, isObject } from './util';
import { style } from './styles';
import './editor';
Expand Down Expand Up @@ -113,7 +121,7 @@ class MultipleEntityRow extends LitElement {
// entities gets its own tap/hold/double-tap handling in renderMainEntity/renderEntity
// instead, correctly scoped to its own action config (see #338, #202).
return html`<hui-generic-entity-row
style="${iconColorCss(this.config.icon_color)}"
style="${iconColorCss(this.config.icon_color)}${nameGapCss(this.config.name_gap)}"
.hass="${this._hass}"
.config="${rowConfig}"
.secondaryText="${this.renderSecondaryInfo()}"
Expand All @@ -131,6 +139,34 @@ class MultipleEntityRow extends LitElement {
</hui-generic-entity-row>`;
}

// The icon→name gap is core hui-generic-entity-row's `.info` padding-inline-start - hardcoded
// 16px, with no CSS variable, inside *its* shadow DOM. `padding` isn't an inherited property and
// styles don't cross a shadow boundary (not even with !important), so neither our own styles nor
// a theme can reach it. When the user opts in via `name_gap`, inject a one-time override into that
// child's shadow that reads our --multiple-entity-row-name-gap variable (set on the host in
// render()), with a 16px fallback so the default is byte-for-byte unchanged. Gated on name_gap, so
// rows that don't use it get zero shadow modification. The injected rule is static (reads the
// variable), so a later name_gap change only updates the host variable via re-render - no re-inject.
async updated(changedProps) {
super.updated?.(changedProps);
if (this.config?.name_gap == null || this.config.name_gap === '') return;
const row = this.renderRoot?.querySelector('hui-generic-entity-row');
if (!row) return;
await row.updateComplete;
const root = row.shadowRoot;
if (!root || root.querySelector('style[data-mer-name-gap]')) return;
const style = document.createElement('style');
style.setAttribute('data-mer-name-gap', '');
// `:host .info` (specificity 0,2,0) is needed to beat core's own `.info` rule (0,1,0): Lit
// puts core's `static styles` in adoptedStyleSheets, which the cascade orders *after* a
// <style> appended to the shadow root, so an equal-specificity rule would lose. Higher
// specificity wins regardless of order, and without !important a user override still wins.
// Both logical and physical padding are set so it also applies in RTL.
style.textContent =
':host .info{padding-inline-start:var(--multiple-entity-row-name-gap,16px);padding-left:var(--multiple-entity-row-name-gap,16px)}';
root.appendChild(style);
}

renderSecondaryInfo() {
if (
!this.config.secondary_info ||
Expand Down
35 changes: 35 additions & 0 deletions src/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,41 @@ describe('multiple-entity-row', () => {
expect(el.shadowRoot.innerHTML).toContain('hui-generic-entity-row');
});

// name_gap sets a --multiple-entity-row-name-gap custom property on the hui-generic-entity-row
// host; the actual `.info` padding override is injected into that (real) element's shadow at
// runtime (see updated() in index.js), which the jsdom stub row can't exercise - so these cover
// the host-variable half, and the injection is verified live on the target dashboard.
describe('name_gap', () => {
const renderWith = async (config) => {
el.setConfig({ entity: 'sensor.main', ...config });
el.hass = buildHass({ 'sensor.main': { entity_id: 'sensor.main', state: 'on', attributes: {} } });
await flushRender(el);
return el.shadowRoot.querySelector('hui-generic-entity-row');
};

it('sets the variable in px when name_gap is a number', async () => {
expect((await renderWith({ name_gap: 8 })).getAttribute('style')).toContain(
'--multiple-entity-row-name-gap: 8px'
);
});

it('passes a string name_gap through unchanged', async () => {
expect((await renderWith({ name_gap: '0.5em' })).getAttribute('style')).toContain(
'--multiple-entity-row-name-gap: 0.5em'
);
});

it('honors a name_gap of 0', async () => {
expect((await renderWith({ name_gap: 0 })).getAttribute('style')).toContain(
'--multiple-entity-row-name-gap: 0px'
);
});

it('omits the variable when name_gap is not configured', async () => {
expect((await renderWith({})).getAttribute('style') ?? '').not.toContain('--multiple-entity-row-name-gap');
});
});

it('populates per-row entities with their own state objects', async () => {
el.setConfig({ entity: 'sensor.main', entities: ['sensor.a'] });
el.hass = buildHass({
Expand Down
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export interface MultipleEntityRowConfig extends EntityOptions {
state_header?: string;
image?: string;
column?: boolean;
// Spacing between the main icon and the row name. A CSS length string ('8px', '0.5em',
// 'var(--x)') or a number (treated as px). See nameGapCss / the updated() injection in index.js.
name_gap?: string | number;
// HA 2026.7+'s row editor renames `format` to `time_format` on save (see #386).
time_format?: string;
entities?: (string | EntityConfig)[];
Expand Down
Loading