Skip to content

Discussion: Web Components might be the right tool and save you time #210

@MrCrayon

Description

@MrCrayon

Hi!
First thing thanks for all great Tailwindlabs tools and I apologize if this is not the right channel, feel free to move this discussion or close it.

I am more of a backend guy but in my, admittedly low, knowledge of Web Components I think they might be the right tool to use for headlessui.

Quoting Adam

Right now just React and Vue, but we would like to explore other frameworks eventually. Just a lot of work to build a library like this for even one framework, have to start somewhere!

I totally understand that you already use Vue and React for your projects and do not want to invest too much time in other technologies but I really think building Web Components with Stenciljs is going to be pretty straight forward for you since it uses TypeScript and JSX and it will save you time since you only need to write once.
I have not worked previously with Stenciljs, TypeScript or React but I was able to port the switch component in half a day.

What would be the benefits of using Web Components instead of framework specific ones?

  • One codebase that works with any framework, a thin wrapper might be needed.
  • Can use components without any framework, drop them in your html and apply what's needed with alpine.js or vanilla javascript.
  • IMHO code looks easier to read

I am no expert in front end development so in the next days I'm going to publish the code for everyone to check and find possible shortcomes.
Hopefully people more knowledgeable than me can contribute in the discussion.

Use with Alpine.js
Alpine.js component needs to be initialized after webcomponent has been rendered the first time

Click to show code
<div class="mt-6" x-data-defer="{ isChecked: true }">
  <h-switch
    class="flex items-center space-x-4"
    :checked="isChecked"
    @switched="isChecked = !isChecked"
  >
    <label label>Enable notifications</label>
    <button
      button
      class="relative inline-flex flex-shrink-0 h-6 transition-colors duration-200 ease-in-out border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:ring"
      :class="[ isChecked ? 'bg-indigo-600' : 'bg-gray-200' ]"
    >
      <span
        class="inline-block w-5 h-5 transition duration-200 ease-in-out transform bg-white rounded-full"
        :class="[ isChecked ? 'translate-x-5' : 'translate-x-0' ]"
      ></span>
    </button>
  </h-switch>
</div>

<script>
  const components = document.querySelectorAll("[x-data-defer]");
  components.forEach(comp => {
    comp.addEventListener("componentRendered", function handleConnected() {
      comp.setAttribute("x-data", comp.getAttribute("x-data-defer"));
      Alpine.initializeComponent(comp);
      comp.removeEventListener("componentRendered", handleConnected);
    });
  });
</script>

Use with Vue.js (vue-cli)
Vue.js needs to be configured to recognize custom elements

Click to show code
//vue.config.js
const vueConfig = {};

vueConfig.chainWebpack = config => {
  config.module
    .rule("vue")
    .use("vue-loader")
    .loader("vue-loader")
    .tap(options => {
      options.compilerOptions = {
        ...(options.compilerOptions || {}),
        isCustomElement: tag => /^h-/.test(tag),
      };
      return options;
    });
};

module.exports = vueConfig;
// main.js
import { defineCustomElements } from "headlessui-webcomponents/dist/esm/loader";

// Bind the custom elements to the window object
defineCustomElements();
// CustomSwitch.vue
<template>
  <div>
    <h-switch
      class="flex items-center space-x-4"
      :checked="modelValue"
      @switched="this.$emit('update:modelValue', $event.detail)"
    >
      <label label>{{ label }}</label>
      <button
        button
        class="w-11 focus:outline-none focus:ring relative inline-flex flex-shrink-0 h-6 transition-colors duration-200 ease-in-out border-2 border-transparent rounded-full cursor-pointer"
        :class="[modelValue ? 'bg-indigo-600' : 'bg-gray-200']"
      >
        <span
          class="inline-block w-5 h-5 transition duration-200 ease-in-out transform bg-white rounded-full"
          :class="[modelValue ? 'translate-x-5' : 'translate-x-0']"
        ></span>
      </button>
    </h-switch>
  </div>
</template>

<script>
export default {
  name: "CustomSwitch",
  emits: ["update:modelValue"],
  props: {
    modelValue: { type: Boolean, default: false },
    label: { type: String, default: "" },
  },
};
</script>

Use with React
I did not try with React but there are extensive instructions in Stencilejs website and Stencilejs should also be able to output React components that wrap the webcomponent.
https://stenciljs.com/docs/react

Use with Angular
I did not try with Angular but there are extensive instructions in Stencilejs website and Stencilejs.
https://stenciljs.com/docs/angular

Use with Ember
I did not try with Ember but there are extensive instructions in Stencilejs website and Stencilejs.
https://stenciljs.com/docs/ember

Implementation with Stenciljs
This is the switch component ported as webcomponent

Click to show code
import {
  Component,
  Element,
  Event,
  EventEmitter,
  h,
  Prop,
  State,
} from "@stencil/core";
import { Keys } from "../../utils/keyboard";
import { useId } from "../../hooks/use-id";

@Component({
  tag: "h-switch",
  shadow: false,
})
export class HSwitch {
  @Element() host: Element;

  /**
   * The status of checkbox
   */
  @Prop() checked: boolean = false;

  /**
   * Event emitted on status change
   */
  @Event({ bubbles: false }) switched: EventEmitter<boolean>;

  /**
   * Event emitted after component is rendered
   */
  @Event() componentRendered: EventEmitter;

  @State() button: HTMLButtonElement;
  @State() label: HTMLLabelElement;
  @State() id: number;

  // This is called only first time
  componentWillLoad() {
    this.id = useId();
    this.label = this.host.querySelector("[label]");
    this.button = this.host.querySelector("[button]");

    if (this.label) {
      this.label.id = "headlessui-switch-label-" + this.id;
      this.label.addEventListener("click", () => this.handleLabelClick());
    }

    this.button.id = "headlessui-switch-" + this.id;

    if (this.button.tagName === "BUTTON") {
      this.button.tabIndex = 0;
      this.label && this.button.setAttribute("aria-labelledby", this.label.id);
      this.button.setAttribute("role", "switch");
      this.button.addEventListener("click", e => this.handleClick(e));
      this.button.addEventListener("keyUp", e => this.handleKeyUp(e));
      this.button.addEventListener("keyPress", e => this.handleKeyPress(e));
    }
  }

  render() {
    this.button.setAttribute("aria-checked", this.checked.toString());

    return <slot></slot>;
  }

  componentDidRender() {
    this.componentRendered.emit();
  }

  private handleLabelClick() {
    this.button?.click();
    this.button?.focus({ preventScroll: true });
  }

  private handleClick(event) {
    event.preventDefault();
    this.toggle();
  }

  private handleKeyUp(event) {
    if (event.key !== Keys.Tab) event.preventDefault();
    if (event.key === Keys.Space) this.toggle();
  }

  private handleKeyPress(event) {
    event.preventDefault();
  }

  private toggle() {
    this.switched.emit(!this.checked);
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions