Compare commits

...
6 changed files with 714 additions and 176 deletions
+59 -90
View File
@@ -1,100 +1,69 @@
import { AssistChip } from "@material/web/chips/internal/assist-chip";
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { css, html } from "lit";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends AssistChip {
export class HaAssistChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
@property({ type: Boolean, reflect: true }) active = false;
static override styles = [
sharedStyles,
elevatedStyles,
styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-assist-chip-container-shape: var(
--ha-assist-chip-container-shape,
16px
);
--md-assist-chip-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400;
}
/** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled {
display: flex;
pointer-events: none;
border-radius: inherit;
inset: 0;
position: absolute;
background-color: var(--ha-assist-chip-filled-container-color);
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]),
::slotted([slot="trailing-icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
font-size: var(--_label-text-size) !important;
}
@property({ type: Boolean, reflect: true }) elevated = false;
.trailing.icon ::slotted(*),
.trailing.icon svg {
margin-inline-end: unset;
margin-inline-start: var(--_icon-label-space);
}
::before {
background: var(--ha-assist-chip-container-color, transparent);
opacity: var(--ha-assist-chip-container-opacity, 1);
}
:where(.active)::before {
background: var(--ha-assist-chip-active-container-color);
opacity: var(--ha-assist-chip-active-container-opacity);
}
.label {
font-family: var(--ha-font-family-body);
}
`,
];
protected override renderOutline() {
if (this.filled) {
return html`<span class="filled"></span>`;
}
return super.renderOutline();
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
active: this.active,
};
}
protected override renderPrimaryContent() {
return html`
<span class="leading icon" aria-hidden="true">
${this.renderLeadingIcon()}
</span>
<span class="label">${this.label}</span>
<span class="touch"></span>
<span class="trailing leading icon" aria-hidden="true">
${this.renderTrailingIcon()}
</span>
`;
}
protected renderTrailingIcon() {
return html`<slot name="trailing-icon"></slot>`;
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-chip-label-color: var(
--md-assist-chip-label-text-color,
var(--md-sys-color-on-surface, var(--primary-text-color))
);
--ha-button-border-radius: var(
--ha-assist-chip-container-shape,
16px
);
--ha-chip-container-color: var(
--ha-assist-chip-container-color,
transparent
);
--ha-chip-container-opacity: var(
--ha-assist-chip-container-opacity,
1
);
--ha-chip-outline-color: var(
--md-assist-chip-outline-color,
var(--outline-color)
);
--ha-chip-icon-label-space: var(
--md-assist-chip-icon-label-space,
var(--ha-space-2)
);
}
:host([filled]) {
--ha-chip-container-color: var(
--ha-assist-chip-filled-container-color
);
--ha-chip-outline-color: transparent;
}
:host([active]) {
--ha-chip-container-color: var(
--ha-assist-chip-active-container-color
);
--ha-chip-container-opacity: var(
--ha-assist-chip-active-container-opacity,
1
);
}
.primary {
padding-inline-end: var(
--md-assist-chip-trailing-space,
var(--ha-space-4)
);
}
`,
];
}
}
+452
View File
@@ -0,0 +1,452 @@
import { LocalizeController } from "@home-assistant/webawesome/dist/utilities/localize.js";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { HaButton } from "../ha-button";
import "../ha-svg-icon";
export interface ChipFocusOptions extends FocusOptions {
trailing?: boolean;
}
export class HaChipBase extends HaButton {
@property() label = "";
@property({ type: Boolean }) removable = false;
@property({ type: Boolean, attribute: "remove-only" }) removeOnly = false;
@property({ type: Boolean, reflect: true, attribute: "soft-disabled" })
softDisabled = false;
@property({ type: Boolean, attribute: "always-focusable" })
alwaysFocusable = false;
@property({ type: Boolean, reflect: true, attribute: "has-icon" })
hasIcon = false;
@property({ type: Number, reflect: true, attribute: "tabindex" })
override tabIndex = 0;
@property({ attribute: "aria-label-remove" }) ariaLabelRemove: string | null =
null;
@query(".primary") private _primary?: HTMLButtonElement | HTMLAnchorElement;
@query(".remove") private _remove?: HTMLButtonElement;
@state() private _trailingFocused = false;
@state() private _hasTrailingIcon = false;
private _chipLocalize = new LocalizeController(this);
override variant: HaButton["variant"] = "neutral";
override appearance: HaButton["appearance"] = "outlined";
override size: HaButton["size"] = "s";
protected get pressed(): boolean | undefined {
return undefined;
}
get focusable() {
return !this.disabled || this.alwaysFocusable;
}
constructor() {
super();
this.addEventListener("click", this._blockDisabledClick);
this.addEventListener("keydown", this._handleKeyDown);
}
override focus(options?: ChipFocusOptions) {
if (!this.focusable) {
return;
}
if (
(options?.trailing || (this.removeOnly && !this.href)) &&
this._remove
) {
this._remove.focus(options);
} else {
this._primary?.focus(options);
}
}
override click() {
if (this.removeOnly && !this.href) {
this._remove?.click();
} else {
this._primary?.click();
}
}
override blur() {
this._primary?.blur();
this._remove?.blur();
}
protected override updated(changed: PropertyValues<this>) {
super.updated(changed);
if (changed.has("disabled") || changed.has("alwaysFocusable")) {
fireEvent(this, "update-focus", undefined, { composed: false });
}
}
override render() {
return html`
<div class=${classMap({ container: true, "has-remove": this.removable })}>
${this._renderPrimaryAction()}
${
this.removable
? html`
<span id="remove-label" hidden>
${this._chipLocalize.term("remove")}
</span>
<span id="accessible-label" hidden>${this.ariaLabel}</span>
<button
class="button remove trailing action"
part="remove-button"
type="button"
?disabled=${this.disabled && !this.alwaysFocusable}
aria-disabled=${this.softDisabled ? "true" : nothing}
aria-label=${this.ariaLabelRemove || nothing}
aria-labelledby=${
this.ariaLabelRemove
? nothing
: this.ariaLabel
? "remove-label accessible-label"
: "remove-label label"
}
tabindex=${this.removeOnly && !this.href ? this.tabIndex : -1}
@click=${this._handleRemove}
@focus=${this._handleRemoveFocus}
@blur=${this._handleRemoveBlur}
>
<span class="icon" aria-hidden="true">
<slot name="remove-trailing-icon">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</slot>
</span>
</button>
`
: nothing
}
</div>
`;
}
private _renderPrimaryAction() {
if (this.href) {
return html`<a
class="button primary action"
part="base"
title=${this.title || nothing}
href=${this.href}
target=${this.target || nothing}
download=${this.download || nothing}
rel=${this.rel || nothing}
aria-label=${this.ariaLabel || nothing}
tabindex=${this._trailingFocused ? -1 : this.tabIndex}
@click=${this.handlePrimaryClick}
>${this.renderPrimaryContent()}</a
>`;
}
if (this.removeOnly) {
return html`<span class="content" title=${this.title || nothing}>
${this.renderPrimaryContent()}
</span>`;
}
return html`<button
class=${classMap({
button: true,
primary: true,
action: true,
disabled: this.disabled || this.softDisabled,
})}
part="base"
type="button"
title=${this.title || nothing}
?disabled=${this.disabled && !this.alwaysFocusable}
aria-disabled=${
this.softDisabled || (this.disabled && this.alwaysFocusable)
? "true"
: nothing
}
aria-label=${this.ariaLabel || nothing}
aria-pressed=${this.pressed ?? nothing}
tabindex=${this._trailingFocused ? -1 : this.tabIndex}
@click=${this.handlePrimaryClick}
>
${this.renderPrimaryContent()}
</button>`;
}
protected renderPrimaryContent() {
return html`
${this.renderLeadingIcon()}
<span class="label" id="label" part="label">
${this.label || html`<slot></slot>`}
</span>
<span class="icon" aria-hidden="true" ?hidden=${!this._hasTrailingIcon}>
<slot name="trailing-icon" @slotchange=${this._iconChanged}></slot>
</span>
`;
}
protected renderLeadingIcon(): TemplateResult | typeof nothing {
return html`<span class="icon leading" aria-hidden="true">
<slot name="icon" @slotchange=${this._iconChanged}></slot>
</span>`;
}
protected handlePrimaryClick(event: MouseEvent) {
this._blockDisabledClick(event);
}
private _iconChanged(event: Event) {
if (!(event.target instanceof HTMLSlotElement)) {
return;
}
const hasIcon = event.target.assignedElements({ flatten: true }).length > 0;
if (event.target.name === "icon") {
this.hasIcon = hasIcon;
} else {
this._hasTrailingIcon = hasIcon;
}
}
private _blockDisabledClick(event: MouseEvent) {
if (
this.softDisabled ||
(this.disabled && (!this.href || this.alwaysFocusable))
) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
private _handleRemove(event: MouseEvent) {
event.stopPropagation();
if (this.disabled || this.softDisabled) {
return;
}
if (
!fireEvent(this, "remove", undefined, {
bubbles: false,
composed: false,
cancelable: true,
}).defaultPrevented
) {
this.remove();
}
}
private _handleRemoveFocus() {
this._trailingFocused = true;
}
private _handleRemoveBlur() {
this._trailingFocused = false;
}
private _handleKeyDown(event: KeyboardEvent) {
if (
(event.key !== "ArrowLeft" && event.key !== "ArrowRight") ||
!this._primary ||
!this._remove
) {
return;
}
const forwards =
(event.key === "ArrowRight") !==
(getComputedStyle(this).direction === "rtl");
if (
(forwards && this.shadowRoot?.activeElement === this._primary) ||
(!forwards && this.shadowRoot?.activeElement === this._remove)
) {
event.preventDefault();
event.stopPropagation();
(forwards ? this._remove : this._primary).focus();
}
}
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
max-width: 100%;
vertical-align: middle;
--ha-button-height: 32px;
--ha-button-border-radius: var(--ha-border-radius-pill);
--wa-font-weight-action: var(--ha-font-weight-normal);
--wa-button-transform-hover: none;
--wa-button-transform-active: none;
}
:host([variant]) {
--wa-color-on-quiet: var(
--ha-chip-label-color,
var(--primary-text-color)
);
--wa-color-fill-quiet: color-mix(
in srgb,
var(--ha-chip-label-color, var(--primary-text-color)) 8%,
transparent
);
}
.container {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
height: var(--ha-button-height);
position: relative;
border-radius: var(--ha-button-border-radius);
}
.container::before,
.container::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
}
.container::before {
background: var(--ha-chip-container-color, transparent);
opacity: var(--ha-chip-container-opacity, 1);
}
.container::after {
border: var(--ha-chip-outline-width, 1px) solid
var(--ha-chip-outline-color, var(--outline-color));
}
.button,
.content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--ha-chip-icon-label-space, var(--ha-space-2));
min-width: 0;
border: 0;
border-radius: inherit;
color: var(--ha-chip-label-color, var(--primary-text-color));
background: transparent;
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
padding-inline: var(--ha-space-4);
position: relative;
box-shadow: none;
}
.button.primary,
.content {
flex: 1;
height: var(--ha-button-height);
min-height: var(--ha-button-height);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: var(--ha-chip-label-size, 0.875rem);
font-weight: var(
--ha-chip-label-weight,
var(--ha-font-weight-normal)
);
line-height: var(--ha-chip-label-line-height, 1.25rem);
color: var(--ha-chip-label-color, var(--primary-text-color));
}
.button.remove {
flex: none;
padding: 0;
width: calc(var(--ha-chip-icon-size, 18px) + 2 * var(--ha-space-2));
height: var(--ha-button-height);
min-height: var(--ha-button-height);
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.has-remove .primary,
.has-remove .content {
border-start-end-radius: 0;
border-end-end-radius: 0;
padding-inline-end: 0;
}
:host([has-icon]) .primary,
:host([has-icon]) .content {
padding-inline-start: var(--ha-space-2);
}
.button::after {
content: "";
position: absolute;
inset: -8px 0;
}
slot:not([name]),
slot[name="icon"],
slot[name="trailing-icon"],
slot[name="selected-icon"],
slot[name="remove-trailing-icon"] {
display: contents;
}
.icon {
display: flex;
flex: none;
color: var(--ha-chip-label-color, var(--primary-text-color));
}
:host(:not([has-icon])) .leading {
display: none;
}
.icon[hidden] {
display: none;
}
ha-svg-icon,
::slotted([slot]) {
flex: none;
display: flex;
--mdc-icon-size: var(--ha-chip-icon-size, 18px);
}
:host([disabled]),
:host([soft-disabled]) {
opacity: 1;
}
:host([disabled]) .label,
:host([disabled]) .icon,
:host([soft-disabled]) .label,
:host([soft-disabled]) .icon {
opacity: 0.38;
}
:host([disabled][selected]) .container::before,
:host([soft-disabled][selected]) .container::before {
background-color: var(--primary-text-color);
opacity: 0.12;
}
:host([elevated]) .container {
box-shadow: var(--ha-box-shadow-s);
}
`,
];
}
}
declare global {
interface HASSDomEvents {
remove: undefined;
"update-focus": undefined;
}
}
+87 -3
View File
@@ -1,8 +1,92 @@
import { MdChipSet } from "@material/web/chips/chip-set";
import { customElement } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { customElement, queryAssignedElements } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-chip-set")
export class HaChipSet extends MdChipSet {}
export class HaChipSet extends LitElement {
@queryAssignedElements({ flatten: true }) private _children!: HTMLElement[];
get chips(): HaChipBase[] {
return this._children.filter(
(child): child is HaChipBase => child instanceof HaChipBase
);
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute("role", "toolbar");
this.addEventListener("keydown", this._handleKeyDown);
this.addEventListener("focusin", this._updateTabIndices);
this.addEventListener("update-focus", this._updateTabIndices);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("focusin", this._updateTabIndices);
this.removeEventListener("update-focus", this._updateTabIndices);
}
protected render() {
return html`<slot @slotchange=${this._updateTabIndices}></slot>`;
}
private _updateTabIndices() {
const chips = this.chips;
const active =
chips.find((chip) => chip.focusable && chip.shadowRoot?.activeElement) ||
chips.find((chip) => chip.focusable);
for (const chip of chips) {
chip.tabIndex = chip === active ? 0 : -1;
}
}
private _handleKeyDown(event: KeyboardEvent) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
return;
}
const chips = this.chips.filter((chip) => chip.focusable);
if (!chips.length) {
return;
}
event.preventDefault();
const forwards =
(event.key === "ArrowRight") !==
(getComputedStyle(this).direction === "rtl");
const current = chips.findIndex((chip) => chip.shadowRoot?.activeElement);
const index =
event.key === "Home"
? 0
: event.key === "End"
? chips.length - 1
: current === -1
? forwards
? 0
: chips.length - 1
: (current + (forwards ? 1 : -1) + chips.length) % chips.length;
chips[index].focus({
trailing: event.key === "End" || (event.key !== "Home" && !forwards),
});
this._updateTabIndices();
}
static styles = css`
:host {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
+68 -41
View File
@@ -1,54 +1,81 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css, html } from "lit";
import { mdiCheck } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-filter-chip")
export class HaFilterChip extends FilterChip {
export class HaFilterChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) selected = false;
@property({ type: Boolean, reflect: true }) elevated = false;
@property({ type: Boolean, reflect: true, attribute: "no-leading-icon" })
noLeadingIcon = false;
static override styles = [
sharedStyles,
elevatedStyles,
trailingIconStyles,
selectableStyles,
styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
--md-filter-chip-container-shape: var(--ha-border-radius-md);
--md-filter-chip-outline-color: var(--outline-color);
--md-filter-chip-selected-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--_label-text-font: var(--ha-font-family-body);
}
`,
];
protected getContainerClasses() {
const classes = super.getContainerClasses();
if (this.noLeadingIcon) {
classes["has-icon"] = false;
}
return classes;
protected override get pressed() {
return this.selected;
}
protected renderLeadingIcon() {
protected override renderLeadingIcon() {
if (this.noLeadingIcon) {
// eslint-disable-next-line lit/prefer-nothing
return html``;
return nothing;
}
return super.renderLeadingIcon();
return this.selected
? html`<span class="icon" aria-hidden="true">
<slot name="selected-icon">
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</slot>
</span>`
: super.renderLeadingIcon();
}
protected override handlePrimaryClick(event: MouseEvent) {
if (this.disabled || this.softDisabled) {
return;
}
const previous = this.selected;
this.selected = !this.selected;
event.stopPropagation();
// Dispatch from the chip so listeners see the new selection before deciding
// whether to cancel it. Preserve pointer and modifier-key information.
const click =
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
? new PointerEvent("click", event)
: new MouseEvent("click", event);
if (!this.dispatchEvent(click)) {
this.selected = previous;
event.preventDefault();
}
}
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-button-border-radius: var(--ha-border-radius-md);
--ha-chip-label-weight: var(--ha-font-weight-medium);
}
:host([selected]) {
--ha-chip-outline-width: var(
--md-filter-chip-selected-outline-width,
0px
);
--ha-chip-container-color: var(
--md-filter-chip-selected-container-color,
rgba(var(--rgb-primary-text-color), 0.15)
);
}
:host([selected]:not([no-leading-icon])) .primary {
padding-inline-start: var(--ha-space-2);
}
`,
];
}
}
+40 -39
View File
@@ -1,45 +1,46 @@
import { InputChip } from "@material/web/chips/internal/input-chip";
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-input-chip")
export class HaInputChip extends InputChip {
static override styles = [
sharedStyles,
trailingIconStyles,
selectableStyles,
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
--md-input-chip-container-shape: 16px;
--md-input-chip-outline-color: var(--outline-color);
--md-input-chip-selected-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.selected::before {
opacity: var(--ha-input-chip-selected-container-opacity);
}
`,
];
export class HaInputChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) selected = false;
@property({ type: Boolean, reflect: true }) avatar = false;
override removable = true;
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-button-border-radius: 16px;
--ha-chip-icon-size: var(--md-input-chip-icon-size, 18px);
}
:host([selected]) {
--ha-chip-container-color: var(
--md-input-chip-selected-container-color,
rgba(var(--rgb-primary-text-color), 0.15)
);
--ha-chip-outline-width: var(
--md-input-chip-selected-outline-width,
0px
);
--ha-chip-container-opacity: var(
--ha-input-chip-selected-container-opacity,
1
);
}
:host([avatar]) ::slotted([slot="icon"]) {
width: 24px;
height: 24px;
border-radius: var(--ha-border-radius-circle);
}
`,
];
}
}
declare global {
@@ -18,9 +18,14 @@ vi.mock("../../../src/components/ha-dialog", () => {
return {};
});
vi.mock("../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
vi.mock("../../../src/components/ha-button", async () => {
const lit = await import("lit");
class HaButton extends lit.LitElement {}
customElements.define("ha-button", HaButton);
return { HaButton };
});
vi.mock("../../../src/components/ha-dialog-footer", () => {