diff --git a/build-scripts/webpack.cjs b/build-scripts/webpack.cjs index 985036313a..17f65387a3 100644 --- a/build-scripts/webpack.cjs +++ b/build-scripts/webpack.cjs @@ -182,6 +182,8 @@ const createWebpackConfig = ({ "@lit-labs/virtualizer/layouts/grid.js", "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": "@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js", + "@lit-labs/observers/resize-controller": + "@lit-labs/observers/resize-controller.js", }, }, output: { diff --git a/package.json b/package.json index f0b94b0271..8a99010b56 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,12 @@ "@lezer/highlight": "1.1.6", "@lit-labs/context": "0.4.1", "@lit-labs/motion": "1.0.4", + "@lit-labs/observers": "2.0.1", "@lit-labs/virtualizer": "2.0.7", "@lrnwebcomponents/simple-tooltip": "7.0.18", "@material/chips": "=14.0.0-canary.53b3cad2f.0", "@material/data-table": "=14.0.0-canary.53b3cad2f.0", + "@material/mwc-base": "0.27.0", "@material/mwc-button": "0.27.0", "@material/mwc-checkbox": "0.27.0", "@material/mwc-circular-progress": "0.27.0", diff --git a/src/components/ha-button-menu.ts b/src/components/ha-button-menu.ts index b06b0e0467..8fda2deff0 100644 --- a/src/components/ha-button-menu.ts +++ b/src/components/ha-button-menu.ts @@ -26,6 +26,8 @@ export class HaButtonMenu extends LitElement { @property({ type: Boolean }) public fixed = false; + @property({ type: Boolean, attribute: "no-anchor" }) public noAnchor = false; + @query("mwc-menu", true) private _menu?: Menu; public get items() { @@ -82,7 +84,7 @@ export class HaButtonMenu extends LitElement { if (this.disabled) { return; } - this._menu!.anchor = this; + this._menu!.anchor = this.noAnchor ? null : this; this._menu!.show(); } diff --git a/src/components/ha-button.ts b/src/components/ha-button.ts index df7bdf46e5..473d08dd15 100644 --- a/src/components/ha-button.ts +++ b/src/components/ha-button.ts @@ -17,6 +17,9 @@ export class HaButton extends Button { .mdc-button { height: var(--button-height, 36px); } + .trailing-icon { + display: flex; + } `, ]; } diff --git a/src/components/ha-two-pane-top-app-bar-fixed.ts b/src/components/ha-two-pane-top-app-bar-fixed.ts new file mode 100644 index 0000000000..ec64e4c2f9 --- /dev/null +++ b/src/components/ha-two-pane-top-app-bar-fixed.ts @@ -0,0 +1,320 @@ +import { + addHasRemoveClass, + BaseElement, +} from "@material/mwc-base/base-element"; +import { supportsPassiveEventListener } from "@material/mwc-base/utils"; +import { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter"; +import { strings } from "@material/top-app-bar/constants"; +import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation"; +import { html, css, nothing } from "lit"; +import { property, query, customElement } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css"; +import { haStyleScrollbar } from "../resources/styles"; + +export const passiveEventOptionsIfSupported = supportsPassiveEventListener + ? { passive: true } + : undefined; + +@customElement("ha-two-pane-top-app-bar-fixed") +export abstract class TopAppBarBaseBase extends BaseElement { + protected override mdcFoundation!: MDCFixedTopAppBarFoundation; + + protected override mdcFoundationClass = MDCFixedTopAppBarFoundation; + + @query(".mdc-top-app-bar") protected mdcRoot!: HTMLElement; + + // _actionItemsSlot should have type HTMLSlotElement, but when TypeScript's + // emitDecoratorMetadata is enabled, the HTMLSlotElement constructor will + // be emitted into the runtime, which will cause an "HTMLSlotElement is + // undefined" error in browsers that don't define it (e.g. IE11). + @query('slot[name="actionItems"]') protected _actionItemsSlot!: HTMLElement; + + protected _scrollTarget!: HTMLElement | Window; + + @property({ type: Boolean }) centerTitle = false; + + @property({ type: Boolean, reflect: true }) prominent = false; + + @property({ type: Boolean, reflect: true }) dense = false; + + @property({ type: Boolean }) pane = false; + + @property({ type: Boolean }) footer = false; + + @query(".content") private _contentElement!: HTMLElement; + + @query(".pane .ha-scrollbar") private _paneElement?: HTMLElement; + + @property({ type: Object }) + get scrollTarget() { + return this._scrollTarget || window; + } + + set scrollTarget(value) { + this.unregisterListeners(); + const old = this.scrollTarget; + this._scrollTarget = value; + this.updateRootPosition(); + this.requestUpdate("scrollTarget", old); + this.registerListeners(); + } + + protected updateRootPosition() { + if (this.mdcRoot) { + const windowScroller = this.scrollTarget === window; + // we add support for top-app-bar's tied to an element scroller. + this.mdcRoot.style.position = windowScroller ? "" : "absolute"; + } + } + + protected barClasses() { + return { + "mdc-top-app-bar--dense": this.dense, + "mdc-top-app-bar--prominent": this.prominent, + "center-title": this.centerTitle, + "mdc-top-app-bar--fixed": true, + "mdc-top-app-bar--pane": this.pane, + }; + } + + protected contentClasses() { + return { + "mdc-top-app-bar--fixed-adjust": !this.dense && !this.prominent, + "mdc-top-app-bar--dense-fixed-adjust": this.dense && !this.prominent, + "mdc-top-app-bar--prominent-fixed-adjust": !this.dense && this.prominent, + "mdc-top-app-bar--dense-prominent-fixed-adjust": + this.dense && this.prominent, + "mdc-top-app-bar--pane": this.pane, + }; + } + + protected override render() { + const title = html``; + return html` +
+
+ ${this.pane + ? html`
+ + ${title} +
` + : nothing} + + +
+
+
+ ${this.pane + ? html`
+
+
+ +
+ ${this.footer + ? html`` + : nothing} +
` + : nothing} +
+ ${this.pane ? html`
` : nothing} +
+ +
+
+
+ `; + } + + protected updated(changedProperties) { + super.updated(changedProperties); + if ( + changedProperties.has("pane") && + changedProperties.get("pane") !== undefined + ) { + this.unregisterListeners(); + this.registerListeners(); + } + } + + protected createAdapter(): MDCTopAppBarAdapter { + return { + ...addHasRemoveClass(this.mdcRoot), + setStyle: (prprty: string, value: string) => + this.mdcRoot.style.setProperty(prprty, value), + getTopAppBarHeight: () => this.mdcRoot.clientHeight, + notifyNavigationIconClicked: () => { + this.dispatchEvent( + new Event(strings.NAVIGATION_EVENT, { + bubbles: true, + cancelable: true, + }) + ); + }, + getViewportScrollY: () => + this.scrollTarget instanceof Window + ? this.scrollTarget.pageYOffset + : this.scrollTarget.scrollTop, + getTotalActionItems: () => + (this._actionItemsSlot as HTMLSlotElement).assignedNodes({ + flatten: true, + }).length, + }; + } + + protected handleTargetScroll = () => { + this.mdcFoundation.handleTargetScroll(); + }; + + protected handlePaneScroll = (ev) => { + if (ev.target.scrollTop > 0) { + ev.target.parentElement.classList.add("scrolled"); + } else { + ev.target.parentElement.classList.remove("scrolled"); + } + }; + + protected handleNavigationClick = () => { + this.mdcFoundation.handleNavigationClick(); + }; + + protected registerListeners() { + if (this.pane) { + this._paneElement!.addEventListener( + "scroll", + this.handlePaneScroll, + passiveEventOptionsIfSupported + ); + this._contentElement.addEventListener( + "scroll", + this.handlePaneScroll, + passiveEventOptionsIfSupported + ); + return; + } + this.scrollTarget.addEventListener( + "scroll", + this.handleTargetScroll, + passiveEventOptionsIfSupported + ); + } + + protected unregisterListeners() { + this._paneElement?.removeEventListener("scroll", this.handlePaneScroll); + this._contentElement.removeEventListener("scroll", this.handlePaneScroll); + this.scrollTarget.removeEventListener("scroll", this.handleTargetScroll); + } + + protected override firstUpdated() { + super.firstUpdated(); + this.updateRootPosition(); + this.registerListeners(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.unregisterListeners(); + } + + static override styles = [ + styles, + haStyleScrollbar, + css` + .mdc-top-app-bar__row { + height: var(--header-height); + border-bottom: var(--app-header-border-bottom); + } + .mdc-top-app-bar--fixed-adjust { + padding-top: var(--header-height); + } + .shadow-container { + position: absolute; + top: calc(-1 * var(--header-height)); + width: 100%; + height: var(--header-height); + z-index: 1; + transition: box-shadow 200ms linear; + } + .scrolled .shadow-container { + box-shadow: var( + --mdc-top-app-bar-fixed-box-shadow, + 0px 2px 4px -1px rgba(0, 0, 0, 0.2), + 0px 4px 5px 0px rgba(0, 0, 0, 0.14), + 0px 1px 10px 0px rgba(0, 0, 0, 0.12) + ); + } + .mdc-top-app-bar { + --mdc-typography-headline6-font-weight: 400; + color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff)); + background-color: var( + --app-header-background-color, + var(--mdc-theme-primary) + ); + } + .mdc-top-app-bar--pane.mdc-top-app-bar--fixed-scrolled { + box-shadow: none; + } + #title { + border-right: 1px solid rgba(255, 255, 255, 0.12); + box-sizing: border-box; + flex: 0 0 var(--sidepane-width, 250px); + width: var(--sidepane-width, 250px); + } + div.mdc-top-app-bar--pane { + display: flex; + height: calc(100vh - var(--header-height)); + } + .pane { + border-right: 1px solid var(--divider-color); + box-sizing: border-box; + display: flex; + flex: 0 0 var(--sidepane-width, 250px); + width: var(--sidepane-width, 250px); + flex-direction: column; + position: relative; + } + .pane .ha-scrollbar { + flex: 1; + } + .pane .footer { + border-top: 1px solid var(--divider-color); + } + .main { + min-height: 100%; + } + .mdc-top-app-bar--pane .main { + position: relative; + flex: 1; + height: 100%; + } + .mdc-top-app-bar--pane .content { + height: 100%; + overflow: auto; + } + `, + ]; +} diff --git a/src/data/calendar.ts b/src/data/calendar.ts index c0b9d2b072..178ef3544e 100644 --- a/src/data/calendar.ts +++ b/src/data/calendar.ts @@ -143,7 +143,7 @@ export const getCalendars = (hass: HomeAssistant): Calendar[] => ) .sort() .map((eid, idx) => ({ - entity_id: eid, + ...hass.states[eid], name: computeStateName(hass.states[eid]), backgroundColor: getColorByIndex(idx), })); diff --git a/src/panels/calendar/ha-full-calendar.ts b/src/panels/calendar/ha-full-calendar.ts index 61a370c2e4..30b959b736 100644 --- a/src/panels/calendar/ha-full-calendar.ts +++ b/src/panels/calendar/ha-full-calendar.ts @@ -439,6 +439,11 @@ export class HAFullCalendar extends LitElement { justify-content: initial; } + .header { + padding-right: var(--calendar-header-padding); + padding-left: var(--calendar-header-padding); + } + .navigation { display: flex; align-items: center; @@ -513,7 +518,11 @@ export class HAFullCalendar extends LitElement { .fc-theme-standard .fc-scrollgrid { border: 1px solid var(--divider-color); - border-radius: var(--mdc-shape-small, 4px); + border-width: var(--calendar-border-width, 1px); + border-radius: var( + --calendar-border-radius, + var(--mdc-shape-small, 4px) + ); } .fc-theme-standard td { diff --git a/src/panels/calendar/ha-panel-calendar.ts b/src/panels/calendar/ha-panel-calendar.ts index c2562d1357..f775d8b02e 100644 --- a/src/panels/calendar/ha-panel-calendar.ts +++ b/src/panels/calendar/ha-panel-calendar.ts @@ -1,22 +1,31 @@ -import "@material/mwc-checkbox"; -import "@material/mwc-formfield"; -import { mdiRefresh } from "@mdi/js"; +import { ResizeController } from "@lit-labs/observers/resize-controller"; +import "@material/mwc-list"; +import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item"; +import { mdiChevronDown, mdiRefresh } from "@mdi/js"; import { - css, CSSResultGroup, - html, LitElement, PropertyValues, TemplateResult, + css, + html, + nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; import { storage } from "../../common/decorators/storage"; import { HASSDomEvent } from "../../common/dom/fire_event"; import { computeStateName } from "../../common/entity/compute_state_name"; +import "../../components/ha-button"; +import "../../components/ha-button-menu"; import "../../components/ha-card"; +import "../../components/ha-check-list-item"; import "../../components/ha-icon-button"; +import type { HaListItem } from "../../components/ha-list-item"; import "../../components/ha-menu-button"; +import "../../components/ha-state-icon"; +import "../../components/ha-svg-icon"; +import "../../components/ha-two-pane-top-app-bar-fixed"; import { Calendar, CalendarEvent, @@ -26,7 +35,6 @@ import { import { haStyle } from "../../resources/styles"; import type { CalendarViewChanged, HomeAssistant } from "../../types"; import "./ha-full-calendar"; -import "../../components/ha-top-app-bar-fixed"; @customElement("ha-panel-calendar") class PanelCalendar extends LitElement { @@ -35,6 +43,8 @@ class PanelCalendar extends LitElement { @property({ type: Boolean, reflect: true }) public narrow!: boolean; + @property({ type: Boolean, reflect: true }) public mobile = false; + @state() private _calendars: Calendar[] = []; @state() private _events: CalendarEvent[] = []; @@ -51,6 +61,38 @@ class PanelCalendar extends LitElement { private _end?: Date; + private _showPaneController = new ResizeController(this, { + callback: (entries: ResizeObserverEntry[]) => + entries[0]?.contentRect.width > 750, + }); + + private _mql?: MediaQueryList; + + private _headerHeight = 56; + + public connectedCallback() { + super.connectedCallback(); + this._mql = window.matchMedia( + "(max-width: 450px), all and (max-height: 500px)" + ); + this._mql.addListener(this._setIsMobile); + this.mobile = this._mql.matches; + const computedStyles = getComputedStyle(this); + this._headerHeight = Number( + computedStyles.getPropertyValue("--header-height").replace("px", "") + ); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + this._mql?.removeListener(this._setIsMobile!); + this._mql = undefined; + } + + private _setIsMobile = (ev: MediaQueryListEvent) => { + this.mobile = ev.matches; + }; + public willUpdate(changedProps: PropertyValues): void { super.willUpdate(changedProps); if (!this.hasUpdated) { @@ -59,54 +101,73 @@ class PanelCalendar extends LitElement { } protected render(): TemplateResult { + const calendarItems = this._calendars.map( + (selCal) => html` + + + ${selCal.name} + + ` + ); + const showPane = this._showPaneController.value ?? !this.narrow; return html` - + -
${this.hass.localize("panel.calendar")}
+ + ${!showPane + ? html` + + ${this.hass.localize("ui.components.calendar.my_calendars")} + + + ${calendarItems} + ` + : html`
+ ${this.hass.localize("ui.components.calendar.my_calendars")} +
`} -
-
-
- ${this.hass.localize("ui.components.calendar.my_calendars")} -
- ${this._calendars.map( - (selCal) => html` -
- - - -
- ` - )} -
- -
-
+ ${showPane + ? html`${calendarItems}` + : nothing} + + `; } @@ -117,46 +178,45 @@ class PanelCalendar extends LitElement { } private async _fetchEvents( - start: Date, - end: Date, + start: Date | undefined, + end: Date | undefined, calendars: Calendar[] ): Promise<{ events: CalendarEvent[]; errors: string[] }> { - if (!calendars.length) { + if (!calendars.length || !start || !end) { return { events: [], errors: [] }; } return fetchCalendarEvents(this.hass, start, end, calendars); } - private async _handleToggle(ev): Promise { - const results = this._calendars.map(async (cal) => { - if (ev.target.value !== cal.entity_id) { - return cal; + private async _requestSelected(ev: CustomEvent) { + ev.stopPropagation(); + const entityId = (ev.target as HaListItem).value; + if (ev.detail.selected) { + this._deSelectedCalendars = this._deSelectedCalendars.filter( + (cal) => cal !== entityId + ); + if (ev.detail.source === "interaction") { + // prevent adding the same calendar twice, an interaction event will be followed by a property event + return; } - - const checked = ev.target.checked; - - if (checked) { - const result = await this._fetchEvents(this._start!, this._end!, [cal]); - this._events = [...this._events, ...result.events]; - this._handleErrors(result.errors); - this._deSelectedCalendars = this._deSelectedCalendars.filter( - (deCal) => deCal !== cal.entity_id - ); - } else { - this._events = this._events.filter( - (event) => event.calendar !== cal.entity_id - ); - this._deSelectedCalendars = [ - ...this._deSelectedCalendars, - cal.entity_id, - ]; + const calendar = this._calendars.find( + (cal) => cal.entity_id === entityId + ); + if (!calendar) { + return; } - - return cal; - }); - - this._calendars = await Promise.all(results); + const result = await this._fetchEvents(this._start, this._end, [ + calendar, + ]); + this._events = [...this._events, ...result.events]; + this._handleErrors(result.errors); + } else { + this._deSelectedCalendars = [...this._deSelectedCalendars, entityId]; + this._events = this._events.filter( + (event) => event.calendar !== entityId + ); + } } private async _handleViewChanged( @@ -175,8 +235,8 @@ class PanelCalendar extends LitElement { private async _handleRefresh(): Promise { const result = await this._fetchEvents( - this._start!, - this._end!, + this._start, + this._end, this._selectedCalendars ); this._events = result.events; @@ -204,56 +264,42 @@ class PanelCalendar extends LitElement { return [ haStyle, css` - .content { - padding: 16px; - display: flex; - box-sizing: border-box; + :host { + display: block; } - - :host(:not([narrow])) .content { - height: calc(100vh - var(--header-height)); - } - - .calendar-list { - padding-right: 16px; - padding-inline-end: 16px; - padding-inline-start: initial; - min-width: 170px; - flex: 0 0 15%; - overflow-x: hidden; - overflow-y: auto; - --mdc-theme-text-primary-on-background: var(--primary-text-color); - direction: var(--direction); - } - - .calendar-list > div { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; - } - - .calendar-list-header { - font-size: 16px; - padding: 16px 16px 8px 8px; - } - ha-full-calendar { - flex-grow: 1; + height: calc(100vh - var(--header-height)); + --calendar-header-padding: 12px; + --calendar-border-radius: 0; + --calendar-border-width: 1px 0; } - - :host([narrow]) ha-full-calendar { - height: calc(100vh - 72px); + ha-button-menu ha-button { + --mdc-theme-primary: currentColor; + --mdc-typography-button-text-transform: none; + --mdc-typography-button-font-size: var( + --mdc-typography-headline6-font-size, + 1.25rem + ); + --mdc-typography-button-font-weight: var( + --mdc-typography-headline6-font-weight, + 500 + ); + --mdc-typography-button-letter-spacing: var( + --mdc-typography-headline6-letter-spacing, + 0.0125em + ); + --mdc-typography-button-line-height: var( + --mdc-typography-headline6-line-height, + 2rem + ); + --button-height: 40px; } - - :host([narrow]) .content { - flex-direction: column-reverse; - padding: 8px 0 0 0; + :host([mobile]) .lists { + --mdc-menu-min-width: 100vw; } - - :host([narrow]) .calendar-list { - margin-bottom: 24px; - width: 100%; - padding-right: 0; + :host([mobile]) ha-button-menu { + --mdc-shape-medium: 0 0 var(--mdc-shape-medium) + var(--mdc-shape-medium); } `, ]; diff --git a/yarn.lock b/yarn.lock index 537440ed68..7ac623e772 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2106,7 +2106,16 @@ __metadata: languageName: node linkType: hard -"@lit-labs/ssr-dom-shim@npm:^1.0.0, @lit-labs/ssr-dom-shim@npm:^1.1.0": +"@lit-labs/observers@npm:2.0.1": + version: 2.0.1 + resolution: "@lit-labs/observers@npm:2.0.1" + dependencies: + "@lit/reactive-element": ^2.0.0 + checksum: 6c4518ee37678d86b263799590edd5c202a9f6800b52b52d2a0fc47e572d029c587fa9722108e449ab6bc7b1b1aee9e780b3e42ca2e91d63a43fc96541a80f98 + languageName: node + linkType: hard + +"@lit-labs/ssr-dom-shim@npm:^1.0.0, @lit-labs/ssr-dom-shim@npm:^1.1.0, @lit-labs/ssr-dom-shim@npm:^1.1.2-pre.0": version: 1.1.2 resolution: "@lit-labs/ssr-dom-shim@npm:1.1.2" checksum: 73fd787893851d4ec4aaa5c775405ed2aae4ca0891b2dd3c973b32c2f4bf70ada5481dd0224e52b786d037aa8a00052186ad1623c44551affd66f6409cca8da6 @@ -2132,6 +2141,15 @@ __metadata: languageName: node linkType: hard +"@lit/reactive-element@npm:^2.0.0": + version: 2.0.0 + resolution: "@lit/reactive-element@npm:2.0.0" + dependencies: + "@lit-labs/ssr-dom-shim": ^1.1.2-pre.0 + checksum: afa12f1cf72e8735cb7eaa51d428610785ee796882ca52108310e75ac54bbf5690da718c8bf85d042060f98c139ff0d5efd54f677a9d3fc4d794ad2e0f7a12c5 + languageName: node + linkType: hard + "@lokalise/node-api@npm:12.0.0": version: 12.0.0 resolution: "@lokalise/node-api@npm:12.0.0" @@ -2497,7 +2515,7 @@ __metadata: languageName: node linkType: hard -"@material/mwc-base@npm:^0.27.0": +"@material/mwc-base@npm:0.27.0, @material/mwc-base@npm:^0.27.0": version: 0.27.0 resolution: "@material/mwc-base@npm:0.27.0" dependencies: @@ -9636,11 +9654,13 @@ __metadata: "@lezer/highlight": 1.1.6 "@lit-labs/context": 0.4.1 "@lit-labs/motion": 1.0.4 + "@lit-labs/observers": 2.0.1 "@lit-labs/virtualizer": 2.0.7 "@lokalise/node-api": 12.0.0 "@lrnwebcomponents/simple-tooltip": 7.0.18 "@material/chips": =14.0.0-canary.53b3cad2f.0 "@material/data-table": =14.0.0-canary.53b3cad2f.0 + "@material/mwc-base": 0.27.0 "@material/mwc-button": 0.27.0 "@material/mwc-checkbox": 0.27.0 "@material/mwc-circular-progress": 0.27.0