Nova App Build Task (for devs)

@dan Wow, these examples are incredibly useful. Additionally, I can now obtain the correct IDs for some of the icons.

QUESTION: With these property examples, are there any dependencies on code in a hooks.js file for processing properties, or can these be used as is?

A lot of them will need to pass through hooks to filter down to the user selected values.
Examples soon.

Pre-release video (will release later today).
You will have to handle the hooks file yourselves, but for now this is the fastest way to get the core groups UI.

Just need to create an icon, at a loss what represents this app :man_shrugging:
Might just have to go with something generic like a flower or something :man_shrugging:

Here you go…Initial release. 1.0.0

Download Component Props App

Requires macOS 26.0 Tahoe or greater.

Please please backup your properties.json file somewhere before experimenting, there may be bugs. The properties.json file is the only file it will amend.

Shipped with Sparkle updater, Component Props > Check for Updates.

icon needs love, don’t judge me :joy:

You need SFSymbols app from Apple, All the icons are SFSymbols.
If you’re trying to match an icon, screenshot it and ask chat jippety “Which SF symbol is this?” Its good at identifying.

Yes, that is what I’m using, but I want to try and use the exact same icons that RM are using, which is why these samples are handy. I had asked some time back for them to provide a list of the icons they use.

I’ll have to see how ChatGPT does at identifying them, had not thought of trying that yet.

Use one of the icons that RM use for properties, maybe!

So the plan going forward to aid in processing the core groups output through your hooks.js file.

I will add a small javascript payload to each of core group items in the right hand inspector.
On right click you will get the option to “Copy Javascript” which will copy to your clipboard.

An example might look like this for Tag in the general group, ready to paste into your hooks.js…

// Processed Tag
  const { tag, customTag } = rw.props;
  const pTag = tag === "custom" ? customTag || "div" : tag;

You’d then add pTag to the rw.setProps and it will be usable in your html as {{pTag}}

That’s the current plan anyway.

Holy shit, just to handle the color group and still needs work. Am I holding something wrong here? Is this the reality of it?

const transformHook = (rw) => {
  const p = rw.props || {};
  const { assetPath } = rw.component;

  // tag
  const pTag = p.tag === "custom" ? p.customTag || "div" : p.tag;

  // helpers -----------------------------------------------------
  const toks = [];
  const add = (v) => {
    if (!v) return;
    if (Array.isArray(v)) {
      v.forEach(add);
      return;
    }
    const s = String(v).trim();
    if (s) toks.push(s);
  };

  // Map "Over" to a hover prefix
  // self -> "hover:" ; others -> "group-hover/<over>:"
  const overToPrefix = (over) =>
    over && over !== "self" ? `group-hover/${over}:` : "hover:";

  // Normalize a single token into the correct hover context
  const reHover = /^hover:/;
  const reGroupHover = /^group-hover(?:\/[A-Za-z0-9_-]+)?:/;
  const toHoverContext = (token, over) => {
    const want = overToPrefix(over);
    if (reHover.test(token) || reGroupHover.test(token)) {
      // Replace any existing hover/group-hover prefix with the one we want
      return token.replace(reGroupHover, want).replace(reHover, want);
    }
    return want + token;
  };

  // Add a space-separated string of tokens into hover/group-hover context
  const addInHoverContext = (s, over) => {
    if (!s) return;
    String(s)
      .split(/\s+/)
      .forEach((t) => {
        if (!t) return;
        add(toHoverContext(t, over));
      });
  };

  // Safe URL for arbitrary classes
  const safeUrl = (u) => String(u).replace(/"/g, '\\"');

  // -------------------------------------------------------------
  const type = p.globalControlTypeBg; // "static" | "hover"
  const style = p.globalBgType; // "color" | "gradient" | "image"
  const over = p.globalHoverGroupBg; // "self" | "parent" | "container" | "grid" | "flex" | "custom"

  // ===== STATIC =================================================
  if (type === "static") {
    if (style === "color") {
      add(p.globalTextColor);
      add(p.globalTextColorOpacity);
    }

    if (style === "gradient") {
      // TEXT gradient (base)
      add(p.globalBgGradientDirection);
      add(p.globalBgGradientFromColor);
      add(p.globalBgGradientFromOpacity);
      add(p.globalBgGradientFromPosition);
      add(p.globalBgGradientViaColor);
      add(p.globalBgGradientViaOpacity);
      add(p.globalBgGradientViaPosition);
      add(p.globalBgGradientToColor);
      add(p.globalBgGradientToOpacity);
      add(p.globalBgGradientToPosition);
      add("bg-clip-text");
      add("text-transparent");
    }

    if (style === "image") {
      const baseUrl = p.globalBgImageResource && p.globalBgImageResource.url;
      if (baseUrl) {
        add(`[--bg-url:url("${safeUrl(baseUrl)}")]`);
        add("[background-image:var(--bg-url)]");
      }
      add(p.globalBgImagePosition);
      add(p.globalBgImageSize);
      add(p.globalBgImageRepeat);
    }
  }

  // ===== HOVER ==================================================
  if (type === "hover") {
    // START (base) — always included
    if (style === "color") {
      add(p.globalTextColor);
      add(p.globalTextColorOpacity);
    }
    if (style === "gradient") {
      add(p.globalBgGradientDirection);
      add(p.globalBgGradientFromColor);
      add(p.globalBgGradientFromOpacity);
      add(p.globalBgGradientFromPosition);
      add(p.globalBgGradientViaColor);
      add(p.globalBgGradientViaOpacity);
      add(p.globalBgGradientViaPosition);
      add(p.globalBgGradientToColor);
      add(p.globalBgGradientToOpacity);
      add(p.globalBgGradientToPosition);
      add("bg-clip-text");
      add("text-transparent");
    }
    if (style === "image") {
      const baseUrl = p.globalBgImageResource && p.globalBgImageResource.url;
      if (baseUrl) {
        add(`[--bg-url:url("${safeUrl(baseUrl)}")]`);
        add("[background-image:var(--bg-url)]");
      }
      add(p.globalBgImagePosition);
      add(p.globalBgImageSize);
      add(p.globalBgImageRepeat);
    }

    // END (hovered) — respect Over for ALL hover-side tokens
    if (style === "color") {
      addInHoverContext(p.globalTextColorHover, over);
      addInHoverContext(p.globalTextColorOpacityHover, over);
    }

    if (style === "gradient") {
      addInHoverContext(p.globalBgGradientDirectionEnd, over);
      addInHoverContext(p.globalBgGradientFromColorEnd, over);
      addInHoverContext(p.globalBgGradientFromOpacityEnd, over);
      addInHoverContext(p.globalBgGradientViaColorEnd, over);
      addInHoverContext(p.globalBgGradientViaOpacityEnd, over);
      addInHoverContext(p.globalBgGradientToColorEnd, over);
      addInHoverContext(p.globalBgGradientToOpacityEnd, over);
      // Ensure text gradient effect also switches on hover
      addInHoverContext("bg-clip-text", over);
      addInHoverContext("text-transparent", over);
      // If we output position classes for end, include them here too:
      addInHoverContext(p.globalBgGradientFromPositionEnd, over);
      addInHoverContext(p.globalBgGradientViaPositionEnd, over);
      addInHoverContext(p.globalBgGradientToPositionEnd, over);
    }

    if (style === "image") {
      const endUrl =
        p.globalBgImageResourceEnd && p.globalBgImageResourceEnd.url;
      if (endUrl)
        addInHoverContext(`[--bg-url:url("${safeUrl(endUrl)}")]`, over);
      addInHoverContext(p.globalBgImagePositionEnd, over);
      addInHoverContext(p.globalBgImageSizeEnd, over);
      addInHoverContext(p.globalBgImageRepeatEnd, over);
    }
  }

  // ==============================================================
  const colorMacro = toks.join(" ").replace(/\s+/g, " ").trim();

  rw.setProps({
    pTag,
    node: rw.node,
    assetPath,
    colorMacro,
  });
};

exports.transformHook = transformHook;

Very close to getting a failsafe colorMacro mapped to the output of “Colour” group. Just almost perfect in one file without reaching out to helpers.

BUT! I think it’s fair to say, developers are not going to be able to map out all the required js in Hooks on their own.

This will need a solution in the api I’d say. Something like the ability to call out to elements helpers from hooks. (spitballing).

I think the takeaway is “patience”. Sometime soon elements will need to provide an api solution.

const transformHook = (rw) => {
  const p = rw.props || {};
  const { assetPath } = rw.component;

  // tag
  const pTag = p.tag === "custom" ? p.customTag || "div" : p.tag;

  // helpers -----------------------------------------------------
  const toks = [];
  const add = (v) => {
    if (!v) return;
    if (Array.isArray(v)) {
      v.forEach(add);
      return;
    }
    const s = String(v).trim();
    if (s) toks.push(s);
  };

  // Map "Over" to a hover prefix
  const overToPrefix = (over) =>
    over && over !== "self" ? `group-hover/${over}:` : "hover:";

  const reHover = /^hover:/;
  const reGroupHover = /^group-hover(?:\/[A-Za-z0-9_-]+)?:/;
  const toHoverContext = (token, over) => {
    const want = overToPrefix(over);
    if (reHover.test(token) || reGroupHover.test(token)) {
      return token.replace(reGroupHover, want).replace(reHover, want);
    }
    return want + token;
  };

  const addInHoverContext = (s, over) => {
    if (!s) return;
    String(s)
      .split(/\s+/)
      .forEach((t) => {
        if (!t) return;
        add(toHoverContext(t, over));
      });
  };

  // Robust resource resolver (handles string or object forms)
  const resolveResourceUrl = (val) => {
    if (!val) return null;
    if (typeof val === "string") return val;
    if (typeof val === "object") {
      return (
        val.url ||
        val.src ||
        val.path ||
        val.href ||
        (typeof val.value === "string" ? val.value : null) ||
        null
      );
    }
    return null;
  };

  // Build bg-[url(...)] safely (escape ])
  const makeBgUrlClass = (u) => {
    if (!u) return null;
    const v = String(u).replace(/\]/g, "\\]");
    return `bg-[url(${v})]`;
  };

  // -------------------------------------------------------------
  const type = p.globalControlTypeBg; // "static" | "hover"
  const style = p.globalBgType; // "color" | "gradient" | "image"
  const over = p.globalHoverGroupBg; // "self" | "parent" | "container" | "grid" | "flex" | "custom"

  // ===== STATIC =================================================
  if (type === "static") {
    if (style === "color") {
      add(p.globalTextColor);
      add(p.globalTextColorOpacity);
    }

    if (style === "gradient") {
      add(p.globalBgGradientDirection);
      add(p.globalBgGradientFromColor);
      add(p.globalBgGradientFromOpacity);
      add(p.globalBgGradientFromPosition);
      add(p.globalBgGradientViaColor);
      add(p.globalBgGradientViaOpacity);
      add(p.globalBgGradientViaPosition);
      add(p.globalBgGradientToColor);
      add(p.globalBgGradientToOpacity);
      add(p.globalBgGradientToPosition);
      add("bg-clip-text");
      add("text-transparent");
    }

    if (style === "image") {
      const baseUrl = resolveResourceUrl(p.globalBgImageResource);
      add("bg-clip-text");
      add("text-[transparent]");
      if (baseUrl) add(makeBgUrlClass(baseUrl));
      add(p.globalBgImagePosition);
      add(p.globalBgImageSize);
      add(p.globalBgImageRepeat);
    }
  }

  // ===== HOVER ==================================================
  if (type === "hover") {
    // START (base)
    if (style === "color") {
      add(p.globalTextColor);
      add(p.globalTextColorOpacity);
    }

    if (style === "gradient") {
      add(p.globalBgGradientDirection);
      add(p.globalBgGradientFromColor);
      add(p.globalBgGradientFromOpacity);
      add(p.globalBgGradientFromPosition);
      add(p.globalBgGradientViaColor);
      add(p.globalBgGradientViaOpacity);
      add(p.globalBgGradientViaPosition);
      add(p.globalBgGradientToColor);
      add(p.globalBgGradientToOpacity);
      add(p.globalBgGradientToPosition);
      add("bg-clip-text");
      add("text-transparent");
    }

    if (style === "image") {
      const baseUrl = resolveResourceUrl(p.globalBgImageResource);
      add("bg-clip-text");
      add("text-[transparent]");
      if (baseUrl) add(makeBgUrlClass(baseUrl));
      add(p.globalBgImagePosition);
      add(p.globalBgImageSize);
      add(p.globalBgImageRepeat);
    }

    // END (hovered) — respect Over
    if (style === "color") {
      addInHoverContext(p.globalTextColorHover, over);
      addInHoverContext(p.globalTextColorOpacityHover, over);
    }

    if (style === "gradient") {
      addInHoverContext(p.globalBgGradientDirectionEnd, over);
      addInHoverContext(p.globalBgGradientFromColorEnd, over);
      addInHoverContext(p.globalBgGradientFromOpacityEnd, over);
      addInHoverContext(p.globalBgGradientViaColorEnd, over);
      addInHoverContext(p.globalBgGradientViaOpacityEnd, over);
      addInHoverContext(p.globalBgGradientToColorEnd, over);
      addInHoverContext(p.globalBgGradientToOpacityEnd, over);
      addInHoverContext("bg-clip-text", over);
      addInHoverContext("text-transparent", over);
      addInHoverContext(p.globalBgGradientFromPositionEnd, over);
      addInHoverContext(p.globalBgGradientViaPositionEnd, over);
      addInHoverContext(p.globalBgGradientToPositionEnd, over);
    }

    if (style === "image") {
      const endUrl = resolveResourceUrl(p.globalBgImageResourceEnd);
      if (endUrl) addInHoverContext(makeBgUrlClass(endUrl), over);
      addInHoverContext(p.globalBgImagePositionEnd, over);
      addInHoverContext(p.globalBgImageSizeEnd, over);
      addInHoverContext(p.globalBgImageRepeatEnd, over);
    }
  }

  // ==============================================================
  const colorMacro = toks.join(" ").replace(/\s+/g, " ").trim();

  rw.setProps({
    pTag,
    node: rw.node,
    assetPath,
    colorMacro,
  });
};

exports.transformHook = transformHook;

If you’re using the controls built for the Core Components, then yes there is quite a bit of javascript logic running behind the scenes to make those controls work :sweat_smile:

We spent a lot of time designing the controls to be as intuitive and user-friendly as possible, which meant moving most of the complexity out of the UI and into the hooks.js files. The controls may look simple, but behind the scenes there’s a lot of logic ensuring each one maps correctly to the right Tailwind classes, and that those classes are applied properly. This approach also ensures Elements doesn’t force you (or us) into any single framework or version.

If you’d like to recreate our controls in your own components, you’ll need to implement some of that javascript logic yourself to handle things like state, logic, and class formatting, just as you’re doing in your example above.

One tip is to use the format property as much as possible. It allows Elements to handle some of the heavy lifting for you, including responsive prefixes. For example, a colour control can automatically output a class string such as:
text-red-500 md:text-blue-500 lg:text-green-500

Some of the more advanced controls, such as those in the Background group, require even more logic to work correctly, meaning your hooks.js file will likely grow quite a bit. My advice is to structure it cleanly, and remember that it’s a standard javascript file, so you can add helper methods and organise it however you like, as long as it includes:
exports.transformHook = transformHook;

If you have any other specific questions, or need help or advice on doing something in a component, just let me know :slight_smile:

Yes, it all became apparent just how much logic mapping out a (on the face of it) simple control in the hooks file is required.

Most of the components I have developed would benefit from the inclusion of at least some of the core groups, and I expect that would be the case for all third party components.

I can map them out and make them re-usable. Just hadn’t foresaw the amount of js this was going to require.

I do hope in the future elements api will find a way (in layman’s terms) to ask for any core group, and have the macro for for the result of that call documented in the api. That seems like it would make things more approachable for third parties.

:100:

I’m working on it too, and I’ve realized there’s a lot of work to be done. Users will probably ask for it when they can compare our work with native components.
In any case, replication isn’t always necessary, but maintaining certain options is important to make the component familiar to the user :grinning_face:

@ben This brings up an interesting question for me. Do all properties HAVE to be processed through the hooks.js file?

I have a lot of properties that work just fine that are not going through a hooks file, so I’m trying to understand if I need to fix that now.

Not really. take this example of the “Advanced” group in the core components

{
      "icon": "gearshape",
      "properties": [
        {
          "id": "cssClasses",
          "textArea": {
            "default": ""
          },
          "title": "CSS Classes"
        },
        {
          "id": "globalID",
          "responsive": false,
          "text": {
            "default": ""
          },
          "title": "ID"
        }
      ],
      "title": "Advanced"
    }

There is only one possible state, so you don’t need to worry about state.
you could just use {{cssClasses}} and {{globalID}} directly.

Mostly the issue comes when there are multiple possible states that the group can be in, like when there are options via select controls. Then you need to map the state in hooks to your own processed macro {{processedMacro}}

if you look at the colour group, there are 6 main states

2 types x 3 styles

Those need processing in hooks.

@Doobox Thanks for clarifying that, Gary. That is essentially the approach I have taken, where simple things don’t go through the hooks file.

It is incredible, though, how complex the whole property process can become if you are trying to do it correctly. I’m finding that to make a component viable for the average user, you have to be very strict about matching the core components.

Which is where your tool will be invaluable and a huge timesaver.

But regardless that you can use these directly without processing. Even things like this can benefit from processing through hooks. in this example you could process in case the user entered junk, and not valid id and or classes. It’s endless what you can do via processing through hooks.

You might for example have in input where the user enters an email address. well while you could use that directly, it would benefit from email address validation via hooks.

4 Likes