lit#LitElement TypeScript Examples

The following examples show how to use lit#LitElement. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: utils.ts    From figspec with MIT License 7 votes vote down vote up
function stylesToArray(styles: typeof LitElement.styles): CSSResultArray {
  if (!styles) {
    return [];
  }

  if (styles instanceof Array) {
    return styles;
  }

  return [styles];
}
Example #2
Source File: opc-loader.ts    From one-platform with MIT License 6 votes vote down vote up
@customElement("opc-loader")
export class OpcLoader extends LitElement {
  static styles = style;

  @property({ type: Boolean }) hidden = false;

  // to avoid overflow scroll
  willUpdate(changedProperties: Map<string, unknown>): void {
    if (changedProperties.has("hidden")) {
      document.body.style.overflowY = this.hidden ? "auto" : "hidden";
    }
  }

  render() {
    return html`
      <div class="opc-loader__container" ?hidden=${this.hidden}>
        <span class="opc-loader">L &nbsp; ading</span>
      </div>
    `;
  }
}
Example #3
Source File: utils.ts    From figspec with MIT License 6 votes vote down vote up
export function extendStyles(
  left: typeof LitElement.styles,
  right: typeof LitElement.styles
): CSSResultArray {
  return [...stylesToArray(left), ...stylesToArray(right)];
}
Example #4
Source File: minimumBodySize.ts    From starboard-notebook with Mozilla Public License 2.0 6 votes vote down vote up
@customElement("starboard-ensure-parent-fits")
export class EnsureParentFitsElement extends LitElement {
  connectedCallback() {
    if (this.parentElement) ensureFitsInBody(this.parentElement);
  }

  disconnectedCallback() {
    if (this.parentElement) removeEnsureFitsInBody(this.parentElement);
  }
}
Example #5
Source File: component.ts    From xiome with MIT License 6 votes vote down vote up
export class Component extends mixinInitiallyHidden(LitElement) {
	init() {}
	firstUpdated(changes: PropertyValues) {
		super.firstUpdated(changes)
		this.init()
	}

	#subscriptions: (() => () => void)[] = []
	addSubscription(subscribe: () => () => void) {
		this.#subscriptions.push(subscribe)
	}

	#unsubscribe = () => {}
	subscribe(): () => void {
		const unsubscribes = this.#subscriptions.map(s => s())
		return () => unsubscribes.forEach(u => u())
	}
	connectedCallback() {
		super.connectedCallback()
		this.#unsubscribe = this.subscribe()
	}
	disconnectedCallback() {
		super.disconnectedCallback()
		this.#unsubscribe()
		this.#unsubscribe = () => {}
	}

	render(): TemplateResult {
		throw new Error("component render method not implemented")
	}
}
Example #6
Source File: opc-filter-chip.ts    From one-platform with MIT License 6 votes vote down vote up
@customElement("opc-filter-chip")
export class OpcFilterChip extends LitElement {
  static styles = style;

  @property({ type: Boolean }) isChipActive = false;

  render() {
    return html`
      <button
        class="opc-notification-drawer__header-chip"
        ?active=${this.isChipActive}
        name="filter-chip"
        aria-label="filter-chip"
      >
        <slot></slot>
      </button>
    `;
  }
}
Example #7
Source File: opc-comment-input.ts    From op-components with MIT License 6 votes vote down vote up
@customElement('opc-comment-list')
export class CommentList extends LitElement {

  @property({type: Array})
  comments: OPCComment[] = [];

  static get styles() {
    return [ style ];
  }

  render() {
    return html`
      ${ !this.comments.length ?
        html`
          <div class="comment-empty-state">
            <p><strong>No comments yet!</strong> Type your comment to start the conversation</p>
          </div>` :
        html`
          ${this.comments.map(i => html`<div class="comment-item">
            <span class="user-avatar"></span>
            <div>
              <p class="user-name">${i.commenter} <small class="comment--time"> at ${i.date}</small></p>
              <p>${i.comment ? i.comment : 'comment not found'}</p>
            </div>
          <div>`)}
        `
      }
    `
  }
}
Example #8
Source File: opc-toast.ts    From one-platform with MIT License 5 votes vote down vote up
@customElement("opc-toast")
export class OpcToast extends LitElement {
  static styles = style;

  @property({ type: Object, reflect: true }) options!: ToastOptions;
  @property({ type: Object, reflect: true }) notification!: Notification;

  @property()
  get toastRef() {
    return this.renderRoot.querySelector("pfe-toast")!;
  }

  render() {
    return html`
      <pfe-toast
        auto-dismiss=${this.options.duration}
        class="op-menu-drawer__notification-toast"
        variant="${this.options.variant}"
        @pfe-toast:close=${(event: Event) => {
          this.remove();
        }}
      >
        <span
          class="op-menu-drawer__notification-time"
          title="${dayjs(this.notification.sentOn).format("LLL")}"
          >just now</span
        >
        <h5 class="op-menu-drawer__notification-subject">
          ${this.notification.link
            ? html`<a href="${this.notification.link}"
                >${this.notification.subject}</a
              >`
            : this.notification.subject}
        </h5>
        <p
          style="display: ${!!this.notification.body ? "block" : "none"};"
          class="op-menu-drawer__notification-body"
        >
          ${this.notification.body}
        </p>
      </pfe-toast>
    `;
  }
}
Example #9
Source File: consoleOutput.ts    From starboard-notebook with Mozilla Public License 2.0 5 votes vote down vote up
@customElement("starboard-console-output")
export class ConsoleOutputElement extends LitElement {
  private logHook: (m: Message) => any;
  private updatePending = false;

  @property({ attribute: false })
  public logs: any[] = [];

  constructor() {
    super();
    this.logHook = (msg) => {
      this.addEntry(msg);
    };
  }

  createRenderRoot() {
    return this;
  }

  hook(consoleCatcher: ConsoleCatcher) {
    consoleCatcher.hook(this.logHook);
  }

  unhook(consoleCatcher: ConsoleCatcher) {
    consoleCatcher.unhook(this.logHook);
  }

  async unhookAfterOneTick(consoleCatcher: ConsoleCatcher) {
    return new Promise((resolve) =>
      window.setTimeout(() => {
        this.unhook(consoleCatcher);
        resolve(undefined);
      }, 0)
    );
  }

  addEntry(msg: Message) {
    this.logs.push(msg);
    if (!this.updatePending) {
      this.updatePending = true;
      requestAnimationFrame(() => this.requestUpdate());
    }
  }

  render() {
    // We load the console output functionality asynchronously
    const comPromise = import(/* webpackChunkName: "console-output", webpackPrefetch: true */ "./consoleOutputModule");

    const rootEl = document.createElement("div");
    rootEl.classList.add("starboard-console-output-inner");
    comPromise.then((c) => {
      c.renderStandardConsoleOutputIntoElement(rootEl, this.logs);
      this.updatePending = false;
    });
    return html`${rootEl}`;
  }
}
Example #10
Source File: _index.ts    From op-components with MIT License 5 votes vote down vote up
LitElement
Example #11
Source File: ngm-map-chooser.ts    From ngm with BSD 3-Clause "New" or "Revised" License 5 votes vote down vote up
@customElement('ngm-map-chooser')
export class NgmMapChooser extends LitElement {
  @property({type: Array}) choices: BaseLayerConfig[] = [];
  @property({type: Object}) active: BaseLayerConfig | undefined = undefined;
  @property({type: Boolean}) initiallyOpened = true;
  @state() open = true;

  protected firstUpdated() {
    this.open = this.initiallyOpened;
  }

  updated() {
    this.dispatchEvent(new CustomEvent('change', {
      detail: {
        active: this.active
      }
    }));
  }

  getMapTemplate(mapConfig) {
    if (!mapConfig) return '';
    return html`
      <div class="ngm-map-preview ${classMap({active: !!(this.active && mapConfig.id === this.active.id)})}"
           @click=${() => this.active = mapConfig}>
        <img src=${mapConfig.backgroundImgSrc}>
      </div>`;
  }

  render() {
    return html`
      <div class="ngm-maps-container" .hidden=${!this.open}>
        ${this.choices.map(c => this.getMapTemplate(c))}
        <div class="ngm-close-icon" @click=${() => this.open = false}></div>
      </div>
      <div class="ngm-selected-map-container" .hidden=${this.open} @click=${() => this.open = true}>
        ${this.getMapTemplate(this.active)}
      </div>`;
  }

  createRenderRoot() {
    // no shadow dom
    return this;
  }
}
Example #12
Source File: opc-menu-drawer.ts    From op-components with MIT License 5 votes vote down vote up
@customElement('opc-menu-drawer-search')
export class OpcMenuDrawerSearch extends LitElement {
  static get styles() {
    return [opcMenuDrawerSearch];
  }

  @property({ type: String, reflect: true })
  value = '';

  @property({ type: String, reflect: true })
  placeholder = 'Search';

  @query('input')
  private input!: HTMLInputElement;

  private _handleInputChange(e: Event) {
    this.dispatchEvent(
      new CustomEvent('opc-menu-drawer-search:change', {
        detail: {
          value: this.input.value,
        },
      })
    );
  }

  render() {
    return html`
      <div class="opc-menu-drawer-search">
        <div class="opc-menu-drawer-search__item">
          <img
            src="${searchIcon}"
            class="opc-menu-drawer-search__icon"
            alt="user"
            width="12px"
            height="12px"
          />
        </div>
        <div class="flex-grow opc-menu-drawer-search__item">
          <input
            class="opc-menu-drawer-search__input"
            type="search"
            name="seach"
            aria-label=${this.placeholder}
            placeholder=${this.placeholder}
            .value=${live(this.value)}
            @input="${this._handleInputChange}"
          />
        </div>
      </div>
    `;
  }
}
Example #13
Source File: mixin-snapstate.ts    From xiome with MIT License 5 votes vote down vote up
export function mixinSnapstateTracking(...tracks: Track[]) {
	return function<C extends Constructor<LitElement>>(
			Base: C
		): Mixin<C, SnapstateTracking> {

		return <any>class extends Base implements SnapstateTracking {

			#tracks = [...tracks]
			#untracks: (() => void)[] = []

			render() {
				return super.render && super.render()
			}

			#startRenderTracking(...tracks: Track[]) {
				this.#untracks.push(...tracks.map(track => track(
					() => { this.render() },
					() => { this.requestUpdate() },
				)))
			}

			#stopRenderTracking() {
				for (const untrack of this.#untracks)
					untrack()
				this.#untracks = []
			}

			attachSnapstateTracking(...tracks: Track[]) {
				this.#tracks.push(...tracks)
				this.#startRenderTracking(...tracks)
			}

			connectedCallback() {
				super.connectedCallback()
				this.#startRenderTracking(...this.#tracks)
			}

			disconnectedCallback() {
				super.disconnectedCallback()
				this.#stopRenderTracking()
			}
		}
	}
}
Example #14
Source File: opc-nav.ts    From op-components with MIT License 5 votes vote down vote up
@customElement('opc-nav')
export class OpcNav extends LitElement {
  static get styles() {
    return [opcNavStyle];
  }

  @property({ type: String }) name = 'opc-nav';
  @property({ type: Array, reflect: true }) links: OpcNavMenuLinks[] = [];
  @property({ type: String, reflect: true }) activeButton: Active | null = null;

  private _handleButtonClick(type: 'notification' | 'menu') {
    const name = `opc-nav-btn-${type}:click`;
    this.dispatchEvent(
      new CustomEvent(name, { bubbles: true, composed: true })
    );
  }

  render() {
    return html` <header class="opc-nav">
      <div class="opc-nav-container">
        <div class="opc-nav-logo-container">
          <slot name="opc-nav-logo"></slot>
        </div>
        <slot></slot>
        <slot name="opc-nav-search"> </slot>
        <div class="opc-nav-menu-container">
          <nav class="opc-nav-menu">
            <slot name="opc-nav-menu-links">
              <ul>
                ${this.links.map(
                  ({ name, href }) =>
                    html` <li><a href="${href}">${name}</a></li> `
                )}
              </ul>
            </slot>
          </nav>
          <div class="opc-nav-btn-container">
            <slot name="opc-nav-btn">
              <button
                @click="${() => this._handleButtonClick('notification')}"
                ?active=${this.activeButton === 'notification'}
              >
                <img
                  src="${notificationIcon}"
                  alt="icons"
                  width="20px"
                  height="20px"
                />
              </button>
              <button
                @click="${() => this._handleButtonClick('menu')}"
                ?active=${this.activeButton === 'menu'}
              >
                <img
                  src="${gridIcon}"
                  alt="icons"
                  width="20px"
                  height="20px"
                  type="menu"
                />
              </button>
            </slot>
          </div>
        </div>
      </div>
    </header>`;
  }
}
Example #15
Source File: cesium-toolbar.ts    From ngm with BSD 3-Clause "New" or "Revised" License 4 votes vote down vote up
@customElement('cesium-toolbar')
export class CesiumToolbar extends LitElement {
  private viewer: Viewer | undefined | null;
  @state() show = true;
  @state() ambientOcclusionOnly = false;
  @state() intensity = 3.0;
  @state() bias = 0.1;
  @state() lengthCap = 0.03;
  @state() stepSize = 1.0;
  @state() blurStepSize = 0.86;
  @state() fogX = 10000;
  @state() fogY = 0;
  @state() fogZ = 150000;
  @state() fogIntensity = 0.3;
  @state() fogColor = '#000';
  @state() undergroundColor = '#000';
  @state() backgroundColor = '#000';

  constructor() {
    super();
    MainStore.viewer.subscribe(viewer => this.viewer = viewer);
  }

  protected updated(_changedProperties) {
    if (this.viewer) {
      const ambientOcclusion =
        this.viewer!.scene.postProcessStages.ambientOcclusion;
      ambientOcclusion.enabled =
        Boolean(this.show) || Boolean(this.ambientOcclusionOnly);
      ambientOcclusion.uniforms.ambientOcclusionOnly = Boolean(
        this.ambientOcclusionOnly
      );
      ambientOcclusion.uniforms.intensity = Number(this.intensity);
      ambientOcclusion.uniforms.bias = Number(this.bias);
      ambientOcclusion.uniforms.lengthCap = Number(this.lengthCap);
      ambientOcclusion.uniforms.stepSize = Number(this.stepSize);
      ambientOcclusion.uniforms.blurStepSize = Number(
        this.blurStepSize
      );

      const fog = this.viewer!.scene.postProcessStages.get(0);
      fog.uniforms.fogByDistance = new Cartesian4(this.fogX, this.fogY, this.fogZ, this.fogIntensity);
      fog.uniforms.fogColor = Color.fromCssColorString(this.fogColor);

      this.viewer.scene.globe.undergroundColor = Color.fromCssColorString(this.undergroundColor);
      this.viewer.scene.backgroundColor = Color.fromCssColorString(this.backgroundColor);

      this.viewer!.scene.requestRender();
    }
    super.updated(_changedProperties);
  }

  static styles = css`
    :host {
      position: absolute;
      background-color: #0000005c;
      color: white;
      margin-left: 5px;
      padding: 5px;
    }

    input[type=number] {
      width: 80px;
    }

    .divider {
      width: 100%;
      border: 1px solid #E0E3E6;
      margin: 5px 0;
    }
  `;

  render() {
    return html`
      <div>
        Ambient Occlusion
        <input type="checkbox" ?checked=${this.show} @change=${event => this.show = event.target.checked}>
      </div>
      <div>
        Ambient Occlusion Only
        <input type="checkbox" ?checked=${this.ambientOcclusionOnly}
               @change=${event => this.ambientOcclusionOnly = event.target.checked}>
      </div>
      <div>
        Intensity
        <input type="range" min="1" max="10" step="1" .value=${this.intensity}
               @input=${evt => this.intensity = Number(evt.target.value)}>
      </div>
      <div>
        Length Cap
        <input type="range" min="0" max="1" step="0.01" .value=${this.lengthCap}
               @input=${evt => this.lengthCap = Number(evt.target.value)}>
      </div>
      <div>
        Step Size
        <input type="range" min="1" max="10" step="0.01" .value=${this.stepSize}
               @input=${evt => this.stepSize = Number(evt.target.value)}></div>
      <div>
        Bias
        <input type="range" min="0" max="1" step="0.01" .value=${this.bias}
               @input=${evt => this.bias = Number(evt.target.value)}></div>
      <div>
        Blur Step Size
        <input type="range" min="0" max="4" step="0.01" .value=${this.blurStepSize}
               @input=${evt => this.blurStepSize = Number(evt.target.value)}>
      </div>
      <div class="divider"></div>
      <div>
        Fog X Direction
        <input type="range" min="0" max="1000000" step="1" .value=${this.fogX}
               @input=${evt => this.fogX = Number(evt.target.value)}>
        <input type="number" min="0" max="1000000" step="1" .value=${this.fogX}
               @input=${evt => this.fogX = Number(evt.target.value)}>
      </div>
      <div>
        Fog Y Direction
        <input type="range" min="0" max="1000000" step="1" .value=${this.fogY}
               @input=${evt => this.fogY = Number(evt.target.value)}>
        <input type="number" min="0" max="1000000" step="1" .value=${this.fogY}
               @input=${evt => this.fogY = Number(evt.target.value)}>
      </div>
      <div>
        Fog Z Direction
        <input type="range" min="0" max="1000000" step="1" .value=${this.fogZ}
               @input=${evt => this.fogZ = Number(evt.target.value)}>
        <input type="number" min="0" max="1000000" step="1" .value=${this.fogZ}
               @input=${evt => this.fogZ = Number(evt.target.value)}>
      </div>
      <div>
        Fog Opacity
        <input type="range" min="0" max="1" step="0.1" .value=${this.fogIntensity}
               @input=${evt => this.fogIntensity = Number(evt.target.value)}>
      </div>
      <div>
        Fog Color
        <input type="color" .value=${this.fogColor} @input=${evt => this.fogColor = evt.target.value}>
      </div>
      <div class="divider"></div>
      <div>
        Underground Color
        <input type="color" .value=${this.undergroundColor} @input=${evt => this.undergroundColor = evt.target.value}>
      </div>
      <div class="divider"></div>
      <div>
        Background Color
        <input type="color" .value=${this.backgroundColor} @input=${evt => this.backgroundColor = evt.target.value}>
      </div>`;
  }
}
Example #16
Source File: compass-card.ts    From compass-card with MIT License 4 votes vote down vote up
@customElement('compass-card')
export class CompassCard extends LitElement {
  public static async getConfigElement(): Promise<LovelaceCardEditor> {
    return document.createElement('compass-card-editor');
  }

  public static getStubConfig(): CompassCardConfig {
    return {
      type: 'custom:compass-card',
      indicator_sensors: [{ sensor: 'sun.sun', attribute: 'azimuth' }],
    };
  }

  @property({ attribute: false }) public _hass!: HomeAssistant;
  @property({ attribute: false }) protected _config!: CompassCardConfig;
  @state() protected colors!: CCColors;
  @state() protected header!: CCHeader;
  @state() protected compass!: CCCompass;
  @state() protected indicatorSensors!: CCIndicatorSensor[];
  @state() protected entities: HassEntities = {};
  @state() protected valueSensors!: CCValueSensor[];

  public setConfig(config: CompassCardConfig): void {
    if (!config) {
      throw new Error(localize('common.invalid_configuration'));
    }

    if (!config.indicator_sensors || !config.indicator_sensors[0].sensor) {
      throw new Error(localize('common.missing_direction_entity'));
    }

    if (config.test_gui) {
      getLovelace().setEditMode(true);
    }

    this.colors = {
      accent: 'var(--accent-color)',
      primary: 'var(--primary-color)',
      stateIcon: 'var(--state-icon-color)',
      secondaryText: 'var(--secondary-text-color)',
      primaryText: 'var(--primary-text-color)',
    };

    this._config = {
      ...config,
    };

    this.updateConfig(this._hass, this._config);
  }

  public getCardSize(): number {
    return 5;
  }

  set hass(hass: HomeAssistant) {
    this._hass = hass;
    this.updateConfig(this._hass, this._config);
  }

  protected shouldUpdate(changedProps: PropertyValues): boolean {
    if (changedProps.has('_config')) {
      return true;
    }
    if (changedProps.has('_hass')) {
      const newHass = changedProps.get('_hass') as HomeAssistant;
      for (const entity in this.entities) {
        if (newHass.states[entity].last_updated !== this._hass.states[entity].last_updated) {
          return true;
        }
      }
    }
    return false;
  }

  private updateConfig(hass: HomeAssistant, config: CompassCardConfig): void {
    if (!hass || !config) {
      return;
    }
    const stringEntities = findValues(this._config, hass.states, getBoolean(this._config.debug, false));
    stringEntities.forEach((stringEntity) => {
      if (this._hass.states[stringEntity]) {
        const entity = this._hass.states[stringEntity];
        this.entities[entity.entity_id] = this._hass.states[stringEntity];
      }
    });
    this.header = getHeader(this._config, this.colors, this.entities[this._config?.indicator_sensors[0].sensor], this.entities);
    this.compass = getCompass(this._config, this.colors, this.entities);
    this.indicatorSensors = getIndicatorSensors(this._config, this.colors, this.entities);
    this.valueSensors = getValueSensors(this._config, this.colors, this.entities);
    if (getBoolean(this._config.debug, false)) {
      console.info('Compass-Card inflated configuration: header', this.header); // eslint-disable-line
      console.info('Compass-Card inflated configuration: compass', this.compass); // eslint-disable-line
      console.info('Compass-Card inflated configuration: indicator sensors', this.indicatorSensors); //eslint-disable-line
      console.info('Compass-Card inflated configuration: value sensors', this.valueSensors); //eslint-disable-line
      console.info('Compass-Card configuration: listening to entities', this.entities); // eslint-disable-line
    }
  }

  protected render(): TemplateResult {
    if (!this._config || !this._hass) {
      return html``;
    }

    return html`
      <ha-card tabindex="0" .label=${`Compass: ${this.header.label}`} class="flex compass-card" @click=${(e) => this.handlePopup(e)}>
        ${this.getVisibility(this.header.title) || this.getVisibility(this.header.icon) ? this.renderHeader() : ''}
        <div class="content">
          <div class="compass">${this.svgCompass(this.compass.north.offset)}</div>
          <div class="indicator-sensors">${this.renderDirections()}</div>
          <div class="value-sensors">${this.renderValues()}</div>
        </div>
      </ha-card>
    `;
  }

  /**
   * Render Header (title and icon on top of card)
   */

  private renderHeader(): TemplateResult {
    return html`
      <div class="header">
        <div class="name" style="color:${this.getColor(this.header.title)};">${this.getVisibility(this.header.title) ? this.renderTitle() : html`<span>&nbsp;</span>`}</div>
        <div class="icon" style="color:${this.getColor(this.header.icon)};">${this.getVisibility(this.header.icon) ? this.renderIcon() : html`<span>&nbsp;</span>`}</div>
      </div>
    `;
  }

  private renderTitle(): TemplateResult {
    return html`<span>${this.header.title.value} </span>`;
  }

  private renderIcon(): TemplateResult {
    return html`<ha-icon .icon=${this.header.icon.value}></ha-icon>`;
  }

  /**
   * Render Directions (abbreviation/degrees inside compass)
   */

  private renderDirections(): TemplateResult[] {
    const divs: TemplateResult[] = [];
    let index = 0;

    this.indicatorSensors.forEach((indicator) => {
      if (this.getVisibility(indicator.state_abbreviation) || this.getVisibility(indicator.state_value)) {
        divs.push(html`<div class="sensor-${index}">
          <span class="abbr" style="color: ${this.getColor(indicator.state_abbreviation)};"
            >${this.getVisibility(indicator.state_abbreviation) ? this.computeIndicator(indicator).abbreviation : ''}</span
          >
          <span class="value" style="color: ${this.getColor(indicator.state_value)};"
            >${this.getVisibility(indicator.state_value) ? this.computeIndicator(indicator).degrees.toFixed(indicator.decimals) : ''}</span
          >
          <span class="measurement" style="color: ${this.getColor(indicator.state_units)};">${this.getVisibility(indicator.state_units) ? indicator.units : ''}</span>
        </div>`);
        index++;
      }
    });
    return divs;
  }

  /**
   * Render Values
   */

  private renderValues(): TemplateResult[] {
    const divs: TemplateResult[] = [];
    let index = 0;
    this.valueSensors.forEach((value) => {
      if (this.getVisibility(value.state_value)) {
        divs.push(html`<div class="sensor-${index}">
          <span class="value" style="color: ${this.getColor(value.state_value)};">${this.getVisibility(value.state_value) ? this.getValue(value).value : ''}</span>
          <span class="measurement" style="color: ${this.getColor(value.state_units)};">${this.getVisibility(value.state_units) ? value.units : ''}</span>
        </div>`);
        index++;
      }
    });
    return divs;
  }

  private getVisibility(properties: CCProperties): boolean {
    if (properties.dynamic_style.bands.length === 0) {
      return properties.show;
    }
    const value = this.getValue(properties.dynamic_style);
    if (isNumeric(value.value)) {
      const usableBands = properties.dynamic_style.bands.filter((band) => band.from_value <= Number(value.value));
      return getBoolean(usableBands[usableBands.length - 1]?.show, properties.show);
    }
    return properties.show;
  }

  private getColor(properties: CCProperties): string {
    if (properties.dynamic_style.bands.length === 0) {
      return properties.color;
    }
    const value = this.getValue(properties.dynamic_style);
    if (isNumeric(value.value)) {
      const usableBands = properties.dynamic_style.bands.filter((band) => band.from_value <= Number(value.value));
      return usableBands[usableBands.length - 1]?.color || properties.color;
    }
    return properties.color;
  }

  /**
   * Draw compass with indicators
   */

  private svgCompass(directionOffset: number): SVGTemplateResult {
    return svg`
    <svg viewbox="0 0 152 152" preserveAspectRatio="xMidYMin slice" style="width: 100%; padding-bottom: 92%; height: 1px; overflow: visible">
      <defs>
        <pattern id="image" x="0" y="0" patternContentUnits="objectBoundingBox" height="100%" width="100%">
          <image x="0" y="0" height="1" width="1" href="${this.compass.circle.background_image}" preserveAspectRatio="xMidYMid meet"></image>
        </pattern>        
      </defs>
      ${this.getVisibility(this.compass.circle) ? this.svgCircle(this.compass.circle.offset_background ? directionOffset : 0) : ''}
        <g class="indicators" transform="rotate(${directionOffset},76,76)" stroke-width=".5">
          ${this.compass.north.show ? this.svgIndicatorNorth() : ''}
          ${this.compass.east.show ? this.svgIndicatorEast() : ''}
          ${this.compass.south.show ? this.svgIndicatorSouth() : ''}
          ${this.compass.west.show ? this.svgIndicatorWest() : ''}
          ${this.svgIndicators()}
        </g>
    </svg>
    `;
  }

  private svgCircle(directionOffset: number): SVGTemplateResult {
    return svg`<circle class="circle" cx="76" cy="76" r="62" stroke="${this.getColor(this.compass.circle)}" stroke-width="2" fill="${this.circleFill()}" fill-opacity="${
      this.compass.circle.background_opacity
    }" stroke-opacity="1.0" transform="rotate(${directionOffset},76,76)" />`;
  }

  private circleFill(): string {
    return this.compass.circle.background_image === '' ? 'white' : 'url(#image)';
  }

  private svgIndicators(): SVGTemplateResult[] {
    const result: SVGTemplateResult[] = [];
    this.indicatorSensors.forEach((indicatorSensor) => {
      if (this.getVisibility(indicatorSensor.indicator)) {
        result.push(this.svgSingleIndicator(indicatorSensor));
      }
    });
    return result;
  }

  private svgIndicator(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    switch (indicatorSensor.indicator.type) {
      case 'arrow_outward':
        return this.svgIndicatorArrowOutward(indicatorSensor);
      case 'circle':
        return this.svgIndicatorCircle(indicatorSensor);
      default:
    }
    return this.svgIndicatorArrowInward(indicatorSensor);
  }

  private svgSingleIndicator(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    const indicatorPath = this.svgIndicator(indicatorSensor);
    const { degrees } = this.computeIndicator(indicatorSensor);

    return svg`
      <g class="indicator" transform="rotate(${degrees},76,76)">
        ${indicatorPath}
      </g>
    `;
  }

  private svgIndicatorArrowOutward(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="arrow-outward">
        <path d="M76 0v23l-8 7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width=".5"/>
        <path d="M76 0v23l8 7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width="0"/>
        <path d="M76 0v23l8 7z" fill="white" opacity="0.5" stroke="white" stroke-width=".5"/>
      </g>
    `;
  }

  private svgIndicatorArrowInward(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="arrow-inward">
        <path d="M76 30.664v-23l-8-7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width=".5" />
        <path d="M76 30.664v-23l8-7z" fill="${this.getColor(indicatorSensor.indicator)}" stroke="${this.getColor(indicatorSensor.indicator)}" stroke-width="0" />
        <path d="M76 30.664v-23l8-7z" fill="white" opacity="0.5" stroke="white" stroke-width=".5" />
      </g>
    `;
  }

  private svgIndicatorCircle(indicatorSensor: CCIndicatorSensor): SVGTemplateResult {
    return svg`
      <g class="circle">
        <path d="m76 5.8262a9.1809 9.1809 0 0 0-0.0244 0 9.1809 9.1809 0 0 0-9.1813 9.18 9.1809 9.1809 0 0 0 9.1813 9.1813 9.1809 9.1809 0 0 0 0.0244 0z" fill="${this.getColor(
          indicatorSensor.indicator,
        )}"/>
        <path d="m76 5.8262v18.361a9.1809 9.1809 0 0 0 9.1556-9.1813 9.1809 9.1809 0 0 0-9.1556-9.18z" fill="${this.getColor(indicatorSensor.indicator)}"/>
        <path d="m76 5.8262v18.361a9.1809 9.1809 0 0 0 9.1556-9.1813 9.1809 9.1809 0 0 0-9.1556-9.18z" fill="white" opacity="0.5"/>
      </g>
    `;
  }

  private svgIndicatorNorth(): SVGTemplateResult {
    return svg`
      <g class="north">
        <text x="76" y="10.089" font-family="sans-serif" font-size="13.333" text-anchor="middle" fill="${this.getColor(this.compass.north)}">
          <tspan x="76" y="11">${localize('directions.N', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }

  private svgIndicatorEast(): SVGTemplateResult {
    return svg`
      <g class="east">
        <text x="140" y="80.089" font-family="sans-serif" font-size="13.333" text-anchor="right" fill="${this.getColor(this.compass.east)}">
          <tspan x="140" y="81">${localize('directions.E', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }

  private svgIndicatorSouth(): SVGTemplateResult {
    return svg`
      <g class="south">
        <text x="76" y="150.089" font-family="sans-serif" font-size="13.333" text-anchor="middle" fill="${this.getColor(this.compass.south)}">
          <tspan x="76" y="151">${localize('directions.S', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }

  private svgIndicatorWest(): SVGTemplateResult {
    return svg`
      <g class="west">
        <text x="-2" y="80.089" font-family="sans-serif" font-size="13.333" text-anchor="left" fill="${this.getColor(this.compass.west)}">
          <tspan x="-2" y="81">${localize('directions.W', '', '', this._config.language)}</tspan>
        </text>
      </g>
    `;
  }

  private getValue(entity: CCEntity): CCValue {
    if (entity.is_attribute) {
      const entityStr = entity.sensor.slice(0, entity.sensor.lastIndexOf('.'));
      const entityObj = this.entities[entityStr];
      if (entityObj && entityObj.attributes) {
        const attribStr = entity.sensor.slice(entity.sensor.lastIndexOf('.') + 1);
        const value = entityObj.attributes[attribStr] || UNAVAILABLE;
        return { value: isNumeric(value) ? Number(value).toFixed(entity.decimals) : value, units: entity.units };
      }
      return { value: UNAVAILABLE, units: entity.units };
    }
    const value = this.entities[entity.sensor]?.state || UNAVAILABLE;
    return { value: isNumeric(value) ? Number(value).toFixed(entity.decimals) : value, units: entity.units };
  }

  private handlePopup(e) {
    e.stopPropagation();
    if (this._config.tap_action) {
      handleClick(this, this._hass, this._config, this._config.tap_action);
    }
  }

  private computeIndicator(entity: CCEntity): CCDirectionInfo {
    // default to North
    let degrees = 0;
    let abbreviation = localize('common.invalid');

    /* The direction entity may either return degrees or a named abbreviations, thus
           determine the degrees and abbreviation with whichever data was returned. */
    const directionStr = this.getValue(entity);

    if (Number.isNaN(Number(directionStr.value))) {
      degrees = CompassCard.getDegrees(directionStr.value);
      abbreviation = directionStr.value;
      if (degrees === -1) {
        const matches = directionStr.value.replace(/\s+/g, '').match(/[+-]?\d+(\.\d)?/);
        if (matches?.length) {
          degrees = CompassCard.positiveDegrees(parseFloat(matches[0]));
        } else {
          degrees = 0;
        }
        abbreviation = CompassCard.getCompassAbbreviation(degrees, this._config.language);
      } else {
        abbreviation = CompassCard.getCompassAbbreviation(degrees, this._config.language);
      }
    } else {
      degrees = CompassCard.positiveDegrees(parseFloat(directionStr.value));
      abbreviation = CompassCard.getCompassAbbreviation(degrees, this._config.language);
    }
    return { abbreviation, degrees: Math.round(degrees) };
  }

  static get styles(): CSSResult {
    return style;
  }

  static getDegrees(abbrevation: string): number {
    if (COMPASS_POINTS[abbrevation]) {
      return COMPASS_POINTS[abbrevation];
    }
    return -1;
  }

  static getCompassAbbreviation(degrees: number, language: string | undefined): string {
    const index = Math.round(CompassCard.positiveDegrees(degrees) / 22.5);
    let string = 'N';
    string = COMPASS_ABBREVIATIONS[index];
    if (index > 15) {
      string = COMPASS_ABBREVIATIONS[0];
    }
    return localize(`directions.${string}`, '', '', language);
  }

  static positiveDegrees(degrees: number): number {
    return degrees < 0 ? degrees + (Math.abs(Math.ceil(degrees / 360)) + 1) * 360 : degrees % 360;
  }
}
Example #17
Source File: ViewerMixin.ts    From figspec with MIT License 4 votes vote down vote up
ViewerMixin = <T extends Constructor<LitElement>>(
  superClass: T
): T & Constructor<IViewer & INodeSelectable & Positioned> => {
  class Viewer extends NodeSelectableMixin(PositionedMixin(superClass)) {
    @property({
      type: Number,
      attribute: "zoom-margin",
    })
    zoomMargin: number = 50;

    @property({
      type: String,
      attribute: "link",
    })
    link: string = "";

    static get styles() {
      // @ts-ignore
      const styles = super.styles;

      return extendStyles(styles, [
        css`
          :host {
            --default-error-bg: #fff;
            --default-error-fg: #333;

            --bg: var(--figspec-viewer-bg, #e5e5e5);
            --z-index: var(--figspec-viewer-z-index, 0);
            --error-bg: var(--figspec-viewer-error-bg, var(--default-error-bg));
            --error-fg: var(--figspec-viewer-error-fg, var(--default-error-fg));
            --error-color: var(--figspec-viewer-error-color, tomato);

            --guide-thickness: var(--figspec-viewer-guide-thickness, 1.5px);
            --guide-color: var(--figspec-viewer-guide-color, tomato);
            --guide-selected-color: var(
              --figspec-viewer-guide-selected-color,
              dodgerblue
            );
            --guide-tooltip-fg: var(--figspec-viewer-guide-tooltip-fg, white);
            --guide-selected-tooltip-fg: var(
              --figspec-viewer-guide-selected-tooltip-fg,
              white
            );
            --guide-tooltip-bg: var(
              --figspec-viewer-guide-tooltip-bg,
              var(--guide-color)
            );
            --guide-selected-tooltip-bg: var(
              --figspec-viewer-guide-selected-tooltip-bg,
              var(--guide-selected-color)
            );
            --guide-tooltip-font-size: var(
              --figspec-viewer-guide-tooltip-font-size,
              12px
            );

            position: relative;
            display: block;

            background-color: var(--bg);
            user-select: none;
            overflow: hidden;
            z-index: var(--z-index);
          }

          @media (prefers-color-scheme: dark) {
            :host {
              --default-error-bg: #222;
              --default-error-fg: #fff;
            }
          }

          .spec-canvas-wrapper {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            display: flex;
            flex-direction: column-reverse;
          }

          .canvas {
            position: absolute;
            top: 50%;
            left: 50%;
            flex: 1;
          }

          .rendered-image {
            position: absolute;
            top: 0;
            left: 0;
          }

          .guides {
            position: absolute;

            overflow: visible;
            stroke: var(--guide-color);
            fill: var(--guide-color);
            pointer-events: none;
            z-index: calc(var(--z-index) + 2);
          }
        `,
        Node.styles,
        ErrorMessage.styles,
        DistanceGuide.styles,
        InspectorView.styles,
        FigmaFooter.styles,
      ]);
    }

    get __images(): Record<string, string> {
      return {};
    }

    // Cached values
    #canvasSize?: Figma.Rect;
    #effectMargins?: Record<string, Margin>;
    #flattenedNodes?: readonly SizedNode[];

    constructor(...args: any[]) {
      super(...args);
    }

    deselectNode() {
      this.selectedNode = null;
    }

    get error(): string | Error | null | TemplateResult | undefined {
      if (!this.#canvasSize || !this.#flattenedNodes) {
        return ErrorMessage.ErrorMessage({
          title: "Error",
          children:
            "Please call `__updateTree/1` method with a valid parameter.",
        });
      }

      return null;
    }

    render() {
      if (this.error) {
        if (this.error instanceof Error) {
          return ErrorMessage.ErrorMessage({
            title: this.error.name || "Error",
            children: this.error.message,
          });
        }

        if (typeof this.error === "string") {
          return ErrorMessage.ErrorMessage({
            title: "Error",
            children: this.error,
          });
        }

        return this.error;
      }

      const canvasSize = this.#canvasSize!;

      const reverseScale = 1 / this.scale;

      const guideThickness = `calc(var(--guide-thickness) * ${reverseScale})`;

      const computedGuideThickness = parseFloat(
        getComputedStyle(this).getPropertyValue("--guide-thickness")
      );

      const computedGuideTooltipFontSize = parseFloat(
        getComputedStyle(this).getPropertyValue("--guide-tooltip-font-size")
      );

      return html`
        <div class="spec-canvas-wrapper" @click=${this.deselectNode}>
          <div
            class="canvas"
            style="
          width: ${canvasSize.width}px;
          height: ${canvasSize.height}px;

          transform: translate(-50%, -50%) ${this.canvasTransform.join(" ")}
        "
          >
            ${Object.entries(this.__images).map(([nodeId, uri]) => {
              const node = this.#getNodeById(nodeId);

              if (
                !node ||
                !("absoluteBoundingBox" in node) ||
                !this.#effectMargins?.[node.id]
              ) {
                return null;
              }

              const margin = this.#effectMargins[node.id];

              return html`
                <img class="rendered-image" src="${uri}"
                style=${styleMap({
                  top: `${node.absoluteBoundingBox.y - canvasSize.y}px`,
                  left: `${node.absoluteBoundingBox.x - canvasSize.x}px`,
                  marginTop: `${-margin.top}px`,
                  marginLeft: `${-margin.left}px`,
                  width:
                    node.absoluteBoundingBox.width +
                    margin.left +
                    margin.right +
                    "px",
                  height:
                    node.absoluteBoundingBox.height +
                    margin.top +
                    margin.bottom +
                    "px",
                })}"
                " />
              `;
            })}
            ${this.selectedNode &&
            Node.Tooltip({
              nodeSize: this.selectedNode.absoluteBoundingBox,
              offsetX: -canvasSize.x,
              offsetY: -canvasSize.y,
              reverseScale,
            })}
            ${svg`
            <svg
              class="guides"
              viewBox="0 0 5 5"
              width="5"
              height="5"
              style=${styleMap({
                left: `${-canvasSize.x}px`,
                top: `${-canvasSize.y}px`,
                strokeWidth: guideThickness,
              })}
            >
              ${
                this.selectedNode &&
                Node.Outline({
                  node: this.selectedNode,
                  selected: true,
                  computedThickness: computedGuideThickness * reverseScale,
                })
              }

              ${this.#flattenedNodes!.map((node) => {
                if (node.id === this.selectedNode?.id) {
                  return null;
                }

                return svg`
                  <g>
                    ${Node.Outline({
                      node,
                      computedThickness: computedGuideThickness * reverseScale,
                      onClick: this.#handleNodeClick(node),
                    })}
                    ${
                      this.selectedNode &&
                      DistanceGuide.Guides({
                        node,
                        distanceTo: this.selectedNode,
                        reverseScale,
                        fontSize: computedGuideTooltipFontSize,
                      })
                    }
                  </g>
                `;
              })}
            </svg>
          `}
          </div>
          ${InspectorView.View({
            node: this.selectedNode as FigmaNode,
            onClose: this.deselectNode,
          })}
          ${FigmaFooter.Footer(this.getMetadata())}
        </div>
      `;
    }

    // implemented in FileViewer/FrameViewer
    getMetadata() {
      return undefined;
    }

    connectedCallback() {
      super.connectedCallback();

      this.resetZoom();
    }

    updated(changedProperties: Parameters<LitElement["updated"]>[0]) {
      super.updated(changedProperties);
    }

    __updateTree(node: Figma.Node) {
      if (
        !(
          node.type === "CANVAS" ||
          node.type === "FRAME" ||
          node.type === "COMPONENT" ||
          //@ts-ignore NOTE: figma-js does not implement COMPONENT_SET type (yet?)
          node.type === "COMPONENT_SET"
        )
      ) {
        throw new Error(
          "Cannot update node tree: Top level node MUST be one of CANVAS, FRAME, COMPONENT, or COMPONENT_SET"
        );
      }

      this.#canvasSize =
        node.type === "CANVAS" ? getCanvasSize(node) : node.absoluteBoundingBox;

      this.#flattenedNodes = flattenNode(node);

      // Since above properties aren't "attribute", their changes does not
      // trigger an update. We need to manually request an update.
      this.requestUpdate();
    }

    __updateEffectMargins() {
      if (!this.__images) {
        return;
      }

      const containers = Object.keys(this.__images)
        .map(this.#getNodeById)
        .filter((n): n is NonNullable<typeof n> => !!n);

      this.#effectMargins = containers.reduce<Record<string, Margin>>(
        (margin, node) => {
          if (!("absoluteBoundingBox" in node)) {
            return margin;
          }

          return {
            ...margin,
            [node.id]: getEffectMargin(node, flattenNode(node)),
          };
        },
        {}
      );

      this.requestUpdate();
    }

    resetZoom() {
      if (this.#canvasSize) {
        // Set initial zoom level based on element size
        const { width, height } = this.#canvasSize;
        const {
          width: elementWidth,
          height: elementHeight,
        } = this.getBoundingClientRect();

        const wDiff = elementWidth / (width + this.zoomMargin * 2);
        const hDiff = elementHeight / (height + this.zoomMargin * 2);

        this.scale = Math.min(wDiff, hDiff, 1);
      }
    }

    #handleNodeClick = (node: SizedNode) => (ev: MouseEvent) => {
      ev.preventDefault();
      ev.stopPropagation();

      this.selectedNode = node;
    };

    #getNodeById = (id: string): Figma.Node | null => {
      return this.#flattenedNodes?.find((n) => n.id === id) ?? null;
    };
  }

  return Viewer;
}
Example #18
Source File: index-editor.ts    From atomic-calendar-revive with MIT License 4 votes vote down vote up
@customElement('atomic-calendar-revive-editor')
export class AtomicCalendarReviveEditor extends LitElement implements LovelaceCardEditor {
	@property({ attribute: false })
	public hass!: HomeAssistant;
	@state() private _config!: atomicCardConfig;
	@state() private _toggle?: boolean;
	@state() private _helpers?: any;
	private _initialized = false;

	static get styles(): CSSResult {
		return style;
	}

	public setConfig(config: atomicCardConfig): void {
		this._config = config;

		this.loadCardHelpers();
	}

	protected shouldUpdate(): boolean {
		if (!this._initialized) {
			this._initialize();
		}

		return true;
	}

	// ENTITY SETTINGS
	get _entityOptions() {
		const entities = Object.keys(this.hass.states).filter(eid => eid.substr(0, eid.indexOf('.')) === 'calendar');

		const entityOptions = entities.map(eid => {
			let matchingConfigEnitity = this._config?.entities.find(entity => (entity && entity.entity || entity) === eid);
			const originalEntity = this.hass.states[eid];

			if (matchingConfigEnitity === undefined) {

				matchingConfigEnitity = {
					entity: eid,
					name: (matchingConfigEnitity && matchingConfigEnitity.name) || originalEntity.attributes.friendly_name || eid,
					checked: !!matchingConfigEnitity
				}

			} else {
				if (!('name' in matchingConfigEnitity)) {
					matchingConfigEnitity = { ...matchingConfigEnitity, name: (matchingConfigEnitity && matchingConfigEnitity.name) || originalEntity.attributes.friendly_name || eid }
				}
				matchingConfigEnitity = { ...matchingConfigEnitity, checked: !!matchingConfigEnitity }

			}
			return matchingConfigEnitity
		});
		return entityOptions;
	}

	//MAIN SETTINGS
	get _name(): string {
		return this._config?.name || '';
	}

	get _firstDayOfWeek(): number {
		return this._config?.firstDayOfWeek || 1;
	}

	get _maxDaysToShow(): number {
		return this._config?.maxDaysToShow || 7;
	}

	get _linkTarget(): string {
		return this._config?.linkTarget || '_blank';
	}
	get _defaultMode(): string {
		return this._config?.defaultMode || 'Event';
	}
	get _cardHeight(): string {
		return this._config?.cardHeight || '100%';
	}

	get _showLocation(): boolean {
		return this._config?.showLocation || true;
	}
	get _showLoader(): boolean {
		return this._config?.showLoader || true;
	}
	get _sortByStartTime(): boolean {
		return this._config?.sortByStartTime || false;
	}
	get _showDeclined(): boolean {
		return this._config?.showDeclined || false;
	}
	get _hideDuplicates(): boolean {
		return this._config?.hideDuplicates || false;
	}
	get _showMultiDay(): boolean {
		return this._config?.showMultiDay || false;
	}
	get _showMultiDayEventParts(): boolean {
		return this._config?.showMultiDayEventParts || false;
	}

	get _dateFormat(): string {
		return this._config?.dateFormat || 'LL';
	}
	get _hoursFormat(): string {
		return this._config?.hoursFormat || '';
	}
	get _refreshInterval(): number {
		return this._config?.refreshInterval || 60;
	}
	get _showDate(): boolean {
		return this._config?.showDate || false;
	}
	get _showRelativeTime(): boolean {
		return this._config?.showRelativeTime || false;
	}
	// MAIN SETTINGS END

	// EVENT SETTINGS

	get _showCurrentEventLine(): boolean {
		return this._config?.showCurrentEventLine || false;
	}

	get _showProgressBar(): boolean {
		return this._config?.showProgressBar || true;

	}

	get _showMonth(): boolean {
		return this._config?.showMonth || false;
	}
	get _showWeekDay(): boolean {
		return this._config?.showWeekDay || false;
	}
	get _showDescription(): boolean {
		return this._config?.showDescription || true;
	}
	get _disableEventLink(): boolean {
		return this._config?.disableEventLink || false;
	}
	get _disableLocationLink(): boolean {
		return this._config?.disableLocationLink || false;
	}
	get _showNoEventsForToday(): boolean {
		return this._config?.showNoEventsForToday || false;
	}
	get _showCalNameInEvent(): boolean {
		return this._config?.showCalNameInEvent || false;
	}
	get _showFullDayProgress(): boolean {
		return this._config?.showFullDayProgress || false;
	}
	get _hideFinishedEvents(): boolean {
		return this._config?.hideFinishedEvents || false;
	}
	get _showEventIcon(): boolean {
		return this._config?.showEventIcon || false;
	}
	get _untilText(): string {
		return this._config?.untilText || '';
	}
	get _fullDayEventText(): string {
		return this._config?.fullDayEventText || '';
	}
	get _noEventsForNextDaysText(): string {
		return this._config?.noEventsForNextDaysText || '';
	}
	get _noEventText(): string {
		return this._config?.noEventText || '';
	}
	get _showHiddenText(): boolean {
		return this._config?.showHiddenText || false;
	}
	get _showCalendarName(): boolean {
		return this._config?.showCalendarName || false;
	}
	get _hiddenEventText(): string {
		return this._config?.hiddenEventText || '';
	}
	get _showWeekNumber(): boolean {
		return this._config?.showWeekNumber || false;
	}
	// EVENT SETTINGS END

	// CALENDAR SETTINGS
	get _showLastCalendarWeek(): boolean {
		return this._config?.showLastCalendarWeek || false;
	}
	get _disableCalEventLink(): boolean {
		return this._config?.disableCalEventLink || false;
	}
	get _disableCalLocationLink(): boolean {
		return this._config?.disableCalLocationLink || false;
	}
	get _calShowDescription(): boolean {
		return this._config?.calShowDescription || false;
	}

	get _disableCalLink(): boolean {
		return this._config?.disableCalLink || false;
	}

	// CALENDAR SETTINGS END

	// APPEARENCE SETTINGS

	get _locationLinkColor(): string {
		return this._config?.locationLinkColor || '';
	}
	get _dimFinishedEvents(): boolean {
		return this._config?.dimFinishedEvents || true;
	}

	// APPEARENCE SETTINGS END

	protected render(): TemplateResult | void {
		if (!this.hass || !this._helpers) {
			return html``;
		}

		// The climate more-info has ha-switch and paper-dropdown-menu elements that are lazy loaded unless explicitly done here
		this._helpers.importMoreInfoControl('climate');

		// You can restrict on domain type
		const entities = Object.keys(this.hass.states).filter(eid => eid.substr(0, eid.indexOf('.')) === 'calendar');

		return html`
			<div class="card-config">
				<div class="option" @click=${this._toggleOption} .option=${'required'}>
					<div class="row">
						<ha-icon .icon=${`mdi:${options.required.icon}`}></ha-icon>
						<div class="title">${localize('required.name')}</div>
					</div>
					<div class="secondary">${localize('required.secondary')}</div>
				</div>
				${options.required.show
				? html`
							<div class="entities">
							${this._entityOptions.map(entity => {
					return html`
								  <div>
								  	<ha-switch
										.checked=${entity.checked}
										.entityId=${entity.entity}
										@change="${this._entityChanged}"
									></ha-switch>
									<label class="mdc-label">${entity.entity}</label>
									${entity.checked ? html`
									<div class="side-by-side">
										<div>
											<paper-input
												label="Name"
												.value="${entity.name}"
												.configValue=${'name'}
												.entityId="${entity.entity}"
												@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
										<div>
											<paper-input
												label="Icon"
												.value="${entity.icon === undefined ? '' : entity.icon}"
												.configValue=${'icon'}
												.entityId="${entity.entity}"
												@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
									</div>
									<div class="side-by-side">
										<div>
											<paper-input
												label="startTimeFilter"
												.value="${entity.startTimeFilter === undefined ? '' : entity.startTimeFilter}"
												.configValue=${'startTimeFilter'}
												.entityId="${entity.entity}"
												@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
										<div>
											<paper-input
												label="endTimeFilter"
												.value="${entity.endTimeFilter === undefined ? '' : entity.endTimeFilter}"
												.configValue=${'endTimeFilter'}
												.entityId="${entity.entity}"
												@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
									</div>
									<div class="side-by-side">
										<div>
											<paper-input
											label="maxDaysToShow"
											.value="${entity.maxDaysToShow === undefined ? '' : entity.maxDaysToShow}"
											.configValue=${'maxDaysToShow'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
										<div>
											<paper-input
											label="showMultiDay"
											.value="${entity.showMultiDay === undefined ? '' : entity.showMultiDay}"
											.configValue=${'showMultiDay'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
									</div>
									<div class="side-by-side">
										<div>
											<paper-input
											label="blocklist"
											.value="${entity.blocklist === undefined ? '' : entity.blocklist}"
											.configValue=${'blocklist'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
										<div>
											<paper-input
											label="blocklistLocation"
											.value="${entity.blocklistLocation === undefined ? '' : entity.blocklistLocation}"
											.configValue=${'blocklistLocation'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
									</div>
									<div class="side-by-side">
										<div>
											<paper-input
											label="allowlist"
											.value="${entity.allowlist === undefined ? '' : entity.allowlist}"
											.configValue=${'allowlist'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
										<div>
											<paper-input
											label="allowlistLocation"
											.value="${entity.allowlistLocation === undefined ? '' : entity.allowlistLocation}"
											.configValue=${'allowlistLocation'}
											.entityId="${entity.entity}"
											@value-changed="${this._entityValueChanged}"
											></paper-input>
										</div>
									</div>` : html``
						}

								  </div>
								`;
				})
					}
							</div>
					  `
				: ''}
				<!-- MAIN SETTINGS -->
				<div class="option" @click=${this._toggleOption} .option=${'main'}>
					<div class="row">
						<ha-icon .icon=${`mdi:${options.main.icon}`}></ha-icon>
						<div class="title">${localize('main.name')}</div>
					</div>
					<div class="secondary">${localize('main.secondary')}</div>
				</div>
				${options.main.show
				? html`
							<div class="values">
								<paper-input
									label="${localize('main.fields.name')}"
									.value=${this._name}
									.configValue=${'name'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<div class="side-by-side">
									<div>
										<paper-input
											label="${localize('main.fields.firstDayOfWeek')}"
											type="number"
											.value=${this._firstDayOfWeek}
											.configValue=${'firstDayOfWeek'}
											@value-changed=${this._valueChanged}
										></paper-input>
									</div>
									<div>
										<paper-input
											label="${localize('main.fields.maxDaysToShow')}"
											type="number"
											.value=${this._maxDaysToShow}
											.configValue=${'maxDaysToShow'}
											@value-changed=${this._valueChanged}
										></paper-input>
									</div>
								</div>
								<paper-input
									label="${localize('main.fields.refreshInterval')}"
									type="number"
									.value=${this._refreshInterval}
									.configValue=${'refreshInterval'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('main.fields.dateFormat')}"
									.value=${this._dateFormat}
									.configValue=${'dateFormat'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('main.fields.hoursFormat')}"
									.value=${this._hoursFormat}
									.configValue=${'hoursFormat'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-dropdown-menu
									label="${localize('main.fields.defaultMode')}"
									@value-changed=${this._valueChanged}
									.configValue=${'defaultMode'}
								>
									<paper-listbox slot="dropdown-content" .selected=${defaultModes.indexOf(this._defaultMode)}>
										${defaultModes.map((mode) => {
					return html` <paper-item>${mode}</paper-item> `;
				})}
									</paper-listbox>
								</paper-dropdown-menu>
								<paper-dropdown-menu
									label="${localize('main.fields.linkTarget')}"
									@value-changed=${this._valueChanged}
									.configValue=${'linkTarget'}
								>
									<paper-listbox slot="dropdown-content" .selected=${linkTargets.indexOf(this._linkTarget)}>
										${linkTargets.map((linkTarget) => {
					return html` <paper-item>${linkTarget}</paper-item> `;
				})}
									</paper-listbox> </paper-dropdown-menu
								><br />
								<div class="side-by-side">
									<div>
										<paper-input
											label="${localize('main.fields.cardHeight')}"
											.value=${this._cardHeight}
											.configValue=${'cardHeight'}
											@value-changed=${this._valueChanged}
										></paper-input>
									</div>
									<div>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											.checked=${this._showLoader !== false}
											.configValue=${'showLoader'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showLoader')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showDate ? 'off' : 'on'}`}
											.checked=${this._showDate !== false}
											.configValue=${'showDate'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showDate')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle Show Declined ${this._showDeclined ? 'off' : 'on'}`}
											.checked=${this._showDeclined !== false}
											.configValue=${'showDeclined'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showDeclined')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._sortByStartTime ? 'off' : 'on'}`}
											.checked=${this._sortByStartTime !== false}
											.configValue=${'sortByStartTime'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.sortByStartTime')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._hideFinishedEvents ? 'off' : 'on'}`}
											.checked=${this._hideFinishedEvents !== false}
											.configValue=${'hideFinishedEvents'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.hideFinishedEvents')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showLocation ? 'on' : 'off'}`}
											.checked=${this._showLocation !== false}
											.configValue=${'showLocation'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showLocation')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showRelativeTime ? 'on' : 'off'}`}
											.checked=${this._showRelativeTime !== false}
											.configValue=${'showRelativeTime'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showRelativeTime')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._hideDuplicates ? 'on' : 'off'}`}
											.checked=${this._hideDuplicates !== false}
											.configValue=${'hideDuplicates'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.hideDuplicates')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showMultiDay ? 'on' : 'off'}`}
											.checked=${this._showMultiDay !== false}
											.configValue=${'showMultiDay'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showMultiDay')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showMultiDayEventParts ? 'on' : 'off'}`}
											.checked=${this._showMultiDayEventParts !== false}
											.configValue=${'showMultiDayEventParts'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('main.fields.showMultiDayEventParts')}</label>
									</div>
								</div>
							</div>

					  `
				: ''}
				<!-- MAIN SETTINGS END -->
				<!-- EVENT SETTINGS -->
				<div class="option" @click=${this._toggleOption} .option=${'event'}>
					<div class="row">
						<ha-icon .icon=${`mdi:${options.event.icon}`}></ha-icon>
						<div class="title">${localize('event.name')}</div>
					</div>
					<div class="secondary">${localize('event.secondary')}</div>
				</div>
				${options.event.show
				? html`
							<div class="values">
								<paper-input
									label="${localize('event.fields.untilText')}"
									type="text"
									.value=${this._untilText}
									.configValue=${'untilText'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('event.fields.fullDayEventText')}"
									type="text"
									.value=${this._fullDayEventText}
									.configValue=${'fullDayEventText'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('event.fields.noEventsForNextDaysText')}"
									type="text"
									.value=${this._noEventsForNextDaysText}
									.configValue=${'noEventsForNextDaysText'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('event.fields.noEventText')}"
									type="text"
									.value=${this._noEventText}
									.configValue=${'noEventText'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<paper-input
									label="${localize('event.fields.hiddenEventText')}"
									type="text"
									.value=${this._hiddenEventText}
									.configValue=${'hiddenEventText'}
									@value-changed=${this._valueChanged}
								></paper-input>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showCurrentEventLine ? 'off' : 'on'}`}
											.checked=${this._showCurrentEventLine !== false}
											.configValue=${'showCurrentEventLine'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showCurrentEventLine')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showProgressBar ? 'on' : 'off'}`}
											.checked=${this._showProgressBar !== false}
											.configValue=${'showProgressBar'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showProgressBar')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showMonth ? 'off' : 'on'}`}
											.checked=${this._showMonth !== false}
											.configValue=${'showMonth'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showMonth')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showWeekDay ? 'off' : 'on'}`}
											.checked=${this._showWeekDay !== false}
											.configValue=${'showWeekDay'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showWeekDay')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showDescription ? 'on' : 'off'}`}
											.checked=${this._showDescription !== false}
											.configValue=${'showDescription'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showDescription')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._disableEventLink ? 'off' : 'on'}`}
											.checked=${this._disableEventLink !== false}
											.configValue=${'disableEventLink'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.disableEventLink')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._disableLocationLink ? 'off' : 'on'}`}
											.checked=${this._disableLocationLink !== false}
											.configValue=${'disableLocationLink'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.disableLocationLink')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showNoEventsForToday ? 'off' : 'on'}`}
											.checked=${this._showNoEventsForToday !== false}
											.configValue=${'showNoEventsForToday'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showNoEventsForToday')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showFullDayProgress ? 'off' : 'on'}`}
											.checked=${this._showFullDayProgress !== false}
											.configValue=${'showFullDayProgress'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showFullDayProgress')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showEventIcon ? 'off' : 'on'}`}
											.checked=${this._showEventIcon !== false}
											.configValue=${'showEventIcon'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showEventIcon')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showHiddenText ? 'on' : 'off'}`}
											.checked=${this._showHiddenText !== false}
											.configValue=${'showHiddenText'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showHiddenText')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showCalendarName ? 'on' : 'off'}`}
											.checked=${this._showCalendarName !== false}
											.configValue=${'showCalendarName'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showCalendarName')}</label>
									</div>
								</div>
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showWeekNumber ? 'on' : 'off'}`}
											.checked=${this._showWeekNumber !== false}
											.configValue=${'showWeekNumber'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('event.fields.showWeekNumber')}</label>
									</div>
								</div>
							</div>
					  `
				: ''}
				<!-- EVENT SETTINGS END -->
				<!-- CALENDAR SETTINGS -->
				<div class="option" @click=${this._toggleOption} .option=${'calendar'}>
					<div class="row">
						<ha-icon .icon=${`mdi:${options.calendar.icon}`}></ha-icon>
						<div class="title">${localize('calendar.name')}</div>
					</div>
					<div class="secondary">${localize('calendar.secondary')}</div>
				</div>
				${options.calendar.show
				? html`
							<div class="values">
								<ha-switch
									aria-label=${`Toggle ${this._calShowDescription ? 'off' : 'on'}`}
									.checked=${this._calShowDescription !== false}
									.configValue=${'calShowDescription'}
									@change=${this._valueChanged}
								></ha-switch>
								<label class="mdc-label">${localize('calendar.fields.calShowDescription')}</label>
								<div calss="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._showLastCalendarWeek ? 'off' : 'on'}`}
											.checked=${this._showLastCalendarWeek !== false}
											.configValue=${'showLastCalendarWeek'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('calendar.fields.showLastCalendarWeek')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._disableCalEventLink ? 'off' : 'on'}`}
											.checked=${this._disableCalEventLink !== false}
											.configValue=${'disableCalEventLink'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('calendar.fields.disableCalEventLink')}</label>
									</div>
								</div>
								<div calss="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._disableCalLocationLink ? 'off' : 'on'}`}
											.checked=${this._disableCalLocationLink !== false}
											.configValue=${'disableCalLocationLink'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('calendar.fields.disableCalLocationLink')}</label>
									</div>
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._disableCalLink ? 'off' : 'on'}`}
											.checked=${this._disableCalLink !== false}
											.configValue=${'disableCalLink'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('calendar.fields.disableCalLink')}</label>
									</div>
								</div>
							</div>
					  `
				: ''}
				<!-- CALENDAR SETTINGS END -->
				<!-- APPEARANCE SETTINGS -->
				<div class="option" @click=${this._toggleOption} .option=${'appearance'}>
					<div class="row">
						<ha-icon .icon=${`mdi:${options.appearance.icon}`}></ha-icon>
						<div class="title">${localize('appearance.name')}</div>
					</div>
					<div class="secondary">${localize('appearance.secondary')}</div>
				</div>
				${options.appearance.show
				? html`
							<div class="values">
								<div class="side-by-side">
									<div>
										<ha-switch
											aria-label=${`Toggle ${this._dimFinishedEvents ? 'off' : 'on'}`}
											.checked=${this._dimFinishedEvents !== false}
											.configValue=${'dimFinishedEvents'}
											@change=${this._valueChanged}
										></ha-switch>
										<label class="mdc-label">${localize('appearance.fields.dimFinishedEvents')}</label>
									</div>
								</div>
							</div>
					  `
				: ''}
				<!-- APPEARANCE SETTINGS END -->
			</div>
		`;
	}
	private _initialize(): void {
		if (this.hass === undefined) return;
		if (this._config === undefined) return;
		if (this._helpers === undefined) return;
		this._initialized = true;
	}

	private async loadCardHelpers(): Promise<void> {
		this._helpers = await (window as any).loadCardHelpers();
	}
	private _toggleOption(ev): void {
		this._toggleThing(ev, options);
	}

	private _toggleThing(ev, optionList): void {
		const show = !optionList[ev.target.option].show;
		for (const [key] of Object.entries(optionList)) {
			optionList[key].show = false;
		}
		optionList[ev.target.option].show = show;
		this._toggle = !this._toggle;
	}

	private _valueChanged(ev): void {
		if (this.cantFireEvent) return;

		const target = ev.target;
		if (this[`_${target.configValue}`] === target.value) {
			return;
		}
		if (target.configValue) {
			if (target.value === '') {
				const tmpConfig = { ...this._config };
				delete tmpConfig[target.configValue];
				this._config = tmpConfig;
			} else {
				this._config = {
					...this._config,
					[target.configValue]: target.checked !== undefined ? target.checked : (isNaN(target.value)) ? target.value : parseInt(target.value),
				};
			}
		}
		fireEvent(this, 'config-changed', { config: this._config });
	}

	get entities() {
		const entities = [...(this._config.entities || [])];

		// convert any legacy entity strings into objects
		let entityObjects = entities.map(entity => {
			if (entity.entity) return entity;
			return { entity, name: entity };
		});

		return entityObjects;
	}
	/**
	  * change the calendar name of an entity
	  * @param {*} ev
	  */
	private _entityValueChanged(ev) {
		if (this.cantFireEvent) return;

		const target = ev.target
		let entityObjects = [...this.entities];

		entityObjects = entityObjects.map(entity => {
			if (entity.entity === target.entityId) {
				if (this[`_${target.configValue}`] === target.value) {
					return;
				}
				if (target.configValue && target.value === '') {
					delete entity[target.configValue];
					return entity;
				} else {
					entity = {
						...entity,
						[target.configValue]: target.checked !== undefined ? target.checked : (isNaN(target.value)) ? target.value : parseInt(target.value),
					}
				}
			}
			return entity;
		});

		this._config = Object.assign({}, this._config, { entities: entityObjects });
		fireEvent(this, 'config-changed', { config: this._config });
	}
	/**
	 * add or remove calendar entities from config
	 * @param {*} ev
	 */
	private _entityChanged(ev) {
		const target = ev.target;

		if (this.cantFireEvent) return;
		let entityObjects = [...this.entities];
		if (target.checked) {
			const originalEntity = this.hass.states[target.entityId];
			entityObjects.push({ entity: target.entityId, name: originalEntity.attributes.friendly_name || target.entityId });
		} else {
			entityObjects = entityObjects.filter(entity => entity.entity !== target.entityId);
		}

		this._config = Object.assign({}, this._config, { entities: entityObjects });
		fireEvent(this, 'config-changed', { config: this._config });
	}
	/**
	* stop events from firing if certains conditions not met
	*/
	get cantFireEvent() {
		return (!this._config || !this.hass);
	}
}
Example #19
Source File: TouchGestureMixin.ts    From figspec with MIT License 4 votes vote down vote up
TouchGestureMixin = <T extends Constructor<LitElement>>(
  superClass: T
): T & Constructor<TouchGestureMixinProps> =>
  class CTouchGesture extends superClass {
    private previousTouches: TouchList | null = null;

    constructor(...args: any[]) {
      super(...args);

      this.addEventListener("touchstart", (ev) => {
        if (shouldSkipEvent(ev)) {
          return;
        }

        ev.preventDefault();
        this.previousTouches = ev.touches;
      });

      this.addEventListener("touchend", (ev) => {
        if (shouldSkipEvent(ev)) {
          return;
        }

        ev.preventDefault();
        this.previousTouches = null;
      });

      this.addEventListener("touchcancel", (ev) => {
        if (shouldSkipEvent(ev)) {
          return;
        }

        ev.preventDefault();
        this.previousTouches = null;
      });

      this.addEventListener("touchmove", (ev) => {
        if (shouldSkipEvent(ev)) {
          return;
        }

        const previousTouches = Array.from(this.previousTouches || []);
        const currentTouches = Array.from(ev.touches);
        this.previousTouches = ev.touches;

        // When one or more than one of touch input sources differs, skip processing.
        if (
          currentTouches.length !== previousTouches.length ||
          !currentTouches.every((t) =>
            previousTouches.some((pt) => pt.identifier === t.identifier)
          )
        ) {
          return;
        }

        // Pan
        if (currentTouches.length === 1) {
          this.onTouchPan({
            x: currentTouches[0].pageX - previousTouches[0].pageX,
            y: currentTouches[0].pageY - previousTouches[0].pageY,
          });
          return;
        }

        // Pinch
        this.onTouchPinch(
          getDistance(
            {
              x: currentTouches[0].pageX,
              y: currentTouches[0].pageY,
            },
            {
              x: previousTouches[0].pageX,
              y: previousTouches[0].pageY,
            }
          )
        );
        return;
      });
    }

    get isTouching() {
      return !!(this.previousTouches && this.previousTouches.length > 0);
    }

    onTouchPan(delta: Point2D) {}
    onTouchPinch(delta: number) {}
  }
Example #20
Source File: index.ts    From atomic-calendar-revive with MIT License 4 votes vote down vote up
class AtomicCalendarRevive extends LitElement {
  @property() public hass!: HomeAssistant;
  @property() private _config!: atomicCardConfig;
  @property() private content;
  @property() private selectedMonth;

  lastCalendarUpdateTime!: dayjs.Dayjs;
  lastEventsUpdateTime!: dayjs.Dayjs;
  lastHTMLUpdateTime!: dayjs.Dayjs;
  events!: {} | any[];
  shouldUpdateHtml: boolean;
  errorMessage: TemplateResult;
  modeToggle: string;
  refreshCalEvents: boolean;
  monthToGet: string;
  month!: Promise<any>;
  showLoader: boolean;
  hiddenEvents: number;
  eventSummary: TemplateResult | void | TemplateResult<1>[];
  firstrun: boolean;
  isUpdating: boolean;
  clickedDate!: dayjs.Dayjs;
  language: string;
  failedEvents!: {} | any[];

  constructor() {
    super();
    this.lastCalendarUpdateTime;
    this.lastEventsUpdateTime;
    this.lastHTMLUpdateTime;
    this.events;
    this.failedEvents;
    this.content = html``;
    this.shouldUpdateHtml = true;
    this.errorMessage = html``;
    this.modeToggle = '';
    this.selectedMonth = dayjs();
    this.refreshCalEvents = true;
    this.monthToGet = dayjs().format('MM');
    this.month;
    this.showLoader = false;
    this.eventSummary = html`&nbsp;`;
    this.firstrun = true;
    this.isUpdating = false;
    this.language = '';
    this.hiddenEvents = 0;
  }

  public static async getConfigElement(): Promise<LovelaceCardEditor> {
    return document.createElement('atomic-calendar-revive-editor') as LovelaceCardEditor;
  }

  public static getStubConfig() {
    return {
      name: 'Calendar Card',
      enableModeChange: true,
    };
  }

  public setConfig(config: atomicCardConfig): void {
    if (!config) {
      throw new Error(localize('errors.invalid_configuration'));
    }
    if (!config.entities || !config.entities.length) {
      throw new Error(localize('errors.no_entities'));
    }

    const customConfig: atomicCardConfig = JSON.parse(JSON.stringify(config));

    this._config = {
      ...defaultConfig,
      ...customConfig,
    };

    this.modeToggle = this._config.defaultMode!;

    if (typeof this._config.entities === 'string')
      this._config.entities = [
        {
          entity: config.entities,
        },
      ];
    this._config.entities.forEach((entity, i) => {
      if (typeof entity === 'string')
        this._config.entities[i] = {
          entity: entity,
        };
    });
  }

  protected render(): TemplateResult | void {
    if (this.firstrun) {
      console.info(
        `%c atomic-calendar-revive %c ${localize('common.version')}: ${CARD_VERSION} `,
        'color: white; background: #484848; font-weight: 700;',
        'color: white; background: #cc5500; font-weight: 700;',
      );
      this.language =
        typeof this._config.language != 'undefined' ? this._config.language! : this.hass.locale ? this.hass.locale.language.toLowerCase() : this.hass.language.toLowerCase();

      dayjs.locale(this.language);

      let timeFormat = typeof this._config.hoursFormat != 'undefined' ? this._config.hoursFormat : (this.hass.locale?.time_format == '12' || this.hass.locale?.time_format == '24') ? formatTime(this.hass.locale) : dayjs().localeData().longDateFormat('LT');
      dayjs.updateLocale(this.language, {
        weekStart: this._config.firstDayOfWeek!,
        formats: {
          LT: timeFormat,
          LTS: 'HH:mm:ss',
          L: 'DD/MM/YYYY',
          LL: 'D MMMM YYYY',
          LLL: 'MMM D YYYY HH:mm',
          LLLL: 'dddd, D MMMM YYYY HH:mm',
        },
      });

      this.selectedMonth = dayjs();
      this.monthToGet = dayjs().format('MM');
    }
    if (!this._config || !this.hass) {
      return html``;
    }
    this.updateCard();

    return html`
			<ha-card class="cal-card" style="--card-height: ${this._config.cardHeight}">
				${this._config.name || this._config.showDate || (this.showLoader && this._config.showLoader)
        ? html` <div class="header">
							${this._config.name
            ? html`<div class="headerName" @click="${() => this.handleToggle()}">${this._config.name}</div>`
            : ''}
							${this.showLoader && this._config.showLoader ? html`<div class="loader"></div>` : ''}
							${this._config.showDate ? html`<div class="headerDate">${getDate(this._config)}</div>` : ''}
					  </div>`
        : ''}

				<div class="cal-eventContainer" style="padding-top: 4px;">${this.content}</div>
			</ha-card>`;
  }

  /**
   * Updates the entire card
   */
  async updateCard() {

    this.firstrun = false;

    // check if an update is needed
    if (!this.isUpdating && this.modeToggle == 'Event') {
      if (
        !this.lastEventsUpdateTime ||
        dayjs().diff(this.lastEventsUpdateTime, 'seconds') > this._config.refreshInterval
      ) {
        this.showLoader = true;
        this.hiddenEvents = 0;
        this.isUpdating = true;
        try {
          const { events, failedEvents } = await getEventMode(this._config, this.hass);
          this.events = events;
          this.failedEvents = failedEvents;
          // Check no event days and display
          if (this._config.showNoEventDays) {
            this.events = setNoEventDays(this._config, this.events);
          }
          this.events = groupEventsByDay(this.events);

        } catch (error) {
          console.log(error);
          this.errorMessage = html`${localize('errors.update_card')}
						<a href="https://docs.totaldebug.uk/atomic-calendar-revive/overview/faq.html" target="${this._config.linkTarget}"
							>See Here</a
						>`;
          this.showLoader = false;
        }

        this.lastEventsUpdateTime = dayjs();
        this.updateEventsHTML(this.events);
        this.isUpdating = false;
        this.showLoader = false;
      }
    }
    // check if the mode was toggled, then load the correct
    // html render
    if (this.modeToggle == 'Event') this.updateEventsHTML(this.events);
    else await this.updateCalendarHTML();
  }

  handleToggle() {
    if (this._config.enableModeChange) {
      this.modeToggle == 'Event' ? (this.modeToggle = 'Calendar') : (this.modeToggle = 'Event');
      this.requestUpdate();
    }
  }

  static get styles(): CSSResultGroup {
    return styles;
  }

  // The height of your card. Home Assistant uses this to automatically
  // distribute all cards over the available columns.
  getCardSize() {
    return this._config.entities.length + 1;
  }

  _toggle(state) {
    this.hass!.callService('homeassistant', 'toggle', {
      entity_id: state.entity_id,
    });
  }

  /**
   * update Events main HTML
   *
   */
  updateEventsHTML(days) {
    let htmlDays = '';

    /**
     * If there is an error, put the message as content
     */
    if (!days) {
      this.content = this.errorMessage;
      return;
    }

    /**
     * If there are no events, put some text in
     */
    if (days.length === 0 && this._config.maxDaysToShow == 1) {
      this.content = this._config.noEventText;
      return;
    } else if (days.length === 0) {
      this.content = this._config.noEventsForNextDaysText;
      return;
    }

    /**
     * Move finished events up to the top
     */
    if (dayjs(days[0][0]).isSame(dayjs(), 'day') && days[0].length > 1) {
      let i = 1;
      while (i < days[0].length) {
        if (days[0][i].isFinished && !days[0][i - 1].isFinished) {
          [days[0][i], days[0][i - 1]] = [days[0][i - 1], days[0][i]];
          if (i > 1) i--;
        } else i++;
      }
    }
    /**
     * If there are no events, post fake event with "No Events Today" text
     */
    if (this._config.showNoEventsForToday && days[0][0].startDateTime.isAfter(dayjs(), 'day') && days[0].length > 0) {
      const emptyEv = {
        eventClass: '',
        config: '',
        start: { dateTime: dayjs().endOf('day') },
        end: { dateTime: dayjs().endOf('day') },
        summary: this._config.noEventText,
        isFinished: false,
        htmlLink: 'https://calendar.google.com/calendar/r/day?sf=true',
      };
      const emptyEvent = new EventClass(emptyEv, this._config);
      emptyEvent.isEmpty = true;
      const d: any[] = [];
      d.push(emptyEvent);
      days.unshift(d);
    }

    /**
     * Loop through each day an process the events
     */
    let currentWeek = 54;
    htmlDays = days.map((day: [EventClass], di) => {

      let weekNumberResults = getWeekNumberHTML(day, currentWeek);
      currentWeek = weekNumberResults.currentWeek

      var dayEvents = day

      /**
       * Loop through each event and add html
       */
      const htmlEvents = dayEvents.map((event: EventClass, i, arr) => {


        const dayWrap = i == 0 && di > 0 ? 'daywrap' : '';
        const isEventNext =
          di == 0 && event.startDateTime.isAfter(dayjs()) && (i == 0 || !arr[i - 1].startDateTime.isAfter(dayjs()))
            ? true
            : false;
        //show line before next event
        const currentEventLine =
          this._config.showCurrentEventLine && isEventNext
            ? html`<div class="eventBar">
								<ha-icon
									icon="mdi:circle"
									class="event-circle"
									style="color:  ${this._config.eventBarColor};"
								></ha-icon>
								<hr class="event" style="--event-bar-color: ${this._config.eventBarColor} "/>
						  </div>`
            : ``;

        const calColor = typeof event.entityConfig.color != 'undefined' ? event.entityConfig.color : this._config.defaultCalColor;

        //show calendar name
        const eventCalName = event.entityConfig.name && this._config.showCalendarName
          ? html`<div class="event-cal-name" style="color: ${calColor};">
							<ha-icon icon="mdi:calendar" class="event-cal-name-icon"></ha-icon>&nbsp;${event.entityConfig.name}
					  </div>`
          : ``;

        //show current event progress bar
        let progressBar = html``;
        if (
          di == 0 &&
          ((event.isRunning && this._config.showFullDayProgress && event.isAllDayEvent) ||
            (event.isRunning && !event.isAllDayEvent && this._config.showProgressBar))
        ) {
          const eventDuration = event.endDateTime.diff(event.startDateTime, 'minutes');
          const eventProgress = dayjs().diff(event.startDateTime, 'minutes');
          const eventPercentProgress = (eventProgress * 100) / eventDuration / 100;
          /**progressBar = html`<mwc-linear-progress
            class="progress-bar"

            progress="${eventPercentProgress}"></mwc-linear-progress>`;*/
          progressBar = html`<progress style="--progress-bar: ${this._config.progressBarColor}; --progress-bar-bg: ${this._config.progressBarBackgroundColor};" value="${eventPercentProgress}" max="1"></progress>`;
        }

        const finishedEventsStyle =
          event.isFinished && this._config.dimFinishedEvents
            ? `opacity: ` + this._config.finishedEventOpacity + `; filter: ` + this._config.finishedEventFilter + `;`
            : ``;

        // Show the hours
        const hoursHTML = this._config.showHours ? html`<div class="hoursHTML" style="--time-color: ${this._config.timeColor}; --time-size: ${this._config.timeSize}%">${getHoursHTML(this._config, event)}</div>` : '';

        // Show the relative time
        const relativeTime = this._config.showRelativeTime
          ? html`<div class="relativeTime" style="--time-color: ${this._config.timeColor}; --time-size: ${this._config.timeSize}%"> ${getRelativeTime(event)}</div>`
          : '';

        const lastEventStyle = i == arr.length - 1 ? 'padding-bottom: 8px;' : '';
        // check and set the date format
        const eventDateFormat =
          this._config.europeanDate == true
            ? html`${i === 0 ? event.startTimeToShow.format('DD') + ' ' : ''
              }${i === 0 && this._config.showMonth
                ? event.startTimeToShow.format('MMM')
                : ''}`
            : html`${i === 0 && this._config.showMonth ? event.startTimeToShow.format('MMM') + ' ' : ''}${i === 0
              ? event.startTimeToShow.format('DD')
              : ''
              }`;

        const dayClassTodayEvent = event.startDateTime.isSame(dayjs(), 'day') ? `event-leftCurrentDay` : ``;

        return html`<tr class="${dayWrap}" style="color:  ${this._config.dayWrapperLineColor};">
	  <td class="event-left" style="color: ${this._config.dateColor};font-size: ${this._config.dateSize}%;">
		  <div class=${dayClassTodayEvent}>
			  ${i === 0 && this._config.showWeekDay ? event.startTimeToShow.format('ddd') : ''}
      </div>
	    <div class=${dayClassTodayEvent}>${eventDateFormat}</div>
		</td>
		<td style="width: 100%;  ${finishedEventsStyle} ${lastEventStyle}">
			<div>${currentEventLine}</div>
				<div class="event-right">
					<div class="event-main">${getTitleHTML(this._config, event)}</div>
					<div class="event-location">${getLocationHTML(this._config, event)} ${eventCalName}</div>
				</div>
        <div class="event-right">${hoursHTML} ${relativeTime}</div>
				${getDescription(this._config, event)}</div>
				</div>
				${progressBar}
    </td>
	</tr>`;
      });
      var daysEvents = html`${this._config.showWeekNumber ? weekNumberResults.currentWeekHTML : ''}${htmlEvents}`
      return daysEvents;
    });
    const eventnotice = this._config.showHiddenText
      ? this.hiddenEvents > 0
        ? this.hiddenEvents + ' ' + this._config.hiddenEventText
        : ''
      : '';
    this.content = html`<table>
				<tbody>
					${htmlDays}
				</tbody>
			</table>
			<span class="hidden-events">${eventnotice}</span>`;
  }

  /**
   * change month in calendar mode
   *
   */
  handleMonthChange(i) {
    this.selectedMonth = this.selectedMonth.add(i, 'month');
    this.monthToGet = this.selectedMonth.format('M');
    this.eventSummary = html`&nbsp;`;
    this.refreshCalEvents = true;
  }

  /**
   * create html calendar header
   *
   */
  getCalendarHeaderHTML() {
    return html`<div class="calDateSelector">
			<ha-icon-button
				class="prev"
        style="--mdc-icon-color: ${this._config.calDateColor}"
				icon="mdi:chevron-left"
				@click="${() => this.handleMonthChange(-1)}"
				title=${this.hass.localize('ui.common.previous')}
			>
				<ha-icon icon="mdi:chevron-left"></ha-icon>
			</ha-icon-button>
			<span class="date" style="text-decoration: none; color: ${this._config.calDateColor};">
				${this.selectedMonth.format('MMMM')} ${this.selectedMonth.format('YYYY')}
			</span>
			<ha-icon-button
				class="next"
        style="--mdc-icon-color: ${this._config.calDateColor}"
				icon="mdi:chevron-right"
				@click="${() => this.handleMonthChange(1)}"
				title=${this.hass.localize('ui.common.next')}
			>
				<ha-icon icon="mdi:chevron-right"></ha-icon>
			</ha-icon-button>
		</div>`;
  }

  /**
   * show events summary under the calendar
   *
   */
  handleCalendarEventSummary(day: CalendarDay, fromClick) {
    if (fromClick) {
      this.clickedDate = day.date;
    }
    var dayEvents = day.allEvents;

    this.eventSummary = dayEvents.map((event: EventClass) => {
      const eventColor = typeof event.entityConfig.color != 'undefined' ? event.entityConfig.color : this._config.defaultCalColor;
      const finishedEventsStyle =
        event.isFinished && this._config.dimFinishedEvents
          ? `opacity: ` + this._config.finishedEventOpacity + `; filter: ` + this._config.finishedEventFilter + `;`
          : ``;

      // is it a full day event? if so then use border instead of bullet else, use a bullet
      if (event.isAllDayEvent) {
        const bulletType: string =
          typeof event.isDeclined
            ? 'summary-fullday-div-declined'
            : 'summary-fullday-div-accepted';

        return html`<div class="${bulletType}" style="border-color:  ${eventColor}; ${finishedEventsStyle}">
              <div aria-hidden="true">
                <div class="bullet-event-span">${getCalendarTitleHTML(this._config, event)} ${getCalendarLocationHTML(this._config, event)}</div>
                <div class="calMain">${this._config.calShowDescription ? getCalendarDescriptionHTML(this._config, event) : ''}</div>
              </div>
            </div>`;
      } else {
        const StartTime = this._config.showHours ? event.startDateTime.format('LT') : '';

        const bulletType: string =
          event.isDeclined
            ? 'bullet-event-div-declined'
            : 'bullet-event-div-accepted';

        return html`
              <div class="summary-event-div" style="${finishedEventsStyle}">
                <div class="${bulletType}" style="border-color: ${eventColor}"></div>
                <div class="bullet-event-span" style="color: ${eventColor};">
                  ${StartTime} - ${getCalendarTitleHTML(this._config, event)} ${getCalendarLocationHTML(this._config, event)}
                </div>
                <div class="calMain">${this._config.calShowDescription ? getCalendarDescriptionHTML(this._config, event) : ''}</div>
              </div>
            `;
      }
    });
    this.requestUpdate();

  }

  /**
   * create calendar mode html cells
   *
   */
  getCalendarDaysHTML(month) {
    let showLastRow = true;
    if (!this._config.showLastCalendarWeek && !dayjs(month[35].date).isSame(this.selectedMonth, 'month'))
      showLastRow = false;

    return month.map((day: CalendarDay, i) => {
      const dayDate = dayjs(day.date);
      const dayStyleOtherMonth = dayDate.isSame(this.selectedMonth, 'month') ? '' : `opacity: .35;`;
      const dayClassToday = dayDate.isSame(dayjs(), 'day') ? `currentDay` : ``;
      const dayStyleSat = dayDate.isoWeekday() == 6 ? `background-color: ${this._config.calEventSatColor};` : ``;
      const dayStyleSun = dayDate.isoWeekday() == 7 ? `background-color: ${this._config.calEventSunColor};` : ``;
      const dayStyleClicked = dayDate.isSame(dayjs(this.clickedDate), 'day')
        ? `background-color: ${this._config.calActiveEventBackgroundColor};`
        : ``;

      if (dayDate.isSame(dayjs(), 'day') && !this.clickedDate) {
        this.handleCalendarEventSummary(day, false)
      }
      if (i < 35 || showLastRow)
        return html`
					${i % 7 === 0 ? html`<tr class="cal"></tr>` : ''}
					<td
						@click="${() => this.handleCalendarEventSummary(day, true)}"
						class="cal"
						style="${dayStyleOtherMonth}${dayStyleSat}${dayStyleSun}${dayStyleClicked} --cal-grid-color: ${this._config.calGridColor}; --cal-day-color: ${this._config.calDayColor}"
					>
						<div class="calDay ${dayClassToday}">
							<div style="position: relative; top: 5%;">${day.date.date()}</div>
							<div>${handleCalendarIcons(day)}</div>
						</div>
					</td>
					${i && i % 6 === 0 ? html`</tr>` : ''}
				`;
    });
  }

  /**
   * update Calendar mode HTML
   *
   */
  async updateCalendarHTML() {
    if (
      this.refreshCalEvents ||
      !this.lastCalendarUpdateTime ||
      dayjs().diff(dayjs(this.lastCalendarUpdateTime), 'second') > this._config.refreshInterval
    ) {
      this.lastCalendarUpdateTime = dayjs();
      this.showLoader = true;
      // get the calendar and all its events
      this.month = await getCalendarMode(this._config, this.hass, this.selectedMonth);
      this.refreshCalEvents = false;
      this.showLoader = false;
      this.hiddenEvents = 0;
    }
    const month = this.month;
    const weekDays = dayjs.weekdaysMin(true);
    const htmlDayNames = weekDays.map(
      (day) => html`<th class="cal" style="color:  ${this._config.calWeekDayColor};">${day}</th>`,
    );
    this.content = html`
			<div class="calTitleContainer">${this.getCalendarHeaderHTML()}${showCalendarLink(this._config, this.selectedMonth)}</div>
			<div class="calTableContainer">
				<table class="cal" style="color: ${this._config.eventTitleColor};--cal-border-color:${this._config.calGridColor}">
					<thead>
						<tr>
							${htmlDayNames}
						</tr>
					</thead>
					<tbody>
						${this.getCalendarDaysHTML(month)}
					</tbody>
				</table>
			</div>
			<div class="summary-div">${this.eventSummary}</div>
		`;
  }
}
Example #21
Source File: FigspecFileViewer.ts    From figspec with MIT License 4 votes vote down vote up
// TODO: Move docs for props in mixins (waiting for support at web-component-analyzer)
/**
 * A Figma spec viewer. Displays a rendered image alongside sizing guides.
 * @element figspec-file-viewer
 *
 * @property {number} [panX=0]
 * Current pan offset in px for X axis.
 * This is a "before the scale" value.
 *
 * @property {number} [panY=0]
 * Current pan offset in px for Y axis.
 * This is a "before the scale" value.
 *
 * @property {number} [scale=1]
 * Current zoom level, where 1.0 = 100%.
 *
 * @property {number} [zoomSpeed=500]
 * How fast zooming when do ctrl+scroll / pinch gestures.
 * Available values: 1 ~ 1000
 * @attr [zoom-speed=500] See docs for `zoomSpeed` property.
 *
 * @property {number} [panSpeed=500]
 * How fast panning when scroll vertically or horizontally.
 * This does not affect to dragging with middle button pressed.
 * Available values: 1 ~ 1000.
 * @attr [pan-speed=500] See docs for `panSpeed` property.
 *
 * @property {Figma.Node | null} [selectedNode=null]
 * Current selected node.
 *
 * @property {string} [link=null]
 * Figma link for the given project/node. If passed, figspec will present a footer with metadata and a link to figma.
 *
 * @property {number} [zoomMargin=50]
 * The minimum margin for the preview canvas in px. Will be used when the preview
 * setting a default zooming scale for the canvas.
 * @attr [zoom-margin=50] See docs for `zoomMargin` property.
 *
 * @fires scalechange When a user zoom-in or zoom-out the preview.
 * @fires positionchange When a user panned the preview.
 * @fires nodeselect When a user selected / unselected a node.
 */
export class FigspecFileViewer extends ViewerMixin(LitElement) {
  /**
   * A response of "GET file nodes" API.
   * https://www.figma.com/developers/api#get-file-nodes-endpoint
   */
  @property({
    type: Object,
    attribute: "document-node",
  })
  documentNode: Figma.FileResponse | null = null;

  /**
   * A record of rendered images, where key is an ID of the node,
   * value is an URI of the image.
   * https://www.figma.com/developers/api#get-images-endpoint
   */
  @property({
    type: Object,
    attribute: "rendered-images",
  })
  renderedImages: Record<string, string> | null = null;

  /**
   * Current selected page (node whose type is "CANVAS").
   */
  selectedPage: Figma.Canvas | null = null;

  /** @private */
  get isMovable(): boolean {
    return !!(this.renderedImages && this.documentNode);
  }

  /** @private */
  get __images() {
    return this.renderedImages || {};
  }

  /** @private */
  get error() {
    if (!this.documentNode || !this.renderedImages) {
      return ErrorMessage.ErrorMessage({
        title: "Parameter error",
        children: html`<span>
          Both <code>document-node</code> and <code>rendered-images</code> are
          required.
        </span>`,
      });
    }

    if (super.error) {
      return super.error;
    }
  }

  static get styles() {
    return extendStyles(super.styles, [
      css`
        :host {
          --figspec-control-bg-default: #fcfcfc;
          --figspec-control-fg-default: #333;

          --control-bg: var(
            --figspec-control-bg,
            var(--figspec-control-bg-default)
          );
          --control-fg: var(
            --figspec-control-bg,
            var(--figspec-control-fg-default)
          );
          --control-shadow: var(
            --figspec-control-shadow,
            0 2px 4px rgba(0, 0, 0, 0.3)
          );
          --padding: var(--figspec-control-padding, 8px 16px);

          display: flex;
          flex-direction: column;
        }

        @media (prefers-color-scheme: dark) {
          :host {
            --figspec-control-bg-default: #222;
            --figspec-control-fg-default: #fff;
          }
        }

        .controls {
          flex-shrink: 0;
          padding: var(--padding);

          background-color: var(--control-bg);
          box-shadow: var(--control-shadow);
          color: var(--control-fg);
          z-index: 1;
        }

        .view {
          position: relative;
          flex-grow: 1;
          flex-shrink: 1;
        }
      `,
    ]);
  }

  render() {
    return html`
      <div class="controls">
        <select @change=${this.#handlePageChange}>
          ${this.documentNode?.document.children.map(
            (c) => html`<option value=${c.id}>${c.name}</option>`
          )}
        </select>
      </div>

      <div class="view">${super.render()}</div>
    `;
  }

  getMetadata() {
    return {
      fileName: this.documentNode!.name,
      timestamp: this.documentNode!.lastModified,
      link: this.link,
    };
  }

  connectedCallback() {
    super.connectedCallback();

    if (this.documentNode) {
      this.#selectFirstPage();

      if (this.selectedPage) {
        this.__updateTree(this.selectedPage);
        this.resetZoom();
      }
    }
  }

  updated(changedProperties: Parameters<LitElement["updated"]>[0]) {
    super.updated(changedProperties);

    if (changedProperties.has("documentNode")) {
      this.#selectFirstPage();

      if (this.selectedPage) {
        this.__updateTree(this.selectedPage);
        this.resetZoom();
      }
    }

    if (changedProperties.has("renderedImages")) {
      this.__updateEffectMargins();
    }
  }

  #selectFirstPage = () => {
    if (!this.documentNode) {
      this.selectedPage = null;
      return;
    }

    this.selectedPage =
      this.documentNode.document.children.filter(
        (c): c is Figma.Canvas => c.type === "CANVAS"
      )[0] ?? null;
  };

  #handlePageChange = (ev: Event) => {
    const target = ev.currentTarget as HTMLSelectElement;

    this.selectedPage =
      (this.documentNode?.document.children.find(
        (c) => c.id === target.value
      ) as Figma.Canvas) ?? null;

    if (this.selectedPage) {
      this.__updateTree(this.selectedPage);
      this.resetZoom();
      this.__updateEffectMargins();
      this.panX = 0;
      this.panY = 0;
    }
  };
}
Example #22
Source File: opc-provider.ts    From one-platform with MIT License 4 votes vote down vote up
@customElement("opc-provider")
export class OpcProvider extends LitElement {
  static styles = styles;
  @property({ reflect: true, type: Boolean }) isWarningHidden = true;

  @query(".opc-toast-container") toastContainer!: HTMLDivElement;

  @state() private opcNotificationDrawer!: any;
  @state() private opcMenuDrawer!: any;
  @state() private opcNavBar!: any;

  @state() private isLoading = true;
  @state() private searchValue = "";

  @state() private notifications: Notification[] = [];
  @state() private apps: Apps[] = [];

  @state() private selectedAppsForNotificationFilter: string[] = [];
  @state() private notificationAppCount: Record<string, number> = {};

  private _notificationsSubscription: subscriptionT | null = null;

  api!: APIService;

  constructor() {
    super();
    // register patternfly toast component
    new PfeToast();
    /**
     * obtain search value if seen in url
     * eg: /search?query="search"
     * onmount value search must be initialzed
     */
    const queryString = window.location.search;
    const urlParams = new URLSearchParams(queryString);
    this.searchValue =
      (window.location.pathname === "/search" && urlParams.get("query")) || "";
  }

  /**
   * Inside constructor accessing template node may get failed because
   * constructor is called as soon as element is in html dom and the nested nodes may not have rendered yet
   * Using firstUpdated lifecycle this can be avoided as this function is only executed
   * after the entire template has been created, giving access to inside template nodes
   */
  firstUpdated() {
    opcBase.auth?.onLogin(async (user) => {
      const config = opcBase.config;

      if (!user) throw Error("user not found");

      // get the references to each component inside the opc-provider
      this.opcNavBar = this.querySelector("opc-nav");
      this.opcMenuDrawer = this.querySelector("opc-menu-drawer");
      this.opcNotificationDrawer = this.querySelector(
        "opc-notification-drawer"
      );

      const userDetails = [user.kerberosID, user.email, ...user.role];

      // inject business logic for all the components needed
      this.injectNav();
      this.injectOpcProviderFn();
      this.injectMenuDrawer();
      this.injectNotificationDrawer();
      this.injectFeedback();
      this.isLoading = false;

      this.api = new APIService({
        apiBasePath: config.apiBasePath,
        subscriptionsPath: config.subscriptionsPath,
        cachePolicy: config.cachePolicy,
      });

      // load the app list and notificatins for the user
      this.getAppListAndNotifications(userDetails).finally(() => {
        this.loadDrawerAppList(this.apps);
      });
      // subscribe to notifications
      this.handleNotificationSubscription(userDetails);
    });
  }

  /**
   * This function is called on top of constructor
   * It injects feedback and toast to window and opcBase
   */
  private injectOpcProviderFn() {
    window.OpNotification = {
      showToast: this.showToast.bind(this),
    } as any;
    /* MAGIC: Aliases for different toast variants */
    (
      ["success", "warn", "danger", "info"] as ToastOptions["variant"][]
    ).forEach((variant) => {
      window.OpNotification[variant] = (...args) => {
        if (args.length > 1) {
          args[1]!["variant"] = variant;
        } else {
          args.push({ variant });
        }
        this.showToast(...args);
      };
    });

    opcBase.toast = window.OpNotification;
    window.sendFeedback = this.sendFeedback.bind(this);
    opcBase.feedback = { sendFeedback: window.sendFeedback };
  }

  private injectNav() {
    // check
    if (!this.opcNavBar) {
      !this.isWarningHidden && console.warn("nav bar not found");
      return;
    }
    // links in navbar
    this.opcNavBar.links = [
      {
        href: " /get-started/docs",
        name: "Documentation",
      },
      {
        href: " /get-started/blog",
        name: "Blog",
      },
    ];

    // adding event listeners to nav button clicks
    this.opcNavBar.addEventListener("opc-nav-btn-menu:click", () => {
      this.opcMenuDrawer.open();
      this.opcNavBar.activeButton = "menu";
    });
    this.opcNavBar.addEventListener("opc-nav-btn-notification:click", () => {
      this.opcNotificationDrawer.open();
      this.opcNavBar.activeButton = "notification";
    });

    render(
      html` <style>
          opc-nav {
            --opc-nav-container__z-index: var(--opc-app-layout__nav-zIndex);
          }
          opc-menu-drawer {
            --opc-menu-drawer__z-index: var(--opc-app-layout__drawer-zIndex);
          }
          opc-notification-drawer {
            --opc-notification-drawer__z-index: var(
              --opc-app-layout__drawer-zIndex
            );
          }
          opc-feedback {
            --op-feedback__z-index: var(--opc-app-layout__feedback-zIndex);
          }
        </style>
        <opc-nav-search
          slot="opc-nav-search"
          value=${this.searchValue}
          @opc-nav-search:change=${(evt: any) =>
            (this.searchValue = evt.detail.value)}
          @opc-nav-search:submit=${(evt: any) =>
            (window.location.href = `/search?query=${evt.detail.value}`)}
        ></opc-nav-search>
        <a slot="opc-nav-logo" href="/">
          <img slot="" src="${opLogo}" height="30px" alt="logo" />
        </a>`,
      this.opcNavBar
    );
  }

  private injectMenuDrawer() {
    // check
    if (!this.opcMenuDrawer) {
      !this.isWarningHidden && console.warn("menu drawer not found");
      return;
    }
    this.opcMenuDrawer.menuTitle = this.userInfo?.fullName;

    this.opcMenuDrawer.addEventListener("opc-menu-drawer:close", () => {
      this.opcNavBar.activeButton = "";
    });

    // rendering avatar and footer for the drawer
    render(
      html`
        <opc-avatar slot="avatar">
          ${this.userInfo?.firstName.charAt(0)}
          ${this.userInfo?.lastName.charAt(0)}
        </opc-avatar>
        <button slot="menu" onclick="window.OpAuthHelper.logout()">
          Log Out
        </button>
      `,
      this.opcMenuDrawer
    );
  }

  private injectNotificationDrawer() {
    // check
    if (!this.opcNotificationDrawer) {
      !this.isWarningHidden && console.warn("notification drawer not found");
      return;
    }

    this.opcNotificationDrawer.addEventListener(
      "opc-notification-drawer:close",
      () => {
        this.opcNavBar.activeButton = "";
      }
    );

    // rendering drawer header for filter operations and footer
    render(
      html`
        <style>
          .p-4 {
            padding: 1rem;
          }
          .opc-notification-drawer__header-chip-group {
            margin-top: 0.5rem;
            display: flex;
            flex-wrap: wrap;
          }
          .opc-notification-drawer__header-chip-group
            opc-filter-chip:not(:last-child) {
            margin-right: 4px;
          }
        </style>
        <div
          slot="header-body"
          class="opc-notification-drawer__header-chip-group"
        ></div>
        <div class="opc-notification-item"></div>
        <div class="p-4" slot="footer">
          <opc-drawer-footer></opc-drawer-footer>
        </div>
      `,
      this.opcNotificationDrawer
    );
  }

  private injectFeedback() {
    // check
    const feedback = this.querySelector("opc-feedback");
    if (!feedback) {
      !this.isWarningHidden && console.warn("feedback component not found");
      return;
    }
    // adding event listeners on feedback component
    feedback.addEventListener("opc-feedback:submit", (event: any) => {
      event.detail.data.createdBy =
        window.OpAuthHelper?.getUserInfo()?.rhatUUID;
      window.sendFeedback(event.detail.data);
    });
  }

  private async getAppListAndNotifications(userDetails: string[]) {
    try {
      // get app and notifications from server
      const res = await this.api.gqlClient
        .query<GetAppList>(GET_APP_LIST, {
          targets: userDetails,
        })
        .toPromise();

      if (!res?.data) return;

      this.apps =
        res.data.appsList
          ?.slice()
          .sort((prev, next) =>
            prev.name?.toLowerCase() <= next.name?.toLowerCase() ? -1 : 1
          ) || [];
      this.notifications = res.data.notificationsList.map((notification) => ({
        ...notification,
        app: notification.config.source.name,
      }));
    } catch (error) {
      console.error(error);
    }
  }

  private loadDrawerAppList(apps: Apps[]) {
    this.opcMenuDrawer.links = [
      {
        title: "Applications",
        links: apps
          .filter(({ applicationType }) => applicationType === "HOSTED")
          .map(({ name, path, isActive }) => ({
            name,
            href: path,
            isDisabled: !(isActive && Boolean(path)),
          })),
      },
      {
        title: "Built In Services",
        links: apps
          .filter(({ applicationType }) => applicationType === "BUILTIN")
          .map(({ name, path, isActive }) => ({
            name,
            href: path,
            isDisabled: !(isActive && Boolean(path)),
          })),
      },
    ];
  }

  private async handleNotificationSubscription(userDetails: string[]) {
    try {
      if (this._notificationsSubscription) {
        this._notificationsSubscription.unsubscribe();
      }

      this._notificationsSubscription = pipe(
        this.api.gqlClient.subscription<
          SubscribeNotification,
          { targets: string[] }
        >(SUBSCRIBE_NOTIFICATION, { targets: userDetails }),
        subscribe((res) => {
          if (res.error) {
            console.error(res.error);
          }
          if (res?.data?.notification) {
            this.showToast(res.data.notification, {
              addToDrawer: true,
              variant: "info",
            });
          }
        })
      );
    } catch (error) {
      console.error(error);
    }
  }

  showToast(
    notification: Notification,
    options: ToastOptions = { variant: "info" }
  ): void {
    if (!notification.sentOn) {
      notification.sentOn = dayjs().toISOString();
    }

    // if no id save as timestamp to do the close operation
    if (!notification.id) {
      notification.id = String(new Date().getTime());
    }
    options = { addToDrawer: false, duration: "5s", ...options };

    // creating the toast component dynamically
    const toastRef: Ref<HTMLDivElement> = createRef();
    const toast = html`<opc-toast
      ${ref(toastRef)}
      .notification=${notification}
      .options=${options}
    ></opc-toast>`;

    // rendering it in toast-container
    this.addToastToList(toast);
    this.requestUpdate();
    this.updateComplete.then(() => {
      (toastRef.value as any)?.toastRef?.open();
    });

    if (options.addToDrawer) {
      this.notifications = [
        ...this.notifications,
        { ...notification, variant: options.variant },
      ];
    }
  }

  async sendFeedback(feedbackInput: CreateFeedbackVariable["input"]) {
    try {
      const res = await this.api.gqlClient
        .mutation<CreateFeedback, CreateFeedbackVariable>(CREATE_FEEDBACK, {
          input: feedbackInput,
        })
        .toPromise();
      if (res?.data?.createFeedback) {
        window.OpNotification
          ? window.OpNotification.success({
              subject: `Submitted Feedback`,
              link: res?.data?.createFeedback?.ticketUrl,
            })
          : alert("Submitted Feedback");
      } else {
        window.OpNotification
          ? window.OpNotification.danger({
              subject: `Error in Feedback Submission`,
            })
          : alert("Error in Feedback Submission");
        throw new Error(
          "There were some errors in the query" + JSON.stringify(res.error)
        );
      }
      return res.data;
    } catch (error) {
      window.OpNotification
        ? window.OpNotification.danger({
            subject: `Error in Feedback Submission`,
            body: `The Server returned an empty response.`,
          })
        : alert("Error in Feedback Submission");
      throw new Error("The Server returned an empty response.");
    }
  }

  private addToastToList(newToast: TemplateResult<1>) {
    render(newToast, this.toastContainer, {
      renderBefore: this.toastContainer.firstChild,
    });
    const toasts = [
      ...this.toastContainer.querySelectorAll("opc-toast"),
    ] as OpcToast[];
    toasts.slice(5).map((toast) => (toast.toastRef as any).open());
  }

  private get userInfo() {
    return window.OpAuthHelper.getUserInfo();
  }

  private handleNotificationClose(removedId: string) {
    this.notifications = this.notifications.filter(
      ({ id }) => id !== removedId
    );
  }

  private handleFilterChipSelect(app: string) {
    if (this.selectedAppsForNotificationFilter.includes(app)) {
      this.selectedAppsForNotificationFilter =
        this.selectedAppsForNotificationFilter.filter(
          (selectedApp) => app !== selectedApp
        );
    } else {
      this.selectedAppsForNotificationFilter = [
        ...this.selectedAppsForNotificationFilter,
        app,
      ];
    }
  }

  /**
   * Will update is used for derived propery calculation
   * notification -> app count is recomputed on notification state change
   */
  willUpdate(changedProperties: any): void {
    // only need to check changed properties for an expensive computation.
    if (
      changedProperties.has("notifications") ||
      changedProperties.has("selectedAppsForNotificationFilter")
    ) {
      this.notificationAppCount = getNotificationAppCount(this.notifications);
      const chipContainer: any = this.querySelector(
        ".opc-notification-drawer__header-chip-group"
      );
      const headerContainer: any = this.querySelector(".opc-notification-item");
      if (chipContainer) {
        render(
          html`
            ${Object.entries(this.notificationAppCount).map(([app, count]) => {
              if (!count) {
                return null;
              }
              const isChipActive =
                this.selectedAppsForNotificationFilter.includes(
                  app.toLowerCase()
                );
              return html`<opc-filter-chip
                @click=${() => this.handleFilterChipSelect(app.toLowerCase())}
                ?isChipActive=${isChipActive}
              >
                ${app} +${count}
              </button>`;
            })}
          `,
          chipContainer
        );
      }
      // to render the chips for filtering in notification drawer
      if (headerContainer) {
        render(
          html`${this.notifications
            .filter(({ app = "others" }) => {
              if (this.selectedAppsForNotificationFilter.length === 0) {
                return true;
              }
              return this.selectedAppsForNotificationFilter.includes(
                app.toLowerCase()
              );
            })
            .map(
              (notification) =>
                html` <opc-notification-item
                  title=${notification.subject}
                  variant=${notification.variant}
                  key=${notification.id}
                  @opc-notification-item:close=${() => {
                    this.handleNotificationClose(notification.id!);
                  }}
                >
                  <span>
                    ${notification.body}.
                    ${notification.link
                      ? html`<a href=${notification.link}>This is the link.</a>`
                      : ""}
                  </span>
                </opc-notification-item>`
            )}`,
          this.opcNotificationDrawer,
          { renderBefore: headerContainer }
        );
      }
    }
  }

  render() {
    return html`
      <opc-loader ?hidden=${!this.isLoading}></opc-loader>
      <slot></slot>
      <div class="opc-toast-container"></div>
    `;
  }
}
Example #23
Source File: element.ts    From starboard-notebook with Mozilla Public License 2.0 4 votes vote down vote up
@customElement("starboard-rich-editor")
export class StarboardRichEditorElement extends LitElement {
  content: ContentContainer;
  runtime: any;
  opts: { editable?: ((state: EditorState) => boolean) | undefined };

  editor!: RichMarkdownEditor;
  editorVNode!: React.CElement<Props, RichMarkdownEditor>;

  constructor(content: ContentContainer, runtime: any, opts: { editable?: (state: EditorState) => boolean } = {}) {
    super();
    this.content = content;
    this.runtime = runtime;
    this.opts = opts;

    this.editorVNode = this.setupEditor();
    this.editor = render(this.editorVNode, this) as unknown as RichMarkdownEditor;
  }

  connectedCallback() {
    super.connectedCallback();

    // We don't run the cell if the editor has focus, as shift+enter has special meaning.
    this.addEventListener("keydown", (event: KeyboardEvent) => {
      if (event.key === "Enter" && this.editor.view.hasFocus()) {
        if (event.ctrlKey || event.shiftKey) {
          event.stopPropagation();
          return true;
        }
      }
    });
  }

  createRenderRoot() {
    return this;
  }

  private setupEditor() {
    const editorTheme: typeof theme = { ...theme };

    editorTheme.fontFamily = "var(--font-sans)";
    editorTheme.fontFamilyMono = "var(--font-mono)";

    const math = new Math();
    const mathDisplay = new MathDisplay();

    return createElement(RichMarkdownEditor, {
      defaultValue: this.content.textContent,
      placeholder: "Start writing here..",
      extensions: [math, mathDisplay],
      theme: editorTheme,
      onChange: (v) => {
        this.content.textContent = v();
      },
      readOnly: this.content.editable === false,
      onClickLink: (href, event) => {
        window.open(href, "_blank");
      },
      embeds: [],
      tooltip: undefined as any,
    });
  }

  public refreshSettings() {
    // Dummy transaction
    this.editor.view.dispatch(this.editor.view.state.tr);
  }

  getContentAsMarkdownString() {
    return this.editor.value();
  }

  focus() {
    this.editor.focusAtStart();
  }

  setCaretPosition(position: "start" | "end") {
    if (position === "start") {
      this.editor.focusAtStart();
    } else {
      this.editor.focusAtEnd();
    }
  }

  dispose() {
    // No cleanup yet..
  }
}
Example #24
Source File: dotlottie-player.ts    From player-component with MIT License 4 votes vote down vote up
/**
 * DotLottiePlayer web component class
 *
 * @export
 * @class DotLottiePlayer
 * @extends {LitElement}
 */
@customElement('dotlottie-player')
export class DotLottiePlayer extends LitElement {
  /**
   * Animation container.
   */
  @query('.animation')
  protected container!: HTMLElement;

  /**
   * Play mode.
   */
  @property()
  public mode: PlayMode = PlayMode.Normal;

  /**
   * Autoplay animation on load.
   */
  @property({ type: Boolean })
  public autoplay = false;

  /**
   * Background color.
   */
  @property({ type: String, reflect: true })
  public background?: string = 'transparent';

  /**
   * Show controls.
   */
  @property({ type: Boolean })
  public controls = false;

  /**
   * Number of times to loop animation.
   */
  @property({ type: Number })
  public count?: number;

  /**
   * Direction of animation.
   */
  @property({ type: Number })
  public direction = 1;

  /**
   * Whether to play on mouse hover
   */
  @property({ type: Boolean })
  public hover = false;

  /**
   * Whether to loop animation.
   */
  @property({ type: Boolean, reflect: true })
  public loop = false;

  /**
   * Renderer to use.
   */
  @property({ type: String })
  public renderer: 'svg' = 'svg';

  /**
   * Animation speed.
   */
  @property({ type: Number })
  public speed = 1;

  /**
   * Bodymovin JSON data or URL to JSON.
   */
  @property({ type: String })
  public src?: string;

  /**
   * Player state.
   */
  @property({ type: String })
  public currentState: PlayerState = PlayerState.Loading;

  @property()
  public seeker: any;

  @property()
  public intermission = 1;

  private _io?: any;
  private _lottie?: any;
  private _prevState?: any;
  private _counter = 0;

  /**
   * Handle visibility change events.
   */
  private _onVisibilityChange(): void {
    if (document.hidden && this.currentState === PlayerState.Playing) {
      this.freeze();
    } else if (this.currentState === PlayerState.Frozen) {
      this.play();
    }
  }

  /**
   * Handles click and drag actions on the progress track.
   */
  private _handleSeekChange(e: any): void {
    if (!this._lottie || isNaN(e.target.value)) {
      return;
    }

    const frame: number = (e.target.value / 100) * this._lottie.totalFrames;

    this.seek(frame);
  }

  /**
   * Configure and initialize lottie-web player instance.
   */
  public async load(src: string): Promise<void> {
    if (!this.shadowRoot) {
      return;
    }

    const options: any = {
      container: this.container,
      loop: false,
      autoplay: false,
      renderer: this.renderer,
      rendererSettings: {
        scaleMode: 'noScale',
        clearCanvas: false,
        progressiveLoad: true,
        hideOnTransparent: true,
      },
    };

    // Load the resource information
    try {
      const srcParsed = await fetchPath(src);

      // Clear previous animation, if any
      if (this._lottie) {
        this._lottie.destroy();
      }

      // Initialize lottie player and load animation
      this._lottie = lottie.loadAnimation({
        ...options,
        animationData: srcParsed,
      });
    } catch (err) {
      this.currentState = PlayerState.Error;

      this.dispatchEvent(new CustomEvent(PlayerEvents.Error));
      return;
    }

    if (this._lottie) {
      // Calculate and save the current progress of the animation
      this._lottie.addEventListener('enterFrame', () => {
        this.seeker = (this._lottie.currentFrame / this._lottie.totalFrames) * 100;

        this.dispatchEvent(
          new CustomEvent(PlayerEvents.Frame, {
            detail: {
              frame: this._lottie.currentFrame,
              seeker: this.seeker,
            },
          }),
        );
      });

      // Handle animation play complete
      this._lottie.addEventListener('complete', () => {
        if (this.currentState !== PlayerState.Playing) {
          this.dispatchEvent(new CustomEvent(PlayerEvents.Complete));
          return;
        }

        if (!this.loop || (this.count && this._counter >= this.count)) {
          this.dispatchEvent(new CustomEvent(PlayerEvents.Complete));
          return;
        }

        if (this.mode === PlayMode.Bounce) {
          if (this.count) {
            this._counter += 0.5;
          }

          setTimeout(() => {
            this.dispatchEvent(new CustomEvent(PlayerEvents.Loop));

            if (this.currentState === PlayerState.Playing) {
              this._lottie.setDirection(this._lottie.playDirection * -1);
              this._lottie.play();
            }
          }, this.intermission);
        } else {
          if (this.count) {
            this._counter += 1;
          }

          window.setTimeout(() => {
            this.dispatchEvent(new CustomEvent(PlayerEvents.Loop));

            if (this.currentState === PlayerState.Playing) {
              this._lottie.stop();
              this._lottie.play();
            }
          }, this.intermission);
        }
      });

      // Handle lottie-web ready event
      this._lottie.addEventListener('DOMLoaded', () => {
        this.dispatchEvent(new CustomEvent(PlayerEvents.Ready));
      });

      // Handle animation data load complete
      this._lottie.addEventListener('data_ready', () => {
        this.dispatchEvent(new CustomEvent(PlayerEvents.Load));
      });

      // Set error state when animation load fail event triggers
      this._lottie.addEventListener('data_failed', () => {
        this.currentState = PlayerState.Error;

        this.dispatchEvent(new CustomEvent(PlayerEvents.Error));
      });

      // Set handlers to auto play animation on hover if enabled
      this.container.addEventListener('mouseenter', () => {
        if (this.hover && this.currentState !== PlayerState.Playing) {
          this.play();
        }
      });
      this.container.addEventListener('mouseleave', () => {
        if (this.hover && this.currentState === PlayerState.Playing) {
          this.stop();
        }
      });

      // Set initial playback speed and direction
      this.setSpeed(this.speed);
      this.setDirection(this.direction);

      // Start playing if autoplay is enabled
      if (this.autoplay) {
        this.play();
      }
    }
  }

  /**
   * Returns the lottie-web instance used in the component.
   */
  public getLottie(): any {
    return this._lottie;
  }

  /**
   * Start playing animation.
   */
  public play() {
    if (!this._lottie) {
      return;
    }

    this._lottie.play();
    this.currentState = PlayerState.Playing;

    this.dispatchEvent(new CustomEvent(PlayerEvents.Play));
  }

  /**
   * Pause animation play.
   */
  public pause(): void {
    if (!this._lottie) {
      return;
    }

    this._lottie.pause();
    this.currentState = PlayerState.Paused;

    this.dispatchEvent(new CustomEvent(PlayerEvents.Pause));
  }

  /**
   * Stops animation play.
   */
  public stop(): void {
    if (!this._lottie) {
      return;
    }

    this._counter = 0;
    this._lottie.stop();
    this.currentState = PlayerState.Stopped;

    this.dispatchEvent(new CustomEvent(PlayerEvents.Stop));
  }

  /**
   * Seek to a given frame.
   */
  public seek(value: number | string): void {
    if (!this._lottie) {
      return;
    }

    // Extract frame number from either number or percentage value
    const matches = value.toString().match(/^([0-9]+)(%?)$/);
    if (!matches) {
      return;
    }

    // Calculate and set the frame number
    const frame = matches[2] === '%' ? (this._lottie.totalFrames * Number(matches[1])) / 100 : matches[1];

    // Set seeker to new frame number
    this.seeker = frame;

    // Send lottie player to the new frame
    if (this.currentState === PlayerState.Playing) {
      this._lottie.goToAndPlay(frame, true);
    } else {
      this._lottie.goToAndStop(frame, true);
      this._lottie.pause();
    }
  }

  /**
   * Snapshot the current frame as SVG.
   *
   * If 'download' argument is boolean true, then a download is triggered in browser.
   */
  public snapshot(download = true): string | void {
    if (!this.shadowRoot) return;

    // Get SVG element and serialize markup
    const svgElement = this.shadowRoot.querySelector('.animation svg') as Node;
    const data = new XMLSerializer().serializeToString(svgElement);

    // Trigger file download
    if (download) {
      const element = document.createElement('a');
      element.href = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(data);
      element.download = 'download_' + this.seeker + '.svg';
      document.body.appendChild(element);

      element.click();

      document.body.removeChild(element);
    }

    return data;
  }

  /**
   * Freeze animation play.
   * This internal state pauses animation and is used to differentiate between
   * user requested pauses and component instigated pauses.
   */
  private freeze(): void {
    if (!this._lottie) {
      return;
    }

    this._lottie.pause();
    this.currentState = PlayerState.Frozen;

    this.dispatchEvent(new CustomEvent(PlayerEvents.Freeze));
  }

  /**
   * Sets animation play speed.
   *
   * @param value Playback speed.
   */
  public setSpeed(value = 1): void {
    if (!this._lottie) {
      return;
    }

    this._lottie.setSpeed(value);
  }

  /**
   * Animation play direction.
   *
   * @param value Direction values.
   */
  public setDirection(value: number): void {
    if (!this._lottie) {
      return;
    }

    this._lottie.setDirection(value);
  }

  /**
   * Sets the looping of the animation.
   *
   * @param value Whether to enable looping. Boolean true enables looping.
   */
  public setLooping(value: boolean): void {
    if (this._lottie) {
      this.loop = value;
      this._lottie.loop = value;
    }
  }

  /**
   * Toggle playing state.
   */
  public togglePlay(): void {
    return this.currentState === PlayerState.Playing ? this.pause() : this.play();
  }

  /**
   * Toggles animation looping.
   */
  public toggleLooping(): void {
    this.setLooping(!this.loop);
  }

  /**
   * Returns the styles for the component.
   */
  static get styles() {
    return styles;
  }

  /**
   * Initialize everything on component first render.
   */
  protected async firstUpdated(): Promise<void> {
    // Add intersection observer for detecting component being out-of-view.
    if ('IntersectionObserver' in window) {
      this._io = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
        if (entries[0].isIntersecting) {
          if (this.currentState === PlayerState.Frozen) {
            this.play();
          }
        } else if (this.currentState === PlayerState.Playing) {
          this.freeze();
        }
      });

      this._io.observe(this.container);
    }

    // Add listener for Visibility API's change event.
    if (typeof document.hidden !== 'undefined') {
      document.addEventListener('visibilitychange', () => this._onVisibilityChange());
    }

    // Setup lottie player
    if (this.src) {
      await this.load(this.src);
    }
  }

  /**
   * Cleanup on component destroy.
   */
  public disconnectedCallback(): void {
    // Remove intersection observer for detecting component being out-of-view.
    if (this._io) {
      this._io.disconnect();
      this._io = undefined;
    }

    // Remove the attached Visibility API's change event listener.
    document.removeEventListener('visibilitychange', () => this._onVisibilityChange());
  }

  protected renderControls() {
    const isPlaying: boolean = this.currentState === PlayerState.Playing;
    const isPaused: boolean = this.currentState === PlayerState.Paused;
    const isStopped: boolean = this.currentState === PlayerState.Stopped;

    return html`
      <div id="lottie-controls" aria-label="lottie-animation-controls" class="toolbar">
        <button
          id="lottie-play-button"
          @click=${this.togglePlay}
          class=${isPlaying || isPaused ? 'active' : ''}
          style="align-items:center;"
          tabindex="0"
          aria-label="play-pause"
        >
          ${isPlaying
            ? html`
                <svg width="24" height="24" aria-hidden="true" focusable="false">
                  <path d="M14.016 5.016H18v13.969h-3.984V5.016zM6 18.984V5.015h3.984v13.969H6z" />
                </svg>
              `
            : html`
                <svg width="24" height="24" aria-hidden="true" focusable="false">
                  <path d="M8.016 5.016L18.985 12 8.016 18.984V5.015z" />
                </svg>
              `}
        </button>
        <button
          id="lottie-stop-button"
          @click=${this.stop}
          class=${isStopped ? 'active' : ''}
          style="align-items:center;"
          tabindex="0"
          aria-label="stop"
        >
          <svg width="24" height="24" aria-hidden="true" focusable="false">
            <path d="M6 6h12v12H6V6z" />
          </svg>
        </button>
        <input
          id="lottie-seeker-input"
          class="seeker"
          type="range"
          min="0"
          step="1"
          max="100"
          .value=${this.seeker}
          @input=${this._handleSeekChange}
          @mousedown=${() => {
            this._prevState = this.currentState;
            this.freeze();
          }}
          @mouseup=${() => {
            this._prevState === PlayerState.Playing && this.play();
          }}
          aria-valuemin="1"
          aria-valuemax="100"
          role="slider"
          aria-valuenow=${this.seeker}
          tabindex="0"
          aria-label="lottie-seek-input"
        />
        <button
          id="lottie-loop-toggle"
          @click=${this.toggleLooping}
          class=${this.loop ? 'active' : ''}
          style="align-items:center;"
          tabindex="0"
          aria-label="loop-toggle"
        >
          <svg width="24" height="24" aria-hidden="true" focusable="false">
            <path
              d="M17.016 17.016v-4.031h1.969v6h-12v3l-3.984-3.984 3.984-3.984v3h10.031zM6.984 6.984v4.031H5.015v-6h12v-3l3.984 3.984-3.984 3.984v-3H6.984z"
            />
          </svg>
        </button>
      </div>
    `;
  }

  render(): TemplateResult | void {
    const className: string = this.controls ? 'main controls' : 'main';
    const animationClass: string = this.controls ? 'animation controls' : 'animation';
    return html`
      <div id="animation-container" class=${className} lang="en" role="img">
        <div id="animation" class=${animationClass} style="background:${this.background};">
          ${this.currentState === PlayerState.Error
            ? html`
                <div class="error">⚠️</div>
              `
            : undefined}
        </div>
        ${this.controls ? this.renderControls() : undefined}
      </div>
    `;
  }
}
Example #25
Source File: opc-feedback.ts    From op-components with MIT License 4 votes vote down vote up
@customElement('opc-feedback')
export class OpcFeedback extends LitElement {
  @property({ type: String, attribute: 'spa' }) spa = '/feedback';
  @property({ type: String, attribute: 'docs' }) docs = '/get-started';
  @property({ reflect: true }) theme = 'red';
  @property({ type: Object }) template = defaultTemplate;
  @property({ type: Object }) app = defaultApplication;
  @state()
  _openConfirmationModal = false;
  @state()
  _openFeedbackModal = false;
  @state()
  _openInitialModal = false;
  @state()
  _openBugModal = false;
  @state() protected _summary = '';
  @state() _experience = '';
  @state()
  _error = '';
  _path = window.location.pathname;

  @query('textarea') textarea: HTMLTextAreaElement;

  constructor() {
    super();
  }

  static get styles() {
    return [style];
  }

  _resetForm() {
    this._summary = '';
    this._experience = '';
    this._error = '';
    this.textarea.value = '';
  }

  _setExperience(selectedExperience) {
    this._experience = selectedExperience;
  }

  _isValidUrl(url: string): boolean {
    try {
      new URL(url);
      return true;
    } catch {
      return false;
    }
  }

  _updateTemplate() {
    this.template = { ...defaultTemplate, ...this.template };
    if (!this._isValidUrl(this.docs)) {
      console.warn(
        `${this.tagName.toLowerCase()} URL validation failed for docs`
      );
    }
    if (!this._isValidUrl(this.spa)) {
      console.warn(
        `${this.tagName.toLowerCase()} URL validation failed for spa`
      );
    }
  }

  _setError(selectedError) {
    this._error = selectedError;
  }

  _submitFeedback() {
    this.dispatchEvent(
      new CustomEvent('opc-feedback:submit', {
        detail: {
          message: this.template.confirmationEventMessage,
          data: {
            summary: this._summary,
            experience: this._experience ? this._experience : null,
            error: this._error ? this._error : null,
            category: this._error ? 'BUG' : 'FEEDBACK',
            stackInfo: {
              stack: navigator.appVersion,
              // Includes the url of the page and removes the last chracter of url ending with / and #
              path:
                (this._path.length !== 1 &&
                  this._path.substr(this._path.length - 1, 1) === '/') ||
                this._path.substr(this._path.length - 1, 1) === '#'
                  ? this._path.slice(0, -1)
                  : this._path,
            },
          },
        },
      })
    );
    this._resetForm();
  }

  toggle() {
    this._openInitialModal = !this._openInitialModal;
  }

  _setModalState(
    initialModalState,
    feedbackModalState,
    bugModalState,
    confirmationModalState
  ) {
    this._openInitialModal = initialModalState;
    this._openFeedbackModal = feedbackModalState;
    this._openBugModal = bugModalState;
    this._openConfirmationModal = confirmationModalState;
  }

  set feedbackTemplate(template) {
    if (!template) {
      console.warn(
        `${this.tagName.toLowerCase()}:Object "template" must be provided. You can do so by using feedbackTemplate setter function`
      );
    } else {
      this.template = { ...defaultTemplate, ...template };
    }
  }

  get feedbackTemplate() {
    return this.template;
  }

  get renderIcon() {
    const isModelOpen =
      this._openFeedbackModal ||
      this._openConfirmationModal ||
      this._openInitialModal ||
      this._openBugModal;
    if (this.theme === 'blue' && !isModelOpen) {
      return html`<img
        src=${beetleIcon}
        width="20px"
        height="20px"
        class="pf-u-mr-xs"
      />`;
    }
    return html`<ion-icon
      name="${isModelOpen ? 'ellipsis-horizontal-outline' : 'chatbox-ellipses'}"
      class="pf-u-font-size-xl pf-u-mr-xs"
    >
    </ion-icon>`;
  }

  updated() {
    dialogPolyfill.registerDialog(
      this.shadowRoot.getElementById('initial-dialog') as any
    );
    dialogPolyfill.registerDialog(
      this.shadowRoot.getElementById('bug-dialog') as any
    );
    dialogPolyfill.registerDialog(
      this.shadowRoot.getElementById('feedback-dialog') as any
    );
    dialogPolyfill.registerDialog(
      this.shadowRoot.getElementById('confirmation-dialog') as any
    );
  }

  render() {
    this._updateTemplate();
    return html`
      <link
        type="text/css"
        rel="stylesheet"
        href="https://unpkg.com/@patternfly/patternfly/patternfly.css"
        crossorigin="anonymous"
      />
      <link
        type="text/css"
        rel="stylesheet"
        href="https://unpkg.com/@patternfly/patternfly/patternfly-addons.css"
        crossorigin="anonymous"
      />

      <!-- Bug Panel -->
      <dialog
        id="bug-dialog"
        class="op-feedback__panel pf-u-mr-0 pf-u-display-block"
        .open="${this._openBugModal}"
      >
        <form class="bug-form" id="bugform">
          <div class="pf-u-display-flex">
            <img
              src="${arrowBackIcon}"
              width="14.25px"
              class="pf-u-font-size-lg"
              @click="${(e) => {
                this._setModalState(true, false, false, true);
                this._resetForm();
              }}"
            />
            <header>
              <h3
                class="pf-u-font-weight-normal pf-u-font-size-lg pf-u-text-align-center pf-u-m-xs"
              >
                ${this.template.errorTitle}
              </h3>
            </header>
          </div>
          ${this.template.errorList.map(
            (error: any) => html`
              <div
                class="pf-c-chip pf-m-draggable op-feedback__chip ${this
                  ._error === error.name
                  ? 'op-feedback__chip__active'
                  : ''}"
                @click="${(e) => this._setError(error.name)}"
                @keydown=${(e) =>
                  e.key === 'Enter' ? this._setError(error.name) : ''}
                tabindex="0"
              >
                <span class="pf-u-font-size-xs">${error.name}</span>
              </div>
            `
          )}
          <p class="op-feedback__subtitle pf-u-font-size-md pf-u-pt-md">
            ${this.template.summary}
          </p>
          <textarea
            id="bugsummary"
            rows="3"
            name="bugsummary"
            @input="${(e: HTMLElementEventMap | any) =>
              (this._summary = e.target.value)}"
            placeholder=${this.template.summaryPlaceholder}
            class="pf-c-form-control pf-m-resize-vertical"
            required
          ></textarea>
          <p class="op-feedback__subtitle pf-u-font-size-xs">
            ${this.template.bugSubmissionNote}
          </p>
          <button
            class="pf-c-button pf-m-block pf-u-my-sm ${this._error.length === 0
              ? 'pf-u-display-none-on-sm'
              : ''}"
            type="button"
            @click="${(e) => {
              this._submitFeedback();
              this._setModalState(
                false,
                false,
                false,
                !this._openConfirmationModal
              );
            }}"
          >
            Submit
          </button>
          <button
            id="submit-bug"
            class="pf-c-button pf-m-block pf-u-my-sm ${this._error.length !== 0
              ? 'pf-u-display-none-on-sm'
              : ''}"
            type="button"
            disabled
          >
            Submit
          </button>
        </form>
        <p class="pf-u-font-size-xs">
          Bug reporting for
          <a href="${this.app.url}" target="_blank" rel="noopener noreferrer">
            ${this.app.name}
          </a>
        </p>
      </dialog>

      <!-- Feedback Panel -->
      <dialog
        id="feedback-dialog"
        class="op-feedback__panel pf-u-mr-0 pf-u-display-block"
        .open="${this._openFeedbackModal}"
      >
        <form class="feedback-form" id="feedbackform">
          <div class="pf-u-display-flex">
            <img
              src="${arrowBackIcon}"
              width="14.25px"
              class="pf-u-font-size-lg"
              @click="${(e) => {
                this._setModalState(true, false, false, true);
                this._resetForm();
              }}"
            />
            <header>
              <h3
                class="pf-u-font-weight-normal pf-u-font-size-lg pf-u-text-align-center pf-u-m-0"
              >
                ${this.template.feedbackTitle}
              </h3>
            </header>
          </div>
          <p
            class="op-feedback__subtitle pf-u-text-align-center pf-u-font-size-sm pf-u-pt-md pf-u-pb-md"
          >
            ${this.template.subtitle}
          </p>
          ${this.template.experienceList.map(
            (experience: any) => html`
              <div
                class="pf-c-chip pf-m-draggable op-feedback__chip ${this
                  ._experience === experience.name
                  ? 'op-feedback__chip__active'
                  : ''}"
                @click="${(e) => this._setExperience(experience.name)}"
                @keydown=${(e) =>
                  e.key === 'Enter' ? this._setExperience(experience.name) : ''}
                tabindex="0"
              >
                <span class="pf-c-chip__icon">
                  <img
                    src="${experience.assetUrl}"
                    alt="${experience.name} icon"
                    width="17px"
                  />
                </span>
                <span class="pf-u-font-size-xs">&nbsp;${experience.name}</span>
              </div>
            `
          )}
          <p class="op-feedback__subtitle pf-u-font-size-md pf-u-pt-md">
            ${this.template.summary}
          </p>
          <textarea
            id="feedbacksummary"
            rows="3"
            name="feedbacksummary"
            @input="${(e: HTMLElementEventMap | any) =>
              (this._summary = e.target.value)}"
            placeholder=${this.template.summaryPlaceholder}
            class="pf-c-form-control pf-m-resize-vertical"
            required
          ></textarea>
          <button
            class="pf-c-button pf-m-block pf-u-my-sm ${this._experience
              .length === 0
              ? 'pf-u-display-none-on-sm'
              : ''}"
            type="button"
            @click="${(e) => {
              this._submitFeedback();
              this._setModalState(
                false,
                false,
                false,
                !this._openConfirmationModal
              );
            }}"
          >
            Submit
          </button>
          <button
            id="submit-feedback"
            class="pf-c-button pf-m-block pf-u-my-sm  ${this._experience
              .length !== 0
              ? 'pf-u-display-none-on-sm'
              : ''}"
            type="button"
            disabled
          >
            Submit
          </button>
        </form>
        <p class="pf-u-font-size-xs">
          Feedback for
          <a href="${this.app.url}" target="_blank" rel="noopener noreferrer">
            ${this.app.name}
          </a>
        </p>
      </dialog>

      <!-- Confirmation Modal -->
      <dialog
        id="confirmation-dialog"
        class="op-feedback__panel pf-u-mr-0"
        .open="${this._openConfirmationModal}"
      >
        <h2 class="pf-u-text-align-center">
          ${this.template.confirmationTitle}
        </h2>
        <p
          class="op-feedback__subtitle pf-u-text-align-center pf-u-font-size-xs pf-u-pb-md"
        >
          ${this.template.confirmationSubTitle}
        </p>
        <hr class="pf-c-divider" />
        <a href="${this.spa}" target="_blank">
          <p class="pf-u-text-align-center pf-u-p-xs">
            ${this.template.spaRedirectTitle}
          </p>
        </a>
        <hr class="pf-c-divider" />
        <p
          class="op-feedback__subtitle pf-u-text-align-center pf-u-p-xs"
          @click="${(e) => (this._openConfirmationModal = false)}"
          @keydown=${(e) =>
            e.key === 'Enter' ? (this._openConfirmationModal = false) : ''}
          tabindex="0"
        >
          close
        </p>
      </dialog>

      <!-- Initial Modal -->
      <dialog
        id="initial-dialog"
        class="op-feedback__panel pf-u-mr-0 pf-u-p-0"
        .open="${this._openInitialModal}"
      >
        <header class="op-feedback__header">
          <h3 class="pf-u-font-weight-normal pf-u-font-size-md pf-u-m-0">
            ${this.template.dialogTitle}
          </h3>
        </header>
        <main id="op-feedback__panel-body">
          <ul class="op-feedback__options pf-u-m-0 pf-u-p-0">
            <li>
              <button
                type="button"
                autofocus="true"
                data-feedback-type="bug"
                class="op-feedback__option-item pf-u-flex-direction-row pf-u-align-items-center pf-u-w-100 pf-u-display-flex"
                @click="${(e) => {
                  this.toggle();
                  this._setModalState(false, false, true, false);
                }}"
              >
                <img
                  src="${bugIcon}"
                  width="16px"
                  class="op-feedback__option-icon pf-m-text-align-left"
                />&nbsp; ${this.template.bugReportTitle}
              </button>
            </li>
            <li>
              <button
                type="button"
                data-feedback-type="feedback"
                class="op-feedback__option-item pf-u-flex-direction-row pf-u-align-items-center pf-u-w-100 pf-u-display-flex"
                @click="${(e) => {
                  this.toggle();
                  this._setModalState(false, true, false, false);
                }}"
              >
                <img
                  src="${chatboxIcon}"
                  width="16px"
                  class="op-feedback__option-icon pf-m-text-align-left"
                />&nbsp; ${this.template.feedbackReportTitle}
              </button>
            </li>
            <li>
              <a
                href="${this.docs}"
                data-feedback-type="feedback-list"
                class="op-feedback__option-item pf-u-flex-direction-row pf-u-align-items-center pf-u-w-100 pf-u-display-flex"
              >
                <img
                  src="${documentIcon}"
                  width="16px"
                  class="op-feedback__option-icon pf-m-text-align-left"
                />&nbsp; ${this.template.documentationTitle}
                <img
                  src="${openLinkIcon}"
                  width="16px"
                  class="op-feedback__icon-secondary pf-u-ml-xs"
                />
              </a>
            </li>
            <li>
              <a
                href="${this.spa}"
                data-feedback-type="feedback-list"
                class="op-feedback__option-item pf-u-flex-direction-row pf-u-align-items-center pf-u-w-100 pf-u-display-flex"
              >
                <img
                  src="${chatBubblesIcon}"
                  width="16px"
                  class="op-feedback__option-icon pf-m-text-align-left"
                />&nbsp; ${this.template.spaRedirectTitle}
                <img
                  src="${openLinkIcon}"
                  width="16px"
                  class="op-feedback__icon-secondary pf-u-ml-xs"
                />
              </a>
            </li>
          </ul>
        </main>
      </dialog>

      <!-- Feedback Button -->
      <button
        id="feedback-popup"
        type="button"
        class="op-feedback__button pf-u-align-items-center pf-u-flex-direction-row pf-u-display-flex"
        @click="${(e) => {
          this.toggle();
          this._setModalState(this._openInitialModal, false, false, false);
        }}"
      >
        ${this.renderIcon} ${this.template.feedbackFAB}
      </button>
    `;
  }
}
Example #26
Source File: LitFlatpickr.ts    From lit-flatpickr with MIT License 4 votes vote down vote up
@customElement('lit-flatpickr')
export class LitFlatpickr extends LitElement {
  /**
   * Placeholder text for input element provided by lit-flatpickr
   * */
  @property({ type: String })
  placeholder = '';
  /**
   * Exactly the same as date format, but for the altInput field
   * @prop
   * @type string
   **/
  @property({ type: String })
  altFormat = 'F j, Y';
  /**
   * Show the user a readable date (as per altFormat), but return something totally different to the server.
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  altInput = false;
  /**
   * This class will be added to the input element created by the altInput option.
   * Note that altInput already inherits classes from the original input.
   * @prop
   * @type string
   * */
  @property({ type: String })
  altInputClass = '';
  /**
   * Allows the user to enter a date directly input the input field. By default, direct entry is disabled.
   * @prop
   * @type boolean
   **/
  @property({ type: Boolean })
  allowInput = false;
  /**
   * Defines how the date will be formatted in the aria-label for calendar days, using the same tokens as dateFormat.
   * If you change this, you should choose a value that will make sense if a screen reader reads it out loud
   * @prop
   * @type string
   **/
  @property({ type: String })
  ariaDateFormat = 'F j, Y';

  /**
   * Whether clicking on the input should open the picker.
   * You could disable this if you wish to open the calendar manually with.open()
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  clickOpens = true;

  /**
   * A string of characters which are used to define how the date will be displayed in the input box.
   * @prop
   * @type string
   * */
  @property({ type: String })
  dateFormat = 'Y-m-d';

  /**
   * Sets the initial selected date(s).
   *
   * If you're using mode: "multiple" or a range calendar supply an Array of
   * Date objects or an Array of date strings which follow your dateFormat.
   *
   * Otherwise, you can supply a single Date object or a date string.
   * @prop
   * @type {DateOption|DateOption[]}
   * */
  @property({ type: Object })
  defaultDate?: DateOption | DateOption[];

  /**
   * Initial value of the hour element.
   * @prop
   * @type number
   * */
  @property({ type: Number })
  defaultHour = 12;

  /**
   * Initial value of the minute element.
   * @prop
   * @type number
   * */
  @property({ type: Number })
  defaultMinute = 0;

  /**
   * Dates selected to be unavailable for selection.
   * @prop
   * @type DateLimit<DateOption>[]
   * */
  @property({ type: Array })
  disable: DateLimit<DateOption>[] = [];

  /**
   * Set disableMobile to true to always use the non-native picker.
   * By default, flatpickr utilizes native datetime widgets unless certain options (e.g. disable) are used.
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  disableMobile = false;

  /**
   * Dates selected to be available for selection.
   * @prop
   * @type DateLimit<DateOption>[]
   * */
  @property({ type: Array })
  enable: DateLimit<DateOption>[] | undefined = undefined;

  /**
   * Enables time picker
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  enableTime = false;

  /**
   * Enables seconds in the time picker
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  enableSeconds = false;

  /**
   * Allows using a custom date formatting function instead of the built-in
   * handling for date formats using dateFormat, altFormat, etc.
   *
   * Function format: (date: Date, format: string, locale: Locale) => string
   *
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  formatDateFn?: (date: Date, format: string, locale: Locale) => string;

  /**
   * Adjusts the step for the hour input (incl. scrolling)
   * @prop
   * @type number
   * */
  @property({ type: Number })
  hourIncrement = 1;

  /**
   * Adjusts the step for the minute input (incl. scrolling)
   * @prop
   * @type number
   * */
  @property({ type: Number })
  minuteIncrement = 5;

  /**
   * Displays the calendar inline
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  inline = false;

  /**
   * The maximum date that a user can pick to (inclusive).
   * @prop
   * @type DateOption
   * */
  @property({ type: String })
  maxDate?: DateOption;

  /**
   * The minimum date that a user can pick to (inclusive).
   * @prop
   * @type DateOption
   * */
  @property({ type: String })
  minDate?: DateOption;

  /**
   * "single", "multiple", "time" or "range"
   * @prop
   * @type {"single" | "multiple" | "range"}
   * */
  @property({ type: String })
  mode: 'single' | 'multiple' | 'range' | 'time' = 'single';

  /**
   * HTML for the arrow icon, used to switch months.
   * @prop
   * @type string
   * */
  @property({ type: String })
  nextArrow = '>';

  /**
   * HTML for the arrow icon, used to switch months.
   * @prop
   * @type string
   * */
  @property({ type: String })
  prevArrow = '<';

  /**
   * Hides the day selection in calendar.
   * Use it along with enableTime to create a time picker.
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  noCalendar = false;

  /**
   * Function(s) to trigger on every date selection
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onChange?: Hook;

  /**
   * Function(s) to trigger every time the calendar is closed
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onClose?: Hook;

  /**
   * Function(s) to trigger every time the calendar is opened
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onOpen?: Hook;

  /**
   * Function(s) to trigger when the calendar is ready
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onReady?: Hook;

  /**
   * Function(s) to trigger every time the calendar month is changed by the user or programmatically
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onMonthChange?: Hook;

  /**
   * Function(s) to trigger every time the calendar year is changed by the user or programmatically
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onYearChange?: Hook;

  /**
   * Function(s) to trigger when the input value is updated with a new date string
   * @prop
   * @type Function
   * */
  @property({ type: Function })
  onValueUpdate?: Hook;

  /**
   * Function that expects a date string and must return a Date object.
   *
   * Function format: (date: string, format: string) => string
   *
   * @prop
   * @type Function
   **/
  @property({ type: Function })
  parseDateFn?: (date: string, format: string) => Date;

  /**
   * Where the calendar is rendered relative to the input
   * @prop
   * @type {"auto" | "above" | "below"}
   * */
  @property({ type: String })
  position: 'auto' | 'above' | 'below' = 'auto';

  /**
   * Show the month using the shorthand version (ie, Sep instead of September)
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  shorthandCurrentMonth = false;

  /**
   * The number of months showed
   * @prop
   * @type number
   * */
  @property({ type: Number })
  showMonths = 1;

  /**
   * Position the calendar inside the wrapper and next to the input element
   * @prop
   * @type boolean
   **/
  @property({ type: Boolean })
  static = false;

  /**
   * Displays the time picker in 24 hour mode without AM/PM selection when enabled
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  time_24hr = false;

  /**
   * Enabled display of week numbers in calendar
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  weekNumbers = false;

  /**
   * flatpickr can parse an input group of textboxes and buttons, common in Bootstrap and other frameworks.
   * This permits additional markup, as well as custom elements to trigger the state of the calendar.
   * @prop
   * @type boolean
   * */
  @property({ type: Boolean })
  wrap = false;

  /**
   * The set theme of flatpickr.
   * @prop
   * @type { "light" | "dark" | "material_blue" | "material_red" | "material_green" | "material_orange" | "airbnb" | "confetti" | "none" }
   * */
  @property({ type: String })
  theme = 'light';

  @property({ type: Number })
  firstDayOfWeek = 1;

  @property({ type: String })
  locale: string | undefined;

  @property({ type: Boolean, attribute: 'default-to-today' })
  defaultToToday = false;

  @property({ type: Boolean, attribute: 'week-select' })
  weekSelect = false;

  @property({ type: Boolean, attribute: 'month-select' })
  monthSelect = false;

  @property({ type: Boolean, attribute: 'confirm-date' })
  confirmDate = false;

  _instance?: Instance;
  _inputElement?: HTMLInputElement;

  @property({ type: Boolean })
  _hasSlottedElement = false;

  static get styles() {
    return [
      css`
        :host {
          width: fit-content;
          display: block;
          cursor: pointer;
          background: #fff;
          color: #000;
          overflow: hidden;
        }

        ::slotted(*) {
          cursor: pointer;
        }

        input {
          width: 100%;
          height: 100%;
          font-size: inherit;
          cursor: pointer;
          background: inherit;
          box-sizing: border-box;
          outline: none;
          color: inherit;
          border: none;
        }
      `,
    ];
  }

  firstUpdated() {
    this._hasSlottedElement = this.checkForSlottedElement();
  }

  updated() {
    // TODO: Might not need to init every time updated, but only
    // when relevant stuff changes
    this.init();
  }

  getToday() {
    const today = new Date();
    const dateString = `${today.getFullYear()}-${today.getMonth() + 1}-${today.getDate()}`;
    return dateString;
  }

  checkForSlottedElement(): boolean {
    const slottedElem = this.shadowRoot?.querySelector('slot');
    // We don't want to think that a whitespace / line break is a node
    const assignedNodes = slottedElem ? slottedElem.assignedNodes().filter(this.removeTextNodes) : [];

    return slottedElem != null && assignedNodes && assignedNodes.length > 0;
  }

  getSlottedElement(): Element | undefined {
    if (!this._hasSlottedElement) {
      return undefined;
    }
    const slottedElem = this.shadowRoot?.querySelector('slot');
    const slottedElemNodes: Array<Node> | undefined = slottedElem?.assignedNodes().filter(this.removeTextNodes);
    if (!slottedElemNodes || slottedElemNodes.length < 1) {
      return undefined;
    }
    return slottedElemNodes[0] as Element;
  }

  removeTextNodes(node: Node): boolean {
    return node.nodeName !== '#text';
  }

  async init(): Promise<void> {
    const styleLoader = new StyleLoader(this.theme as FlatpickrTheme);
    await styleLoader.initStyles();
    if (this.locale) {
      await loadLocale(this.locale);
    }
    await this.initializeComponent();
  }

  async getOptions(): Promise<Options> {
    /* eslint-disable  @typescript-eslint/no-explicit-any */
    let options = {
      altFormat: this.altFormat,
      altInput: this.altInput,
      altInputClass: this.altInputClass,
      allowInput: this.allowInput,
      ariaDateFormat: this.ariaDateFormat,
      clickOpens: this.clickOpens,
      dateFormat: this.dateFormat,
      defaultDate: this.defaultToToday ? this.getToday() : this.defaultDate,
      defaultHour: this.defaultHour,
      defaultMinute: this.defaultMinute,
      disable: this.disable,
      disableMobile: this.disableMobile,
      enable: this.enable,
      enableTime: this.enableTime,
      enableSeconds: this.enableSeconds,
      formatDate: this.formatDateFn,
      hourIncrement: this.hourIncrement,
      inline: this.inline,
      maxDate: this.maxDate,
      minDate: this.minDate,
      minuteIncrement: this.minuteIncrement,
      mode: this.mode,
      nextArrow: this.nextArrow,
      prevArrow: this.prevArrow,
      noCalendar: this.noCalendar,
      onChange: this.onChange,
      onClose: this.onClose,
      onOpen: this.onOpen,
      onReady: this.onReady,
      onMonthChange: this.onMonthChange,
      onYearChange: this.onYearChange,
      onValueUpdate: this.onValueUpdate,
      parseDate: this.parseDateFn,
      position: this.position,
      shorthandCurrentMonth: this.shorthandCurrentMonth,
      showMonths: this.showMonths,
      static: this.static,
      // eslint-disable-next-line @typescript-eslint/camelcase
      time_24hr: this.time_24hr,
      weekNumbers: this.weekNumbers,
      wrap: this.wrap,
      locale: this.locale,
      plugins: [],
    } as any;
    options = await loadPlugins(this, options);
    Object.keys(options).forEach(key => {
      if (options[key] === undefined) delete options[key];
    });
    return options;
  }

  async initializeComponent(): Promise<void> {
    if (this._instance) {
      if (Object.prototype.hasOwnProperty.call(this, 'destroy')) {
        this._instance.destroy();
      }
    }

    let inputElement: HTMLInputElement | null;
    if (this._hasSlottedElement) {
      // If lit-flatpickr has a slotted element, it means that
      // the user wants to use their custom input.
      inputElement = this.findInputField();
    } else {
      inputElement = this.shadowRoot?.querySelector('input') as HTMLInputElement;
    }

    if (inputElement) {
      this._inputElement = inputElement as HTMLInputElement;
      flatpickr.l10ns.default.firstDayOfWeek = this.firstDayOfWeek;
      const options = await this.getOptions();
      this._instance = flatpickr(inputElement, options);
    }
  }

  findInputField(): HTMLInputElement | null {
    let inputElement: HTMLInputElement | null = null;
    // First we check if the slotted element is just light dom HTML
    inputElement = this.querySelector('input');
    if (inputElement) {
      return inputElement as HTMLInputElement;
    }
    // If not, we traverse down the slotted element's dom/shadow dom until we
    // find a dead-end or an input
    const slottedElement: Element | undefined = this.getSlottedElement();
    if (typeof slottedElement !== undefined) {
      inputElement = this.searchWebComponentForInputElement(slottedElement as Element);
    }

    return inputElement ? (inputElement as HTMLInputElement) : null;
  }

  /**
   * Traverse the shadow dom tree and search for input from it
   * and it's children
   * */
  searchWebComponentForInputElement(element: Element): HTMLInputElement | null {
    let inputElement: HTMLInputElement | null = this.getInputFieldInElement(element);
    if (inputElement) return inputElement;

    const webComponentsInChildren = this.getWebComponentsInsideElement(element);
    for (let i = 0; i < webComponentsInChildren.length; i++) {
      inputElement = this.searchWebComponentForInputElement(webComponentsInChildren[i]);
      if (inputElement) {
        break;
      }
    }
    return inputElement;
  }

  /**
   * Check if said element's dom tree contains a input element
   * */
  getInputFieldInElement(element: Element): HTMLInputElement | null {
    let inputElement: HTMLInputElement | null = null;
    if (element.shadowRoot) {
      inputElement = element.shadowRoot.querySelector('input');
    } else {
      inputElement = element.querySelector('input');
    }
    return inputElement;
  }

  getWebComponentsInsideElement(element: Element): Array<Element> {
    if (element.shadowRoot) {
      return [
        ...Array.from(element.querySelectorAll('*')),
        ...Array.from(element.shadowRoot.querySelectorAll('*')),
      ].filter((elem: Element) => elem.shadowRoot);
    } else {
      return Array.from(element.querySelectorAll('*')).filter((elem: Element) => elem.shadowRoot);
    }
  }

  changeMonth(monthNum: number, isOffset = true): void {
    if (!this._instance) return;
    this._instance.changeMonth(monthNum, isOffset);
  }

  clear(): void {
    if (!this._instance) return;
    this._instance.clear();
  }

  close(): void {
    if (!this._instance) return;
    this._instance.close();
  }

  destroy(): void {
    if (!this._instance) return;
    this._instance.destroy();
  }

  formatDate(dateObj: Date, formatStr: string): string {
    if (!this._instance) return '';
    return this._instance.formatDate(dateObj, formatStr);
  }

  jumpToDate(date: Date, triggerChange: boolean) {
    if (!this._instance) return;
    this._instance.jumpToDate(date, triggerChange);
  }

  open(): void {
    if (!this._instance) return;
    this._instance.open();
  }

  parseDate(dateStr: string, dateFormat: string): Date | undefined {
    if (!this._instance) return undefined;
    return this._instance.parseDate(dateStr, dateFormat);
  }

  redraw(): void {
    if (!this._instance) return;
    this._instance.redraw();
  }

  /* eslint-disable  @typescript-eslint/no-explicit-any */
  set(
    option:
      | keyof Options
      | {
          [k in keyof Options]?: Options[k];
        },
    value?: any
  ): void {
    if (!this._instance) return;
    this._instance.set(option, value);
  }

  setDate(date: DateOption | DateOption[], triggerChange: boolean, dateStrFormat: string): void {
    if (!this._instance) return;
    this._instance.setDate(date, triggerChange, dateStrFormat);
  }

  toggle(): void {
    if (!this._instance) return;
  }

  getSelectedDates(): Array<Date> {
    if (!this._instance) return [];
    return this._instance.selectedDates;
  }

  getCurrentYear(): number {
    if (!this._instance) return -1;
    return this._instance.currentYear;
  }

  getCurrentMonth(): number {
    if (!this._instance) return -1;
    return this._instance.currentMonth;
  }

  getConfig(): ParsedOptions {
    if (!this._instance) return {} as ParsedOptions;
    return this._instance.config;
  }

  getValue(): string {
    if (!this._inputElement) return '';
    return this._inputElement.value;
  }

  render() {
    return html`
      ${!this._hasSlottedElement
        ? html`<input class="lit-flatpickr flatpickr flatpickr-input" placeholder=${this.placeholder} />`
        : html``}
      <slot></slot>
    `;
  }
}
Example #27
Source File: power-distribution-card.ts    From power-distribution-card with MIT License 4 votes vote down vote up
@customElement('power-distribution-card' + DEV_FLAG)
export class PowerDistributionCard extends LitElement {
  /**
   * Linking to the visual Editor Element
   * @returns Editor DOM Element
   */
  public static async getConfigElement(): Promise<LovelaceCardEditor> {
    return document.createElement('power-distribution-card-editor' + DEV_FLAG) as LovelaceCardEditor;
  }

  /**
   * Function for creating the standard power-distribution-card
   * @returns Example Config for this Card
   */
  public static getStubConfig(): Record<string, unknown> {
    return {
      title: 'Title',
      entities: [],
      center: {
        type: 'bars',
        content: [
          { preset: 'autarky', name: localize('editor.settings.autarky') },
          { preset: 'ratio', name: localize('editor.settings.ratio') },
        ],
      },
    };
  }

  @property() public hass!: HomeAssistant;

  @state() private _config!: PDCConfig;

  @property() private _card!: LovelaceCard;

  private _resizeObserver?: ResizeObserver;
  @state() private _narrow = false;

  /**
   * Configuring all the passed Settings and Changing it to a more usefull Internal one.
   * @param config The Config Object configured via YAML
   */
  public async setConfig(config: PDCConfig): Promise<void> {
    //The Addition of the last object is needed to override the entities array for the preset settings
    const _config = Object.assign({}, DefaultConfig, config, { entities: [] });

    //Entities Preset Object Stacking
    if (!config.entities) throw new Error('You need to define Entities!');
    config.entities.forEach((item) => {
      if (item.preset && PresetList.includes(<PresetType>item.preset)) {
        const _item: EntitySettings = Object.assign({}, DefaultItem, PresetObject[item.preset], <EntitySettings>item);
        _config.entities.push(_item);
      } else {
        throw new Error('The preset `' + item.preset + '` is not a valid entry. Please choose a Preset from the List.');
      }
    });
    this._config = _config;

    //Setting up card if needed
    if (this._config.center.type == 'card') {
      this._card = this._createCardElement(this._config.center.content as LovelaceCardConfig);
    }
  }

  public firstUpdated(): void {
    const _config = this._config;

    _config.entities.forEach((item, index) => {
      if (!item.entity) return;
      //unit-of-measurement Auto Configuration from hass element
      const hass_uom = this._state({ entity: item.entity, attribute: 'unit_of_measurement' }) as string;
      !item.unit_of_measurement ? (this._config.entities[index].unit_of_measurement = hass_uom || 'W') : undefined;
    });

    //Resize Observer
    this._adjustWidth();
    this._attachObserver();
    //This is needed to prevent Rendering without the unit_of_measurements
    this.requestUpdate();
  }

  protected updated(changedProps: PropertyValues): void {
    super.updated(changedProps);
    if (!this._card || (!changedProps.has('hass') && !changedProps.has('editMode'))) {
      return;
    }
    if (this.hass) {
      this._card.hass = this.hass;
    }
  }

  public static get styles(): CSSResultGroup {
    return styles;
  }

  public connectedCallback(): void {
    super.connectedCallback();
    this.updateComplete.then(() => this._attachObserver());
  }

  public disconnectedCallback(): void {
    if (this._resizeObserver) {
      this._resizeObserver.disconnect();
    }
  }

  private async _attachObserver(): Promise<void> {
    if (!this._resizeObserver) {
      await installResizeObserver();
      this._resizeObserver = new ResizeObserver(debounce(() => this._adjustWidth(), 250, false));
    }
    const card = this.shadowRoot?.querySelector('ha-card');
    // If we show an error or warning there is no ha-card
    if (!card) return;
    this._resizeObserver.observe(card);
  }

  private _adjustWidth(): void {
    const card = this.shadowRoot?.querySelector('ha-card');
    if (!card) return;
    this._narrow = card.offsetWidth < 400;
  }

  /**
   * Retrieving the sensor value of hass for a Item as a number
   * @param item a Settings object
   * @returns The current value from Homeassistant in Watts
   */
  private _val(item: EntitySettings | BarSettings): number {
    let modifier = item.invert_value ? -1 : 1;
    //Proper K Scaling e.g. 1kW = 1000W
    if (item.unit_of_measurement?.charAt(0) == 'k') modifier *= 1000;
    //Checking if an attribute was defined to pull the value from
    const attr = (item as EntitySettings).attribute || null;
    // If an entity exists, check if the attribute setting is entered -> value from attribute else value from entity
    let num =
      item.entity && this.hass.states[item.entity]
        ? attr
          ? Number(this.hass.states[item.entity].attributes[attr])
          : Number(this.hass.states[item.entity].state)
        : NaN;
    //Applying Threshold
    const threshold = (item as EntitySettings).threshold || null;
    num = threshold ? (Math.abs(num) < threshold ? 0 : num) : num;
    return num * modifier;
  }

  /**
   * Retrieving the raw state of an sensor/attribute
   * @param item A Settings object
   * @returns entitys/attributes state
   */
  private _state(item: EntitySettings): unknown {
    return item.entity && this.hass.states[item.entity]
      ? item.attribute
        ? this.hass.states[item.entity].attributes[item.attribute]
        : this.hass.states[item.entity].state
      : null;
  }

  /**
   * This is the main rendering function for this card
   * @returns html for the power-distribution-card
   */
  protected render(): TemplateResult {
    const left_panel: TemplateResult[] = [];
    const center_panel: (TemplateResult | LovelaceCard)[] = [];
    const right_panel: TemplateResult[] = [];

    let consumption = 0;
    let production = 0;

    this._config.entities.forEach((item, index) => {
      const value = this._val(item);

      if (!item.calc_excluded) {
        if (item.producer && value > 0) {
          production += value;
        }
        if (item.consumer && value < 0) {
          consumption -= value;
        }
      }

      const _item = this._render_item(value, item, index);
      //Sorting the Items to either side
      switch (index % 2) {
        case 0: //Even
          left_panel.push(_item);
          break;
        case 1: //Odd
          right_panel.push(_item);
          break;
      }
    });

    //Populating the Center Panel
    const center = this._config.center;
    switch (center.type) {
      case 'none':
        break;
      case 'card':
        this._card ? center_panel.push(this._card) : console.warn('NO CARD');
        break;
      case 'bars':
        center_panel.push(this._render_bars(consumption, production));
        break;
    }

    return html` ${this._narrow ? narrow_styles : undefined}
      <ha-card .header=${this._config.title}>
        <div class="card-content">
          <div id="left-panel">${left_panel}</div>
          <div id="center-panel">${center_panel}</div>
          <div id="right-panel">${right_panel}</div>
        </div>
      </ha-card>`;
  }

  private _handleAction(ev: ActionHandlerEvent): void {
    if (this.hass && this._config && ev.detail.action) {
      handleAction(
        this,
        this.hass,
        {
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          entity: (ev.currentTarget as any).entity,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          tap_action: (ev.currentTarget as any).tap_action,
          // eslint-disable-next-line @typescript-eslint/no-explicit-any
          double_tap_action: (ev.currentTarget as any).double_tap_action,
        },
        ev.detail.action,
      );
    }
  }

  /**
   * Creating a Item Element
   * @param value The Value of the Sensor
   * @param item The EntitySettings Object of the Item
   * @param index The index of the Item. This is needed for the Arrow Directions.
   * @returns Html for a single Item
   */
  private _render_item(value: number, item: EntitySettings, index: number): TemplateResult {
    //Placeholder item
    if (!item.entity) {
      return html`<item class="placeholder"></item>`;
    }
    const state = item.invert_arrow ? value * -1 : value;
    //Toggle Absolute Values
    value = item.display_abs ? Math.abs(value) : value;
    //Unit-Of-Display and Unit_of_measurement
    let unit_of_display = item.unit_of_display || 'W';
    const uod_split = unit_of_display.charAt(0);
    if (uod_split[0] == 'k') {
      value /= 1000;
    } else if (item.unit_of_display == 'adaptive') {
      //Using the uom suffix enables to adapt the initial unit to the automatic scaling naming
      let uom_suffix = 'W';
      if (item.unit_of_measurement) {
        uom_suffix =
          item.unit_of_measurement[0] == 'k' ? item.unit_of_measurement.substring(1) : item.unit_of_measurement;
      }
      if (Math.abs(value) > 999) {
        value = value / 1000;
        unit_of_display = 'k' + uom_suffix;
      } else {
        unit_of_display = uom_suffix;
      }
    }

    //Decimal Precision
    const decFakTen = 10 ** (item.decimals || item.decimals == 0 ? item.decimals : 2);
    value = Math.round(value * decFakTen) / decFakTen;
    //Format Number
    const formatValue = formatNumber(value, this.hass.locale);

    // Secondary info
    let secondary_info: string | undefined;
    if (item.secondary_info_entity) {
      if (item.secondary_info_attribute) {
        secondary_info =
          this._state({ entity: item.secondary_info_entity, attribute: item.secondary_info_attribute }) + '';
      } else {
        secondary_info = `${this._state({ entity: item.secondary_info_entity })}${
          this._state({ entity: item.secondary_info_entity, attribute: 'unit_of_measurement' }) || ''
        }`;
      }
    }
    // Secondary info replace name
    if (item.secondary_info_replace_name) {
      item.name = secondary_info;
      secondary_info = undefined;
    }

    //Preset Features
    // 1. Battery Icon
    let icon = item.icon;
    if (item.preset === 'battery' && item.battery_percentage_entity) {
      const bat_val = this._val({ entity: item.battery_percentage_entity });
      if (!isNaN(bat_val)) {
        icon = 'mdi:battery';
        // mdi:battery-100 and -0 don't exist thats why we have to handle it seperately
        if (bat_val < 5) {
          icon = 'mdi:battery-outline';
        } else if (bat_val < 95) {
          icon = 'mdi:battery-' + (bat_val / 10).toFixed(0) + '0';
        }
      }
    }
    // 2. Grid Buy-Sell
    let nameReplaceFlag = false;
    let grid_buy_sell = html``;
    if (item.preset === 'grid' && (item.grid_buy_entity || item.grid_sell_entity)) {
      nameReplaceFlag = true;
      grid_buy_sell = html`
        <div class="buy-sell">
          ${item.grid_buy_entity
            ? html`<div class="grid-buy">
                B:
                ${this._val({ entity: item.grid_buy_entity })}${this._state({
                  entity: item.grid_buy_entity,
                  attribute: 'unit_of_measurement',
                }) || undefined}
              </div>`
            : undefined}
          ${item.grid_sell_entity
            ? html`<div class="grid-sell">
                S:
                ${this._val({ entity: item.grid_sell_entity })}${this._state({
                  entity: item.grid_sell_entity,
                  attribute: 'unit_of_measurement',
                }) || undefined}
              </div>`
            : undefined}
        </div>
      `;
    }

    // COLOR CHANGE
    const ct = item.color_threshold || 0;
    // Icon color dependant on state
    let icon_color: string | undefined;
    if (item.icon_color) {
      if (state > ct) icon_color = item.icon_color.bigger;
      if (state < ct) icon_color = item.icon_color.smaller;
      if (state == ct) icon_color = item.icon_color.equal;
    }
    // Arrow color
    let arrow_color: string | undefined;
    if (item.arrow_color) {
      if (state > ct) arrow_color = item.arrow_color.bigger;
      if (state < ct) arrow_color = item.arrow_color.smaller;
      if (state == ct) arrow_color = item.arrow_color.equal;
    }

    //NaNFlag for Offline Sensors for example
    const NanFlag = isNaN(value);

    return html`
      <item
        .entity=${item.entity}
        .tap_action=${item.tap_action}
        .double_tap_action=${item.double_tap_action}
        @action=${this._handleAction}
        .actionHandler=${actionHandler({
          hasDoubleClick: hasAction(item.double_tap_action),
        })}
    ">
        <badge>
          <icon>
            <ha-icon icon="${icon}" style="${icon_color ? `color:${icon_color};` : ''}"></ha-icon>
            ${secondary_info ? html`<p class="secondary">${secondary_info}</p>` : null}
          </icon>
          ${nameReplaceFlag ? grid_buy_sell : html`<p class="subtitle">${item.name}</p>`}
        </badge>
        <value>
          <p>${NanFlag ? `` : formatValue} ${NanFlag ? `` : unit_of_display}</p>
          ${
            !item.hide_arrows
              ? this._render_arrow(
                  //This takes the side the item is on (index even = left) into account for the arrows
                  value == 0 || NanFlag
                    ? 'none'
                    : index % 2 == 0
                    ? state > 0
                      ? 'right'
                      : 'left'
                    : state > 0
                    ? 'left'
                    : 'right',
                  index,
                  arrow_color,
                )
              : html``
          }
        <value
      </item>
    `;
  }

  /**
   * Render function for Generating Arrows (CSS Only)
   * @param direction One of three Options: none, right, left
   * @param index To detect which side the item is on and adapt the direction accordingly
   */
  private _render_arrow(direction: ArrowStates, index: number, color?: string): TemplateResult {
    const a = this._config.animation;
    const b = `${direction}-${index}`;
    if (direction == 'none') {
      return html` <div class="blank"></div> `;
    } else {
      return html`
        <svg width="57" height="18" class="arrow">
          <defs>
            <polygon id="arrow-right-${index}" points="0 0, 0 16, 16 8" fill="${color}" />
            <polygon id="arrow-left-${index}" points="16 0, 16 16, 0 8" fill="${color}" />
            <g id="slide-${index}" class="arrow-color">
              <use href="#arrow-${b}" x="-36" />
              <use href="#arrow-${b}" x="-12" />
              <use href="#arrow-${b}" x="12" />
              <use href="#arrow-${b}" x="36" />
            </g>
            <g id="flash-${index}" fill="red">
              <use href="#arrow-${b}" x="0" style="animation-delay: ${direction == 'right' ? 0 : 2}s;" id="a-flash" />
              <use href="#arrow-${b}" x="20" style="animation-delay: 1s;" id="a-flash" />
              <use href="#arrow-${b}" x="40" style="animation-delay: ${direction == 'right' ? 2 : 0}s;" id="a-flash" />
            </g>
            <g id="none-${index}" class="arrow-color">
              <use href="#arrow-${b}" x="0" />
              <use href="#arrow-${b}" x="20" />
              <use href="#arrow-${b}" x="40" />
            </g>
          </defs>
          <use href="#${a}-${index}" id="a-${a}-${direction}" />
        </svg>
      `;
    }
  }

  /**
   * Render Support Function Calculating and Generating the Autarky and Ratio Bars
   * @param consumption the total home consumption
   * @param production the total home production
   * @returns html containing the bars as Template Results
   */
  private _render_bars(consumption: number, production: number): TemplateResult {
    const bars: TemplateResult[] = [];
    if (!this._config.center.content || (this._config.center.content as BarSettings[]).length == 0) return html``;
    (this._config.center.content as BarSettings[]).forEach((element) => {
      let value = -1;
      switch (element.preset) {
        case 'autarky': //Autarky in Percent = Home Production(Solar, Battery)*100 / Home Consumption
          if (!element.entity)
            value = consumption != 0 ? Math.min(Math.round((production * 100) / Math.abs(consumption)), 100) : 0;
          break;
        case 'ratio': //Ratio in Percent = Home Consumption / Home Production(Solar, Battery)*100
          if (!element.entity)
            value = production != 0 ? Math.min(Math.round((Math.abs(consumption) * 100) / production), 100) : 0;
          break;
      }
      if (value < 0) value = parseInt(this._val(element).toFixed(0), 10);
      bars.push(html`
        <div
          class="bar-element"
          .entity=${element.entity}
          .tap_action=${element.tap_action}
          .double_tap_action=${element.double_tap_action}
          @action=${this._handleAction}
          .actionHandler=${actionHandler({
            hasDoubleClick: hasAction(element.double_tap_action),
          })}
          style="${element.tap_action || element.double_tap_action ? 'cursor: pointer;' : ''}"
        >
          <p class="bar-percentage">${value}%</p>
          <div class="bar-wrapper" style="${element.bar_bg_color ? `background-color:${element.bar_bg_color};` : ''}">
            <bar style="height:${value}%; background-color:${element.bar_color};" />
          </div>
          <p>${element.name || ''}</p>
        </div>
      `);
    });
    return html`${bars.map((e) => html`${e}`)}`;
  }

  private _createCardElement(cardConfig: LovelaceCardConfig) {
    const element = createThing(cardConfig) as LovelaceCard;
    if (this.hass) {
      element.hass = this.hass;
    }
    element.addEventListener(
      'll-rebuild',
      (ev) => {
        ev.stopPropagation();
        this._rebuildCard(element, cardConfig);
      },
      { once: true },
    );
    return element;
  }

  private _rebuildCard(cardElToReplace: LovelaceCard, config: LovelaceCardConfig): void {
    const newCardEl = this._createCardElement(config);
    if (cardElToReplace.parentElement) {
      cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace);
    }
    this._card === cardElToReplace ? (this._card = newCardEl) : undefined;
  }
}
Example #28
Source File: opc-notification-drawer.ts    From op-components with MIT License 4 votes vote down vote up
@customElement('opc-notification-drawer')
export class OpcNotificationDrawer extends LitElement {
  @property() name = 'opc-notification-drawer';
  static get styles() {
    return [style];
  }

  @state() private _isOpen = false;
  @property({ type: String, reflect: true }) title = 'Notifications';

  close() {
    this._isOpen = false;
  }

  open() {
    this._isOpen = true;
  }

  toggle() {
    this._isOpen = !this._isOpen;
  }

  get isOpen() {
    return this._isOpen;
  }

  updated(changedProperties: any) {
    if (changedProperties.has('_isOpen')) {
      this.dispatchEvent(
        new CustomEvent(
          `opc-notification-drawer:${this.isOpen ? 'open' : 'close'}`,
          {
            bubbles: true,
            composed: true,
          }
        )
      );
    }
  }

  render() {
    return html`
      <div
        class="opc-notification-drawer-container"
        ?isHidden="${!this._isOpen}"
      >
        <div
          class="opc-notification-drawer-backdrop"
          @click="${this.close}"
        ></div>
        <dialog
          ?open=${this.isOpen}
          id="opc-notification-drawer"
          class="opc-notification-drawer"
          role="dialog"
          aria-modal="true"
        >
          <slot name="header">
            <div class="opc-notification-drawer__header-container">
              <div class="opc-notification-drawer__header">
                <div>
                  <h4 class="opc-notification-drawer__header-title">
                    ${this.title}
                  </h4>
                </div>
                <button @click=${this.close}>
                  <img
                    src="${closeIcon}"
                    alt="angle-icon"
                    class="angle-icon"
                    width="12px"
                    height="12px"
                  />
                </button>
              </div>
              <div>
                <slot name="header-body"></slot>
              </div>
            </div>
          </slot>
          <div class="opc-notification-drawer__body">
            <slot></slot>
          </div>
          <footer>
            <slot name="footer"></slot>
          </footer>
        </dialog>
      </div>
    `;
  }
}