mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-19 15:26:36 +00:00
Add experimental UI (#1205)
* Add experimental UI * Allow theming a view * Name it css * Allow applying themes * Add filter card * Add normal column layout logic
This commit is contained in:
parent
75e3f1f37b
commit
5f226d1809
@ -49,6 +49,10 @@ function ensureLoaded(panel) {
|
|||||||
imported = import(/* webpackChunkName: "panel-dev-template" */ '../panels/dev-template/ha-panel-dev-template.js');
|
imported = import(/* webpackChunkName: "panel-dev-template" */ '../panels/dev-template/ha-panel-dev-template.js');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'experimental-ui':
|
||||||
|
imported = import(/* webpackChunkName: "panel-experimental-ui" */ '../panels/experimental-ui/ha-panel-experimental-ui.js');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'history':
|
case 'history':
|
||||||
imported = import(/* webpackChunkName: "panel-history" */ '../panels/history/ha-panel-history.js');
|
imported = import(/* webpackChunkName: "panel-history" */ '../panels/history/ha-panel-history.js');
|
||||||
break;
|
break;
|
||||||
|
96
src/panels/experimental-ui/ha-panel-experimental-ui.js
Normal file
96
src/panels/experimental-ui/ha-panel-experimental-ui.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import '@polymer/app-layout/app-header-layout/app-header-layout.js';
|
||||||
|
import '@polymer/app-layout/app-header/app-header.js';
|
||||||
|
import '@polymer/app-layout/app-toolbar/app-toolbar.js';
|
||||||
|
import '@polymer/paper-icon-button/paper-icon-button.js';
|
||||||
|
|
||||||
|
import { html } from '@polymer/polymer/lib/utils/html-tag.js';
|
||||||
|
import { PolymerElement } from '@polymer/polymer/polymer-element.js';
|
||||||
|
|
||||||
|
import './hui-view.js';
|
||||||
|
|
||||||
|
class ExperimentalUI extends PolymerElement {
|
||||||
|
static get template() {
|
||||||
|
return html`
|
||||||
|
<style include='ha-style'>
|
||||||
|
app-header-layout {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<app-header-layout>
|
||||||
|
<app-header slot="header" fixed>
|
||||||
|
<app-toolbar>
|
||||||
|
<ha-menu-button narrow='[[narrow]]' show-menu='[[showMenu]]'></ha-menu-button>
|
||||||
|
<div main-title>Experimental UI</div>
|
||||||
|
<paper-icon-button icon='hass:refresh' on-click='_fetchConfig'></paper-icon-button>
|
||||||
|
</app-toolbar>
|
||||||
|
</app-header>
|
||||||
|
|
||||||
|
<hui-view
|
||||||
|
hass='[[hass]]'
|
||||||
|
config='[[_curView]]'
|
||||||
|
columns='[[_columns]]'
|
||||||
|
></hui-view>
|
||||||
|
</app-header-layout>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
hass: Object,
|
||||||
|
|
||||||
|
narrow: {
|
||||||
|
type: Boolean,
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
showMenu: {
|
||||||
|
type: Boolean,
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
_columns: {
|
||||||
|
type: Number,
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
|
||||||
|
_config: {
|
||||||
|
type: Object,
|
||||||
|
value: null,
|
||||||
|
observer: '_configChanged',
|
||||||
|
},
|
||||||
|
|
||||||
|
_curView: Object
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ready() {
|
||||||
|
super.ready();
|
||||||
|
this._fetchConfig();
|
||||||
|
this._handleWindowChange = this._handleWindowChange.bind(this);
|
||||||
|
this.mqls = [300, 600, 900, 1200].map((width) => {
|
||||||
|
const mql = matchMedia(`(min-width: ${width}px)`);
|
||||||
|
mql.addListener(this._handleWindowChange);
|
||||||
|
return mql;
|
||||||
|
});
|
||||||
|
this._handleWindowChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleWindowChange() {
|
||||||
|
const matchColumns = this.mqls.reduce((cols, mql) => cols + mql.matches, 0);
|
||||||
|
// Do -1 column if the menu is docked and open
|
||||||
|
this._columns = Math.max(1, matchColumns - (!this.narrow && this.showMenu));
|
||||||
|
}
|
||||||
|
|
||||||
|
_fetchConfig() {
|
||||||
|
this.hass.connection.sendMessagePromise({ type: 'frontend/experimental_ui' })
|
||||||
|
.then((conf) => { this._config = conf.result; });
|
||||||
|
}
|
||||||
|
|
||||||
|
_configChanged(config) {
|
||||||
|
if (!config) return;
|
||||||
|
// Currently hardcode to first view.
|
||||||
|
this._curView = config.views[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('ha-panel-experimental-ui', ExperimentalUI);
|
114
src/panels/experimental-ui/hui-entities-card.js
Normal file
114
src/panels/experimental-ui/hui-entities-card.js
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import '@polymer/iron-flex-layout/iron-flex-layout-classes.js';
|
||||||
|
import { html } from '@polymer/polymer/lib/utils/html-tag.js';
|
||||||
|
import { PolymerElement } from '@polymer/polymer/polymer-element.js';
|
||||||
|
|
||||||
|
import stateCardType from '../../common/entity/state_card_type.js';
|
||||||
|
|
||||||
|
import '../../components/ha-card.js';
|
||||||
|
|
||||||
|
// just importing this now as shortcut to import correct state-card-*
|
||||||
|
import '../../state-summary/state-card-content.js';
|
||||||
|
|
||||||
|
// Support for overriding type from attributes should be removed
|
||||||
|
// Instead, it should be coded inside entity config.
|
||||||
|
// stateCardType requires `hass` because we check if a service exists.
|
||||||
|
// This should also be determined during runtime.
|
||||||
|
function stateElement(hass, entityId, stateObj) {
|
||||||
|
if (!stateObj) {
|
||||||
|
return 'state-card-display';
|
||||||
|
} else if (stateObj.attributes && 'custom_ui_state_card' in stateObj.attributes) {
|
||||||
|
return stateObj.attributes.custom_ui_state_card;
|
||||||
|
}
|
||||||
|
return 'state-card-' + stateCardType(hass, stateObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HuiEntitiesCard extends PolymerElement {
|
||||||
|
static get template() {
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
ha-card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.state {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
@apply --paper-font-headline;
|
||||||
|
/* overwriting line-height +8 because entity-toggle can be 40px height,
|
||||||
|
compensating this with reduced padding */
|
||||||
|
line-height: 40px;
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
padding: 4px 0 12px;
|
||||||
|
}
|
||||||
|
.header .name {
|
||||||
|
@apply --paper-font-common-nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<ha-card>
|
||||||
|
<div class='header'>
|
||||||
|
<div class="name">[[_computeTitle(config)]]</div>
|
||||||
|
</div>
|
||||||
|
<div id="states"></div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
hass: {
|
||||||
|
type: Object,
|
||||||
|
observer: '_hassChanged',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
observer: '_configChanged',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._elements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
// +1 for the header
|
||||||
|
return 1 + (this.config ? this.config.entities.length : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeTitle(config) {
|
||||||
|
return config.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
_configChanged(config) {
|
||||||
|
const root = this.$.states;
|
||||||
|
|
||||||
|
while (root.lastChild) {
|
||||||
|
root.removeChild(root.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._elements = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < config.entities.length; i++) {
|
||||||
|
const entityId = config.entities[i];
|
||||||
|
const stateObj = this.hass.states[entityId];
|
||||||
|
const tag = stateElement(this.hass, entityId, stateObj);
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.stateObj = stateObj;
|
||||||
|
element.hass = this.hass;
|
||||||
|
this._elements.push({ entityId, element });
|
||||||
|
root.appendChild(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hassChanged(hass) {
|
||||||
|
for (let i = 0; i < this._elements.length; i++) {
|
||||||
|
const { entityId, element } = this._elements[i];
|
||||||
|
const stateObj = hass.states[entityId];
|
||||||
|
element.stateObj = stateObj;
|
||||||
|
element.hass = hass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('hui-entities-card', HuiEntitiesCard);
|
55
src/panels/experimental-ui/hui-entity-filter-card.js
Normal file
55
src/panels/experimental-ui/hui-entity-filter-card.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { html } from '@polymer/polymer/lib/utils/html-tag.js';
|
||||||
|
import { PolymerElement } from '@polymer/polymer/polymer-element.js';
|
||||||
|
|
||||||
|
import './hui-entities-card.js';
|
||||||
|
|
||||||
|
import computeStateDomain from '../../common/entity/compute_state_domain.js';
|
||||||
|
|
||||||
|
class HuiEntitiesCard extends PolymerElement {
|
||||||
|
static get template() {
|
||||||
|
return html`
|
||||||
|
<hui-entities-card
|
||||||
|
hass='[[hass]]'
|
||||||
|
config='[[_computeCardConfig(hass, config)]]'
|
||||||
|
></hui-entities-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
hass: Object,
|
||||||
|
config: Object,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getCardSize() {
|
||||||
|
// +1 for the header
|
||||||
|
return 1 + this._getEntities(this.hass, this.config.filter).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a list of entities based on a filter.
|
||||||
|
_getEntities(hass, filter) {
|
||||||
|
const filters = [];
|
||||||
|
|
||||||
|
if (filter.domain) {
|
||||||
|
const domain = filter.domain;
|
||||||
|
filters.push(stateObj => computeStateDomain(stateObj) === domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filter.state) {
|
||||||
|
const state = filter.state;
|
||||||
|
filters.push(stateObj => stateObj.state === state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(hass.states)
|
||||||
|
.filter(stateObj => filters.every(filterFunc => filterFunc(stateObj)))
|
||||||
|
.map(stateObj => stateObj.entity_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
_computeCardConfig(hass, config) {
|
||||||
|
return Object.assign({}, config.card_config || {}, {
|
||||||
|
entities: this._getEntities(hass, config.filter),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
customElements.define('hui-entity-filter-card', HuiEntitiesCard);
|
182
src/panels/experimental-ui/hui-view.js
Normal file
182
src/panels/experimental-ui/hui-view.js
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { html } from '@polymer/polymer/lib/utils/html-tag.js';
|
||||||
|
import { PolymerElement } from '@polymer/polymer/polymer-element.js';
|
||||||
|
|
||||||
|
import './hui-entities-card.js';
|
||||||
|
import './hui-entity-filter-card.js';
|
||||||
|
|
||||||
|
import applyThemesOnElement from '../../common/dom/apply_themes_on_element.js';
|
||||||
|
|
||||||
|
const VALID_TYPES = ['entities', 'entity-filter'];
|
||||||
|
const CUSTOM_TYPE_PREFIX = 'custom:';
|
||||||
|
|
||||||
|
function cardElement(type) {
|
||||||
|
if (VALID_TYPES.includes(type)) {
|
||||||
|
return `hui-${type}-card`;
|
||||||
|
} else if (type.startsWith(CUSTOM_TYPE_PREFIX)) {
|
||||||
|
return type.substr(CUSTOM_TYPE_PREFIX.length);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HaView extends PolymerElement {
|
||||||
|
static get template() {
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-right: 8px;
|
||||||
|
transform: translateZ(0);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
#columns {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
flex-basis: 0;
|
||||||
|
flex-grow: 1;
|
||||||
|
max-width: 500px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column > * {
|
||||||
|
display: block;
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
:host {
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column > * {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 599px) {
|
||||||
|
.column {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div id='columns'></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
hass: {
|
||||||
|
type: Object,
|
||||||
|
observer: '_hassChanged',
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: {
|
||||||
|
type: Number,
|
||||||
|
observer: '_configChanged',
|
||||||
|
},
|
||||||
|
|
||||||
|
config: {
|
||||||
|
type: Object,
|
||||||
|
observer: '_configChanged',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._elements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
_getElements(cards) {
|
||||||
|
const elements = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < cards.length; i++) {
|
||||||
|
const cardConfig = cards[i];
|
||||||
|
const tag = cardElement(cardConfig.type);
|
||||||
|
if (!tag) {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
console.error('Unknown type encountered:', cardConfig.type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
element.config = cardConfig;
|
||||||
|
element.hass = this.hass;
|
||||||
|
elements.push(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
_configChanged() {
|
||||||
|
const root = this.$.columns;
|
||||||
|
const config = this.config;
|
||||||
|
|
||||||
|
while (root.lastChild) {
|
||||||
|
root.removeChild(root.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
this._elements = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = this._getElements(config.cards);
|
||||||
|
|
||||||
|
let columns = [];
|
||||||
|
const columnEntityCount = [];
|
||||||
|
for (let i = 0; i < this.columns; i++) {
|
||||||
|
columns.push([]);
|
||||||
|
columnEntityCount.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find column with < 5 entities, else column with lowest count
|
||||||
|
function getColumnIndex(size) {
|
||||||
|
let minIndex = 0;
|
||||||
|
for (let i = 0; i < columnEntityCount.length; i++) {
|
||||||
|
if (columnEntityCount[i] < 5) {
|
||||||
|
minIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (columnEntityCount[i] < columnEntityCount[minIndex]) {
|
||||||
|
minIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
columnEntityCount[minIndex] += size;
|
||||||
|
|
||||||
|
return minIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.forEach(el =>
|
||||||
|
columns[getColumnIndex(el.getCardSize())].push(el));
|
||||||
|
|
||||||
|
// Remove empty columns
|
||||||
|
columns = columns.filter(val => val.length > 0);
|
||||||
|
|
||||||
|
columns.forEach((column) => {
|
||||||
|
const columnEl = document.createElement('div');
|
||||||
|
columnEl.classList.add('column');
|
||||||
|
column.forEach(el => columnEl.appendChild(el));
|
||||||
|
root.appendChild(columnEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
this._elements = elements;
|
||||||
|
|
||||||
|
if ('theme' in config) {
|
||||||
|
applyThemesOnElement(root, this.hass.themes, config.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_hassChanged(hass) {
|
||||||
|
for (let i = 0; i < this._elements.length; i++) {
|
||||||
|
this._elements[i].hass = hass;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('hui-view', HaView);
|
Loading…
x
Reference in New Issue
Block a user