ryansolid / dom-expressions

A Fine-Grained Runtime for Performant DOM Rendering
MIT License
850 stars 122 forks source link

Support combining tag classes and the class property in hyper dom expressions #282

Closed wrnrlr closed 1 week ago

wrnrlr commented 8 months ago

It is not possible to combine classes from both the tag name and the element class property. For example h('button.px-3',{class:()=>'py-3'},'Hi'} will currently only render the class property. This is a useful feature especially if you have a lot of static as well as dynamic classes.

I've created a modified version of the createHyperScript function that scratches my itch and if you like I can create a pull request with my changes but I first wanted to check if this is something that is in scope.

My version of h is tested to work with the following conditions, dunno if I forgot some more edge case:

function Example() {
  const [count,setCount] = createSignal(1),
    down = ()=>setCount(count()-1), up = ()=>setCount(count()+1),
    classes = () => 'p-' + count()
  return h('div',[
    h('button.p-3','Hu'), ' ',
    h('button.p-3',{},'He'), ' ',
    h('button.px-3',{class:'py-3'},'Hi'), ' ',
    h('button.px-3.py-1',{class:()=>'py-3'},'Ho'), ' ',
    h('button.text-red-600',{class:classes},'Ha'), ' ',
    h('button.p-3',{onClick:down},'-'),
    ()=>'count '+count(),
    h('button.p-3',{onClick:up},'+')
    ])
}

I've tried to keep my changes to a minimum, the most relevant part is in the "object" branch of the item function. Cyclomatic complexity be damned.

const $ELEMENT = Symbol("hyper-element");

// Inspired by https://github.com/hyperhype/hyperscript
export function createHyperScript(r) {
  function h() {
    let args = [].slice.call(arguments), e, multiExpression = false, classes = []

    while (Array.isArray(args[0])) args = args[0];
    if (args[0][$ELEMENT]) args.unshift(h.Fragment);
    typeof args[0] === "string" && detectMultiExpression(args);
    const ret = () => {
      while (args.length) item(args.shift());
      if (e instanceof Element && classes.length) e.classList.add(...classes)
      return e
    };
    ret[$ELEMENT] = true;
    return ret;

    function item(l) {
      const type = typeof l;
      if (l == null) void 0;
      else if ("string" === type) {
        if (!e) parseClass(l);
        else e.appendChild(document.createTextNode(l));
      } else if ("number" === type || "boolean" === type || l instanceof Date || l instanceof RegExp) {
        (e).appendChild(document.createTextNode(l.toString()));
      } else if (Array.isArray(l)) {
        for (let i = 0; i < l.length; i++) item(l[i]);
      } else if (l instanceof Element) {
        r.insert(e, l, multiExpression ? null : undefined);
      } else if ("object" === type) {
        let dynamic = false;
        let d = Object.getOwnPropertyDescriptors(l);
        for (const k in d) {
          if (k === "class" && classes.length !== 0) {
            const fixedClasses = classes.join(" "),
              value = typeof d["class"].value === "function" ?
              ()=>fixedClasses + " " + d["class"].value() :
              fixedClasses + " " + l["class"]
            Object.defineProperty(l,"class",{...d[k],value})
            classes = []
          }
          if (k !== "ref" && k.slice(0, 2) !== "on" && typeof d[k].value === "function") {
            r.dynamicProperty(l, k);
            dynamic = true;
          } else if (d[k].get) dynamic = true;
        }
        dynamic ? r.spread(e, l, e instanceof SVGElement, !!args.length) : r.assign(e, l, e instanceof SVGElement, !!args.length);
      } else if ("function" === type) {
        if (!e) {
          let props,
            next = args[0];
          if (next == null || (typeof next === "object" && !Array.isArray(next) && !(next instanceof Element)))
            props = args.shift();
          props || (props = {});
          if (args.length) props.children = args.length > 1 ? args : args[0]
          const d = Object.getOwnPropertyDescriptors(props);
          for (const k in d) {
            if (Array.isArray(d[k].value)) {
              const list = d[k].value;
              props[k] = () => {
                for (let i = 0; i < list.length; i++) {
                  while (list[i][$ELEMENT]) list[i] = list[i]();
                }
                return list;
              };
              r.dynamicProperty(props, k);
            } else if (typeof d[k].value === "function" && !d[k].value.length)
              r.dynamicProperty(props, k);
          }
          e = r.createComponent(l, props);
          args = [];
        } else {
          while ((l)[$ELEMENT]) l = (l)();
          r.insert(e, l, multiExpression ? null : undefined);
        }
      }
    }
    function parseClass(string) {
      const m = string.split(/([\.#]?[^\s#.]+)/);
      if (/^\.|#/.test(m[1])) e = document.createElement("div");
      for (let i = 0; i < m.length; i++) {
        const v = m[i], s = v.substring(1, v.length);
        if (!v) continue;
        if (!e) e = r.SVGElements.has(v) ? document.createElementNS("http://www.w3.org/2000/svg", v) : document.createElement(v);
        else if (v[0] === ".") classes.push(s);
        else if (v[0] === "#") e.setAttribute("id", s);
      }
    }
    function detectMultiExpression(list) {
      for (let i = 1; i < list.length; i++) {
        if (typeof list[i] === "function") {
          multiExpression = true;
          return;
        } else if (Array.isArray(list[i])) {
          detectMultiExpression(list[i]);
        }
      }
    }
  }

  h.Fragment = (props) => props.children;
  return h;
}
ryansolid commented 3 weeks ago

This seems like a good improvement. I will need some time to review it but I think this is generally a good thing.