feat: Modernize the visual card editor. (#2586)

This commit is contained in:
Dermot Duffy
2026-07-20 20:44:16 -07:00
committed by GitHub
parent e204ec183f
commit b6e1de5999
146 changed files with 8487 additions and 5992 deletions
+34
View File
@@ -0,0 +1,34 @@
a {
display: flex;
align-items: center;
padding: 10px 10px 10px 14px;
border-bottom: 1px solid var(--advanced-camera-card-divider-color);
text-decoration: none;
font-style: italic;
color: var(--advanced-camera-card-editor-doc-link-color);
cursor: help;
transition:
background-color 0.2s ease,
color 0.2s ease;
opacity: 0.7;
}
a:hover {
background-color: var(--advanced-camera-card-editor-doc-link-hover-background);
color: var(--advanced-camera-card-editor-doc-link-hover-color);
opacity: 1;
}
a ha-icon:first-child {
margin-right: 15px;
}
a div {
flex: 1;
pointer-events: none;
}
+45
View File
@@ -0,0 +1,45 @@
// The content the editor renders inside an expander body.
//
// Every editor component that renders such content does so in its own shadow
// root, which the editor's own stylesheet cannot reach, so each includes these
// rules itself. The theme variables they use need no such treatment: custom
// properties inherit across shadow boundaries from the editor element.
:host {
display: block;
}
// A single-column grid rather than block layout: the `minmax(0, 1fr)` column
// lets children shrink below their intrinsic minimum width, so a form field
// with long unbreakable content (an entity ID, a select option) clamps to the
// panel width instead of overflowing it horizontally.
@mixin shrinkable-column {
display: grid;
grid-template-columns: minmax(0, 1fr);
}
// No background or border of its own: the body is the panel's own surface, so
// the margins around the form read as part of it rather than as a frame around
// it.
.values {
@include shrinkable-column;
}
// The blocks stacked in a body all take the same inset, so their outlines line
// up with each other and none touches the body edge. It is applied to each
// block rather than as body padding because a form's own fields cannot be
// reached from outside its shadow DOM, so the form element itself has to carry
// the spacing.
.values ha-form,
.values ha-expansion-panel {
display: block;
margin: 10px;
}
// De-emphasized to the same degree Home Assistant de-emphasizes the icons on
// the panels `ha-form` renders, so the panels the editor builds itself sit
// alongside them without standing out.
advanced-camera-card-icon[slot='leading-icon'] {
color: var(--advanced-camera-card-editor-secondary-text-color);
}
+19
View File
@@ -0,0 +1,19 @@
// The keyboard shortcuts panel renders editor body content into its own shadow
// root, so it carries the shared body rules alongside its own.
@use './editor-expander-body.scss';
// The key assigners are rows of a shared three-column table (label, buttons,
// assigned keys): the columns are defined here and each assigner joins them
// with a subgrid, so the rows align across components.
.values {
// `minmax(0, 1fr)` rather than `1fr`: the keys column must be able to
// shrink below its content so long key combinations wrap instead of
// overflowing.
grid-template-columns: max-content max-content minmax(0, 1fr);
}
// Content other than the assigners (the documentation link, the shortcuts
// form) spans the whole row.
.values > *:not(advanced-camera-card-key-assigner) {
grid-column: 1 / -1;
}
+66
View File
@@ -0,0 +1,66 @@
// A list of configuration items as rows, styled as Home Assistant styles the
// editable lists in its own editors.
@use './editor-expander-body.scss';
// Whatever precedes the list draws a line of its own (a documentation link's
// lower border, a form's fields), which the rows' own borders would otherwise
// sit directly against.
:host {
padding-top: 8px;
}
ha-md-list {
gap: 8px;
padding: 0 10px;
}
ha-md-list-item {
border: 1px solid var(--advanced-camera-card-divider-color);
border-radius: var(--ha-border-radius-md, 8px);
--ha-md-list-item-gap: 8px;
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 4px;
--md-list-item-one-line-container-height: 48px;
--md-list-item-two-line-container-height: 56px;
}
// Sized and de-emphasized as Home Assistant sizes the controls on the rows of
// its own editable lists: 36px buttons rather than the 48px default, against a
// 48px row.
.handle,
ha-md-list-item advanced-camera-card-icon[slot='start'] {
color: var(--advanced-camera-card-editor-secondary-text-color);
}
.handle {
cursor: move; // Fallback where the grab cursor is unsupported.
cursor: grab;
}
// The handle is what a drag is started from, so nothing inside it may become
// the target of the pointer instead.
.handle > * {
pointer-events: none;
}
ha-icon-button {
--ha-icon-button-size: 36px;
color: var(--advanced-camera-card-editor-secondary-text-color);
}
.title,
.description {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.add {
margin: 8px 10px;
}
+36
View File
@@ -0,0 +1,36 @@
// The editor for one item of a list, shown in place of the list, headed as
// Home Assistant heads the editor for a sub-element of a card.
@use './editor-expander-body.scss';
// The shared body rules set `:host { display: block }`, which outranks the
// browser's default styling of the `hidden` attribute, so the page is hidden
// explicitly. A page is hidden while it stays mounted under a page opened on
// top of it, keeping its expanded panels expanded for when it returns.
:host([hidden]) {
display: none;
}
.header {
display: flex;
align-items: center;
margin: 8px 10px;
}
.heading {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: var(--ha-font-size-l, 1.125rem);
}
// The page's blocks (the item's forms, then any list of its own) are spaced
// as Home Assistant spaces the blocks within a form, so a list below a form
// reads at the same distance as one form group below another.
::slotted(*:not(:last-child)) {
display: block;
margin-bottom: 24px;
}
+12
View File
@@ -0,0 +1,12 @@
// A section renders its own panel, so it carries the rules for the panel's
// frame and body. The editor around it positions the sections themselves.
@use './editor-expander-body.scss';
// Content the section was given ends its body; without this it sits flush
// against the section's bottom edge.
.values > advanced-camera-card-editor-cameras,
.values > advanced-camera-card-editor-folders,
.values > advanced-camera-card-editor-keyboard-shortcuts {
display: block;
margin-bottom: 10px;
}
+22 -133
View File
@@ -1,147 +1,36 @@
@use './themes/base.scss';
@use './button.scss';
.option {
padding: 8px 4px;
cursor: pointer;
// A hidden form rendered only to pre-load `ha-form`/`ha-selector`'s lazily
// imported element types.
.selector-sideload {
display: none;
}
.option.option-overrides .title {
color: var(--warning-color);
}
.row {
display: flex;
margin-bottom: -14px;
pointer-events: none;
}
.title {
padding-left: 16px;
margin-top: -6px;
pointer-events: none;
}
.title.warning {
color: var(--warning-color);
}
.secondary {
padding-left: 40px;
color: var(--secondary-text-color);
pointer-events: none;
}
.values {
background: var(--secondary-background-color);
display: grid;
grid-template-columns: minmax(0, 1fr);
// Card-state notice banners sit above the section stack.
.card-config > ha-alert {
display: block;
margin-bottom: 8px;
}
// Space submenus from the next top-level options.
.values + .option,
.submenu + .option {
margin-top: 10px;
// Each top-level section is an HA expansion panel. An open section draws its
// own outline, which would otherwise sit directly against the neighbouring
// sections, so every section is spaced whether it is open or not: a gap that
// appeared only on opening would move the sections below as the panel grew.
// The margins of two adjacent sections collapse together, so the gap is the
// same wherever it falls.
.section {
display: block;
margin-top: 8px;
margin-bottom: 8px;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
}
.submenu-header {
display: flex;
align-items: center;
padding: 10px;
cursor: pointer;
}
.submenu.selected > .submenu-header {
background-color: var(--primary-color);
color: var(--primary-text-color);
}
.submenu-header * {
flex-basis: auto;
// Only allow clicks on the header.
pointer-events: none;
}
.submenu-header .new {
font-style: italic;
}
.submenu:not(.selected) > .submenu-header .new {
color: var(--secondary-text-color, 'black');
}
.submenu-header advanced-camera-card-icon {
margin-right: 15px;
}
.submenu.selected {
border: 1px solid var(--primary-color);
}
// Nested menus should shrink width.
.submenu {
width: calc(100% - 10px * 2);
margin-left: auto;
margin-right: auto;
margin-bottom: 10px;
}
// Add a margin above a submenu unless it's another submenu.
.submenu:first-child,
:not(.submenu) + .submenu {
margin-top: 10px;
}
.submenu .values .controls {
display: inline-block;
margin-left: auto;
margin-right: 0px;
margin-bottom: 5px;
margin-top: 5px;
}
.submenu .values .controls ha-icon-button {
--ha-icon-button-size: 32px;
--mdc-icon-size: calc(var(--ha-icon-button-size) / 2);
}
span.info {
padding: 10px;
}
ha-selector {
padding: 10px;
border: 1px solid var(--divider-color);
}
.doc-link-row {
display: flex;
align-items: center;
padding: 10px 10px 10px 14px;
border-bottom: 1px solid var(--divider-color);
text-decoration: none;
font-style: italic;
color: var(--secondary-text-color);
cursor: help;
transition:
background-color 0.2s ease,
color 0.2s ease;
opacity: 0.7;
}
.doc-link-row:hover {
background-color: var(--secondary-background-color);
color: var(--primary-color);
opacity: 1;
}
.doc-link-row ha-icon:first-child {
margin-right: 15px;
}
.doc-link-row div {
flex: 1;
padding-left: 0;
margin-top: 0;
pointer-events: none;
}
ha-button {
margin: 10px;
}
+1 -7
View File
@@ -7,19 +7,13 @@
}
ha-icon,
state-badge,
svg {
state-badge {
display: block;
height: 100%;
width: 100%;
box-sizing: border-box;
}
svg path {
// !important is necessary to overcome the fill set on the path itself.
fill: currentColor !important;
}
:host([allow-override-non-active-styles]) state-badge {
--state-inactive-color: inherit;
}
+56 -36
View File
@@ -1,12 +1,29 @@
// Fade content out over the last `$distance` of the element's width. The
// prefixed property covers Safari before 15.4 and Chrome before 120.
@mixin fade-out-right($distance: 20px) {
$gradient: linear-gradient(
to right,
black calc(100% - #{$distance}),
transparent 100%
);
-webkit-mask-image: $gradient;
mask-image: $gradient;
}
// Each assigner is one row of the columns its parent grid defines (label,
// buttons, assigned keys), so the rows align like a table across components.
:host {
display: flex;
flex-direction: row;
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
align-items: center;
// Values match other HA editor components.
// Values match other HA editor components. A minimum rather than a fixed
// height: a long key combination may wrap onto multiple lines.
padding: 10px;
height: 56px;
border: 1px solid var(--divider-color);
min-height: 56px;
box-sizing: border-box;
}
:host([assigning]) ha-button.assign span,
@@ -15,7 +32,7 @@
@keyframes pulse {
50% {
color: var(--warning-color);
color: var(--advanced-camera-card-editor-warning-color);
}
}
}
@@ -30,16 +47,27 @@ advanced-camera-card-icon {
}
div.label {
width: 100px;
margin-left: 4px;
}
div.key-row {
flex: 1;
div.buttons {
display: flex;
justify-content: center;
align-items: center;
}
div.key-row {
display: flex;
align-items: center;
// A long key combination wraps rather than overflowing the row. When even a
// single key is wider than the row (a very narrow editor), wrapping cannot
// help, so the overflow is clipped and faded out at the edge rather than
// spilling out of the panel.
flex-wrap: wrap;
row-gap: 4px;
min-width: 0;
overflow: hidden;
@include fade-out-right;
padding-left: 10px;
padding-right: 10px;
@@ -49,7 +77,6 @@ div.key {
display: flex;
align-items: center;
height: 90%;
width: min-content;
margin-left: 5px;
@@ -57,32 +84,25 @@ div.key {
}
div.key-inner {
height: 100%;
width: 100%;
padding-top: 2px;
padding-bottom: 2px;
padding-left: 4px;
padding-right: 4px;
border: 2px;
border-radius: 4px;
border-style: outset;
border-color: var(--divider-color);
font-family: monospace;
text-transform: capitalize;
}
div.unassigned {
font-style: italic;
}
div.key + div.key:before {
display: flex;
align-items: center;
justify-content: center;
margin-right: 5px;
min-width: 24px;
content: ' + ';
padding: 4px 8px;
background-color: var(--secondary-background-color);
border: 1px solid var(--advanced-camera-card-divider-color);
border-radius: 4px;
// The darker lower edge reads as the side of a raised keycap.
box-shadow: var(
--advanced-camera-card-box-shadow-override,
inset 0 -2px 0 var(--advanced-camera-card-divider-color)
);
font-family: monospace;
font-size: 0.9em;
text-transform: capitalize;
}
+15 -1
View File
@@ -9,6 +9,10 @@
pointer-events: none;
// The rotating icon's bounding box oscillates as it turns; clip it here so
// it cannot grow ancestor scrollable overflow (flickering scrollbars).
overflow: hidden;
background-color: var(--advanced-camera-card-loading-background-color);
color: var(--advanced-camera-card-loading-foreground-color);
@@ -20,10 +24,20 @@
opacity: 0;
}
// The hidden element stays mounted: without this the icon would rotate
// invisibly forever.
:host([loaded]) advanced-camera-card-icon {
animation: none;
}
advanced-camera-card-icon {
// The icon's own box sets the size; the inner `ha-icon` machinery sizes
// itself from `--mdc-icon-size`, which needs a definite box to fill.
--mdc-icon-size: 100%;
margin-bottom: 20px;
height: 25%;
width: auto;
aspect-ratio: 1;
animation: rotate 8s linear infinite;
}
+16
View File
@@ -301,6 +301,22 @@
transparent 20%
);
/********
* Editor
********/
--advanced-camera-card-editor-doc-link-color: var(--secondary-text-color);
--advanced-camera-card-editor-doc-link-hover-color: var(
--advanced-camera-card-foreground-primary
);
--advanced-camera-card-editor-doc-link-hover-background: var(
--secondary-background-color
);
--advanced-camera-card-editor-secondary-text-color: var(--secondary-text-color);
// Home Assistant's amber `--warning-color`, not the card's own
// `--advanced-camera-card-warning-color`.
--advanced-camera-card-editor-warning-color: var(--warning-color);
/**************
* Notification (block)
**************/