Fan slider problem

I have a heavily customized button-card template with a circular slider, exactly the one tamper evident on youtube created for his dashboard.
For lights, drag-to-change brightness works perfectly.
For fans (Tuya integration), I can see percentage and change speed from the HA more-info popup,
but the same circular slider does not allow dragging to change fan speed.

Facts:

  • Fan entity has attributes:
    percentage, percentage_step
  • fan.set_percentage works from popup
  • circle SVG renders and % text updates
  • cursor never changes to grab
  • dragging does nothing

Question:
What is the correct way to enable drag interaction for fan entities in button-card?
Is there a frontend restriction for fan sliders compared to light brightness sliders?

Any insight into how the HA frontend handles fan sliders internally would help.

Since, I really don’t know where the error is coming from, i have added the code where i think issue can be.


  extra_styles:
    extra_styles: |
      [[[
        if (entity) {
            if (entity.entity_id.split('.')[0] === 'light' && variables.state_on) {

                // theme variable and conditions
                let style = getComputedStyle(document.body),
                    theme_var = style.getPropertyValue('--button-card-light-color-temp'),
                    is_hsl = theme_var.startsWith('hsl('),
                    is_color_temp = entity.attributes.color_mode === 'color_temp';

                if (is_hsl && is_color_temp && entity.attributes.brightness) {

                    // calculate lightness in hsl
                    let regex_pattern = /(\d+)(?!.*\d)/g,
                        brightness = entity.attributes.brightness / 2.54,
                        lightness = parseFloat(theme_var.match(regex_pattern)[0]),
                        min = lightness - 10,
                        max = lightness + 10,
                        calc_lightness = brightness * (max - min) / 100 + min;

                    var light_color = theme_var.replace(regex_pattern, calc_lightness);
                }
                else {
                    var light_color = 'var(--button-card-light-color)';
                }
            }
        }
        return `

         /* * * * * * * * * * * * * * * * * *
          *                                 *
          *              LIGHT              *
          *                                 *
          * * * * * * * * * * * * * * * * * */

          svg {
            --light-color: ${
              variables.state_on && entity.attributes.brightness
                  ? light_color
                  : variables.state_on && !entity.attributes.brightness
                      ? 'var(--state-icon-active-color);'
                      : 'var(--state-icon-color);' }
          }

          .light-color {
            fill: var(--light-color);
            transition: all 0.25s ease-out;
          }

          /* magnification */
          :host {
            --card-portrait: 1.4;
            --card-phone: 2.271;
          }

          ${this._config.template.includes('light') ? `

           /* * * * * * * * * * * * * * * * * *
            *                                 *
            *          CIRCLE SLIDER          *
            *                                 *
            * * * * * * * * * * * * * * * * * */

            #circle_slider {
              opacity: 0;
              appearance: none;
              transform: rotate(270deg);
              width: 90%;
              position: absolute;
              pointer-events: none;
              cursor: grab;
              left: 26%;
              margin-top: 13%;
            }

            #circle_slider::-webkit-slider-thumb {
              pointer-events: initial;
              appearance: none;
              width: 3vw;
              height: 3vw;
              border-radius: 50%;
              background: green;
            }

            #circle_slider::-webkit-slider-runnable-track {
              background: cornflowerblue;
            }

            #circle_slider::-moz-range-thumb {
              pointer-events: initial;
              appearance: none;
              width: 3vw;
              height: 3vw;
              border-radius: 50%;
              background: green;
            }

            #circle_slider::-moz-range-track {
              background: cornflowerblue;
              height: 3vw;
            }

            /* portrait */
            @media screen and (max-width: 1200px) {
              #circle_slider::-webkit-slider-thumb {
                width: 4vw;
                height: 4vw;
              }

              #circle_slider::-moz-range-thumb {
                width: 4vw;
                height: 4vw;
              }
            }

            /* phone */
            @media screen and (max-width: 800px) {
              #circle_slider::-webkit-slider-thumb {
                width: 5.8vw;
                height: 5.8vw;
              }

              #circle_slider::-moz-range-thumb {
                width: 5.8vw;
                height: 5.8vw;
              }
            }

          `:''}

         /* * * * * * * * * * * * * * * * * *
          *                                 *
          *              BASE               *
          *                                 *
          * * * * * * * * * * * * * * * * * */

          #container {
            text-align: left !important;
            z-index: 1;
          }

          #card {
            padding: 10.9% 9.9% 8.9% 10.9%;
          }

          #state::first-letter {
            text-transform: uppercase;
          }

          #name, #state {
            font-size: var(--button-card-font-size);
            font-weight: var(--button-card-font-weight);
            letter-spacing: var(--button-card-letter-spacing);
          }

          /* portrait */
          @media screen and (max-width: 1200px) {
            #name, #state {
              font-size: calc(var(--button-card-font-size) * var(--card-portrait));
            }
          }
          /* phone */
          @media screen and (max-width: 800px) {
            #name, #state {
              font-size: calc(var(--button-card-font-size) * var(--card-phone));
            }
          }

          ${variables.tilt_enable === true ? `

           /* * * * * * * * * * * * * * * * * *
            *                                 *
            *              TILT               *
            *                                 *
            * * * * * * * * * * * * * * * * * */

              #name, #state {
                font-size: calc(var(--button-card-font-size) - var(--z-axis-adjustment));
              }

              /* portrait */
              @media screen and (max-width: 1200px) {
                #name, #state {
                  font-size: calc(calc(var(--button-card-font-size) * var(--card-portrait)) - var(--z-axis-adjustment));
                }
              }

              /* phone */
              @media screen and (max-width: 800px) {
                #name, #state {
                  font-size: calc(calc(var(--button-card-font-size) * var(--card-phone)) - var(--z-axis-adjustment));
                }
              }

              #container {
                transform: translateZ(${variables.tilt_options.parallax});
              }

              #circle_slider {
                width: 100%;
                margin-top: 0;
              }

              /* adjust circle_slider position for firefox */
              @supports (-moz-appearance:none) {
                #circle_slider {
                  margin-top: 13%;
                }
              }

              #card {
                padding: 12% 11% 10.5% 12%;
                transform-style: preserve-3d;
                overflow: visible;
                /* firefox pixelated edges */
                outline: 1px solid transparent;
              }

              #ripple, .js-tilt-glare {
                clip-path: inset(0 round var(--button-card-border-radius));
                overflow: hidden;
              }

              .js-tilt-glare {
                z-index: 1;
              }

              .js-tilt-glare-inner {
                background-color: rgba(0,0,0,0.9);
              }

          `:''}

          ${this._config.template.includes('conditional_media') ? `

           /* * * * * * * * * * * * * * * * * *
            *                                 *
            *              MEDIA              *
            *                                 *
            * * * * * * * * * * * * * * * * * */

            :host {
              --blur-intensity: blur(4.5px) brightness(0.8);
            }

            /* phone */
            @media screen and (max-width: 800px) {
              :host {
                --blur-intensity: blur(2.5px) brightness(0.8);
              }
            }

            #ripple, .js-tilt-glare {
              clip-path: inset(0 round calc(var(--button-card-border-radius) / 2));
            }

            #container {
              overflow: hidden;
            }

            .marquee {
              animation: marquee 20s linear infinite;
            }

            @keyframes marquee {
              from {
                transform: translateX(0%);
              }
              to {
                transform: translateX(-50%);
              }
            }

          `:''}

          ${this._config.template.includes('footer') ? `

           /* * * * * * * * * * * * * * * * * *
            *                                 *
            *             FOOTER              *
            *                                 *
            * * * * * * * * * * * * * * * * * */

            /* magnification */
            :host {
              --footer-portrait: 1.4;
              --footer-phone: 2.8;
            }

            #ripple, .js-tilt-glare {
              border-radius: calc(var(--footer-card-border-radius) - 0.1vw);
              clip-path: inset(0 round calc( var(--button-card-border-radius) - 0.1vw ));
            }

            #name {
              font-size: var(--footer-card-font-size);
              padding: var(--footer-card-padding-v) var(--footer-card-padding-h);
              letter-spacing: 0.05vw;
            }

            ha-icon {
              width: var(--footer-card-icon-size);
              vertical-align: 7%;
              padding-right: 0.1vw;
              opacity: 0.4;
            }

            #card {
              border-radius: var(--footer-card-border-radius);
              background: rgba(115, 115, 115, 0.10);
            }

            #notify {
              font-size: var(--footer-notify-font-size);
              width: var(--footer-notify-box-size);
              height: var(--footer-notify-box-size);
              line-height: var(--footer-notify-box-size);
              padding-right: 0.5%;
              padding-top: 0.5%;
              top: var(--footer-notify-top);
              right: var(--footer-notify-right);
            }

            /* portrait */
            @media screen and (max-width: 1200px) {
              #name {
                font-size: calc(var(--footer-card-font-size) * var(--footer-portrait));
                padding: calc(var(--footer-card-padding-v) * var(--footer-portrait)) calc(var(--footer-card-padding-h) * var(--footer-portrait));
              }

              ha-icon {
                width: calc(var(--footer-card-icon-size) * var(--footer-portrait));
              }

              #card {
                border-radius: calc(var(--footer-card-border-radius) * var(--footer-portrait));
                margin: 0 0.5vw;
              }

              #notify {
                font-size: calc(var(--footer-notify-font-size) * var(--footer-portrait));
                width: calc(var(--footer-notify-box-size) * var(--footer-portrait));
                height: calc(var(--footer-notify-box-size) * var(--footer-portrait));
                line-height: calc(var(--footer-notify-box-size) * var(--footer-portrait));
              }
            }

            /* phone */
            @media screen and (max-width: 800px) {
              #name {
                font-size: calc(var(--footer-card-font-size) * var(--footer-phone));
                padding: calc(var(--footer-card-padding-v) * var(--footer-phone)) calc(var(--footer-card-padding-h) * var(--footer-phone));
                letter-spacing: 0.05vw;
              }

              ha-icon {
                width: calc(var(--footer-card-icon-size) * var(--footer-phone));
              }

              #card {
                border-radius: calc(var(--footer-card-border-radius) * var(--footer-phone));
                background: rgba(115, 115, 115, 0.12);
                margin: 0 0.5vw;
              }

              #notify {
                font-size: calc(var(--footer-notify-font-size) * var(--footer-phone));
                width: calc(var(--footer-notify-box-size) * var(--footer-phone));
                height: calc(var(--footer-notify-box-size) * var(--footer-phone));
                line-height: calc(var(--footer-notify-box-size) * var(--footer-phone) + 1px);
                top: calc(var(--footer-notify-top) * var(--footer-phone));
                right: calc(var(--footer-notify-right) * var(--footer-phone) + 2%);
                padding: 0;
              }
            }

          `:''}
        `;
      ]]]
  card_light:
    show_state: true
    state_display: null
    size: 40%
    styles:
      card:
        - width: 120px
        - height: 120px
      grid:
        - grid-template-areas: '"i" "n" "s"'
        - grid-template-columns: 1fr
        - grid-template-rows: 1fr min-content min-content
      icon:
        - color: var(--button-card-light-color)
      img_cell:
        - align-self: start
        - text-align: start
        - color: var(--button-card-light-color)
      name:
        - color: var(--button-card-light-color)
        - justify-self: start
        - padding-left: 10px
        - font-weight: bold
        - font-size: 11pt
      state:
        - justify-self: start
        - padding-left: 10px
        - text-transform: capitalize
        - font-size: 11pt
        - color: var(--button-card-light-color)
    state:
      - value: 'off'
        styles:
          card:
            - filter: opacity(70%)
          icon:
            - filter: grayscale(100%)
    hold_action:
      action: more-info
  light:
    template:
      - base
      - circle
      - loader
    double_tap_action:
      action: more-info
    variables:
      circle_input: |
        [[[
          if (entity) {
              // if light group get brightness from child to remove bounce
              let child = entity.attributes.entity_id,
                  brightness = child && states[child[0]].attributes.brightness
                      ? Math.round(states[child[0]].attributes.brightness / 2.54)
                      : Math.round(entity.attributes.brightness / 2.54);
              return brightness === 0 && entity.state !== 'off'
                  ? 1
                  : brightness
          }
        ]]]
      circle_input_unit: '%'
  base:
    template:
      - settings
      - tilt
      - extra_styles
    variables:
      state_on: >
        [[[ return ['on', 'home', 'cool', 'heat', 'fan_only', 'playing',
        'unlocked', 'armed_home', 'armed_away', 'armed_night',
        'open'].indexOf(!entity || entity.state) !== -1; ]]]
      state: |
        [[[ return !entity || entity.state; ]]]
      entity_id: |
        [[[ return !entity || entity.entity_id; ]]]
      entity_picture: |
        [[[ return !entity || entity.attributes.entity_picture; ]]]
      timeout: |
        [[[ return !entity || Date.now() - Date.parse(entity.last_changed); ]]]
      is_youtube: |
        [[[
          let is_youtube = entity?.attributes?.app_id === 'com.google.ios.youtube',
              sensor = this?._config?.triggers_update,
              media_title = entity?.attributes?.media_title,
              watching_title = states[sensor]?.attributes?.title;
          if (is_youtube && media_title === watching_title) {
              return true;
          }
        ]]]
    aspect_ratio: 1/1
    show_state: true
    show_icon: true
    state_display: |
      [[[
        const stateDict = {
          'on': variables.translate_on,
          'off': variables.translate_off,
          'cool': variables.translate_cool,
          'fan_only': variables.translate_fan_only,
        };
        if (variables.state === true) return variables.translate_unknown;
        return stateDict[variables.state];
      ]]]
    tap_action:
      ui_sound_tablet: |
        [[[
          let screensaver = states[variables.entity_tablet] === undefined ||
              states[variables.entity_tablet].state;

          if (variables.state === 'off' && screensaver === 'off') {
              hass.callService('media_player', 'play_media', {
                  entity_id: variables.entity_browser_mod,
                  media_content_id: '/local/sound/on.m4a',
                  media_content_type: 'music'
              });
          }
          if (variables.state_on && screensaver === 'off') {
              hass.callService('media_player', 'play_media', {
                  entity_id: variables.entity_browser_mod,
                  media_content_id: '/local/sound/off.m4a',
                  media_content_type: 'music'
              });
          }
        ]]]
      card_bounce: |
        [[[
                // add animation
                if (this.getElementsByTagName("style").length === 0) {

                    // phone condition
                    let mq = window.matchMedia('(max-width: 800px)').matches;

                    let style = document.createElement('style');

                    style.innerHTML = `
                        @keyframes card_bounce {
                            0%   { transform: scale(1); }
                            10%  { transform: scale(${ mq ? '0.92' : '0.94' }); }
                            25%  { transform: scale(1); }
                            30%  { transform: scale(${ mq ? '0.96' : '0.98' }); }
                            100% { transform: scale(1); }
                        }
                    `;

                    this.appendChild(style);
                }

                // duration
                let duration = 800;

                // animate
                this.style.animation = `card_bounce ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;

                // reset
                window.setTimeout(() => { this.style.animation = "none"; }, duration + 100)
              ]]]
      action: toggle
    hold_action:
      action: block
    state:
      - value: unavailable
        styles:
          card:
            - opacity: 45%
        tap_action:
          action: none
      - value: 'off'
        styles:
          icon:
            - color: rgb(215,215,215)
      - value: closed
        styles:
          icon:
            - color: rgb(215,215,215)
      - value: paused
        styles:
          icon:
            - color: rgb(215,215,215)
    styles:
      grid:
        - grid-template-areas: |
            "i  circle"
            "n     n"
            "s     s"
        - grid-template-columns: repeat(2, 1fr)
        - grid-template-rows: auto repeat(2, min-content)
        - gap: 1.3%
        - align-items: start
        - will-change: transform
      name:
        - justify-self: start
        - line-height: 121%
        - font-weight: 700
      state:
        - justify-self: start
        - line-height: 115%
      icon:
        - color: var(--button-card-light-color)
        - icon_size: 600px
        - padding: 0px 0px
        - margin-top: '-45%'
        - margin-left: '-28%'
      card:
        - border-radius: 22px
        - border-width: 0
        - '-webkit-tap-highlight-color': rgba(0,0,0,0)
        - transition: none
        - '--mdc-ripple-color': |
            [[[
              return variables.state_on
                  ? 'rgb(0, 0, 0)'
                  : '#97989c';
            ]]]
        - color: |
            [[[
              return variables.state_on
                  ? '#4b5254'
                  : '#97989c';
            ]]]
        - background-color: |
            [[[
              return variables.state_on
                  ? 'rgba(255, 255, 255, 0.85)'
                  : 'var(--tile-background-color)';
            ]]]
  circle:
    styles:
      card:
        - '--c-stroke-color-on': '#b0b0b0'
        - '--c-stroke-color-off': none
        - '--c-fill-color-on': none
        - '--c-fill-color-off': rgba(255,255,255,0.04)
        - '--c-stroke-width': 2.3
        - '--c-stroke-width-dragging': 4
        - '--c-font-color': '#97989c'
        - '--c-font-size': 14px
        - '--c-unit-font-size': 10.5px
        - '--c-font-weight': 700
        - '--c-letter-spacing': '-0.02rem'
      custom_fields:
        circle:
          - display: initial
          - width: 88%
          - margin: '-3% 2% 0 0'
          - justify-self: end
          - opacity: 1
    custom_fields:
      circle: |
        [[[
          if (entity) {
              let r = 22.1,
                  c = r * 2 * Math.PI,
                  tspan = '<tspan dx=".2" dy="-.4">',
                  domain = entity.entity_id.split('.')[0],
                  state = variables.state_on,
                  input =
                  domain === 'fan'
                      ? entity.attributes.percentage ?? 0
                      : variables.circle_input || ' ',
                  unit =
                  domain === 'fan'
                      ? '%'
                      : variables.circle_input_unit || ' ';

             /* * * * * * * * * * * * * * * * * *
              *                                 *
              *             CIRCLE              *
              *                                 *
              * * * * * * * * * * * * * * * * * */

              let circle = (state, input, unit) => {
                  return `
                    <svg viewBox="0 0 50 50">
                      <style>
                        circle {
                          transform: rotate(-90deg);
                          transform-origin: 50% 50%;
                          stroke-dasharray: ${c};
                          stroke-dashoffset: ${typeof input === 'number' && c - input / 100 * c};
                          stroke-width: var(--c-stroke-width);
                          stroke: ${state ? 'var(--c-stroke-color-on)' : 'var(--c-stroke-color-off)'};
                          fill: ${state ? 'var(--c-fill-color-on)' : 'var(--c-fill-color-off)'};
                        }
                        text {
                          font-size: var(--c-font-size);
                          font-weight: var(--c-font-weight);
                          letter-spacing: var(--c-letter-spacing);
                          fill: var(--c-font-color);
                        }
                        tspan {
                          font-size: var(--c-unit-font-size);
                        }
                        #circle_value, tspan {
                          text-anchor: middle;
                          dominant-baseline: central;
                        }
                      </style>
                      <circle id="circle_stroke" cx="25" cy="25" r="${r}"/>
                      <text id="circle_value" x="50%" y="52%">${input}${tspan}${unit}</tspan></text>
                    </svg>

                    ${(domain === 'light' || domain === 'fan') && `
                        <input id="circle_slider"
                          type="range"
                          min="0"
                          max="100"
                          step="${domain === 'fan'
                            ? (entity.attributes.percentage_step || 1)
                            : 1}"
                          value="${input}">
                    `}
                  `;
              }

             /* * * * * * * * * * * * * * * * * *
              *                                 *
              *              LIGHT              *
              *                                 *
              * * * * * * * * * * * * * * * * * */

              if (domain === 'light' && state) {

                  // wait 0ms for shadow dom
                  setTimeout(() => {

                      // then get elements
                      let elt = this.shadowRoot,
                          circle_slider = elt.getElementById('circle_slider'),
                          circle_value = elt.getElementById('circle_value'),
                          circle_stroke = elt.getElementById('circle_stroke');

                      // approximate position of thumb relative to circle
                      circle_slider.style.top = `${(circle_slider.value - 50) / 1.66 - 1}%`;

                      // debug position
                      let debug = false;
                      if (debug) circle_slider.style.opacity = 0.3;

                      // pass each event to handler
                      ['click', 'input', 'mousedown', 'mouseup', 'touchstart', 'touchend'].forEach((event) => {
                          circle_slider.addEventListener(event, handler, { passive: true })
                      });

                      function handler(event) {

                          // "this" refers to slider
                          if (event.target === this) {

                              // bypass button-card tap_action
                              event.stopPropagation();

                              // update circle_value
                              circle_value.innerHTML = `${this.value}${tspan}${unit}</tspan>`;

                              // update stroke
                              circle_stroke.style.strokeDashoffset = c - this.value / 100 * c;
                              circle_stroke.style.strokeWidth = 'var(--c-stroke-width-dragging)';
                              
                              // set cursor while dragging
                              if (event.type === 'mousedown' || event.type === 'input') {
                                  this.style.cursor = 'grabbing';
                              } else {
                                  this.style.cursor = 'grab';
                              }

                              // reset stroke width if value doesn't change
                              if (input == this.value && (event.type === 'click' || event.type === 'touchend'))
                                  circle_stroke.style.strokeWidth = 'var(--c-stroke-width)';

                              // on release
                              if (event.type === 'mouseup' || event.type === 'touchend') {

                                  // display loader if brightness is 0
                                  if (circle_slider.value == 0 && elt.getElementById('loader')) {
                                      elt.getElementById('loader').style.display = 'initial';
                                      elt.getElementById('circle').style.display = 'none';
                                  }

                                  // set brightness
                                  hass.callService('light', 'turn_on', {
                                      entity_id: entity.entity_id,
                                      brightness_pct: this.value
                                  });
                              }
                          }
                      }
                  }, 0);

                  return circle(state, input, unit);
              }

             /* * * * * * * * * * * * * * * * * *
              *                                 *
              *             PERSON              *
              *                                 *
              * * * * * * * * * * * * * * * * * */

              else if (domain === 'person') {
                  let time = c => {
                      let s = (c/1e3),
                          m = (c/6e4),
                          h = (c/36e5),
                          d = (c/864e5);
                      return s < 60
                          ? parseInt(s) + 's'
                          : m < 60 ? parseInt(m) + 'm'
                          : h < 24 ? parseInt(h) + 'h'
                          : parseInt(d) + 'd';
                  };
                  let input = states[variables.retain] === undefined || states[variables.retain].state === 'unavailable'
                          ? time(Date.now() - Date.parse(entity.last_changed))
                          : time(Date.now() - Date.parse(states[variables.retain].state)),
                      unit = ' ';
                  return circle(state, input, unit);
              }

             /* * * * * * * * * * * * * * * * * *
              *                                 *
              *             CLIMATE             *
              *                                 *
              * * * * * * * * * * * * * * * * * */

              else if (domain === 'fan' && state) {
                
                  setTimeout(() => {
                    const elt = this.shadowRoot;
                    const slider = elt.getElementById('circle_slider');
                    const circle_value = elt.getElementById('circle_value');
                    const circle_stroke = elt.getElementById('circle_stroke');
                
                    if (!slider) return;
                
                    // ENABLE interaction
                    slider.style.pointerEvents = 'auto';
                
                    const updateUI = (val) => {
                      circle_value.innerHTML = `${val}${tspan}%</tspan>`;
                      circle_stroke.style.strokeDashoffset = c - val / 100 * c;
                      slider.style.cursor = 'grabbing';
                    };
                
                    // LIVE UI update while dragging
                    slider.addEventListener('input', (e) => {
                      updateUI(e.target.value);
                    });
                
                    // APPLY SPEED ONLY ON RELEASE
                    slider.addEventListener('mouseup', apply);
                    slider.addEventListener('touchend', apply);
                
                    function apply(e) {
                      slider.style.cursor = 'grab';
                
                      hass.callService('fan', 'set_percentage', {
                        entity_id: entity.entity_id,
                        percentage: Number(e.target.value)
                      });
                    }
                
                  }, 0);
                
                  return `
                    ${circle(state, entity.attributes.percentage ?? 0, '%')}
                    <input id="circle_slider"
                      type="range"
                      min="0"
                      max="100"
                      step="${entity.attributes.percentage_step || 1}"
                      value="${entity.attributes.percentage ?? 0}">
                  `;
                }

             ```