npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loomidev/text-editor

v0.3.0

Published

<loomi-text-editor> — a themeable rich-text editor web component with configurable tools, a floating label, and inline validation.

Readme

@loomidev/text-editor

<loomi-text-editor> is a themeable rich-text editor web component with a native browser editing surface, a configurable toolbar, Loomi icons, Loomi tooltips, floating labels, inline validation, and native form submission.

The editor stores its value as HTML. That means users can format text visually, and your form or application receives markup such as <p>Hello <strong>world</strong></p>.

npm install @loomidev/text-editor lit
import "@loomidev/text-editor";

Basic Usage

<loomi-text-editor name="comment" label="Comment"></loomi-text-editor>

Use placeholder for a hint inside the editing area:

<loomi-text-editor
  name="comment"
  label="Comment"
  placeholder="Write a thoughtful response"
></loomi-text-editor>

Choosing Toolbar Tools

The tools prop controls what appears in the toolbar.

For plain HTML, pass a comma-separated list:

<loomi-text-editor
  label="Release notes"
  tools="basic,headings,lists,link"
></loomi-text-editor>

For JavaScript frameworks, you can also assign an array property. This is nicer when the tools come from configuration:

const editor = document.querySelector("loomi-text-editor");
editor.tools = ["basic", "colors", "lists", "embed"];

Both forms are supported on purpose:

  • Use a comma-separated string for HTML, Blade, Astro, or server-rendered templates.
  • Use an array when you are already in JavaScript, React refs, Vue refs, Svelte actions, or another framework layer.

If tools is not set, the editor uses:

default = basic, headings, lists, align, embed

Use tools="all" for the complete toolbar, or tools="none" to hide the toolbar and use the editor as a plain rich-text field.

Individual Tools

| Tool value | What it shows | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | heading | A block style picker with Body and H1-H6. | | font-family | Font family picker. | | font-size | Relative font size picker. | | bold | Bold text. | | italic | Italic text. italics is accepted as an alias. | | underline | Underlined text. | | strikethrough | Struck-through text. strike is accepted as an alias. | | font-color | Text color picker. color, text-color, and font-colour are accepted aliases. | | highlight-color | Highlight/background color picker. highlight and highlight-colour are accepted aliases. | | bullet-list | Dotted list. bullets, dots, and unordered-list are accepted aliases. | | ordered-list | Numbered list. numbers and numbered-list are accepted aliases. | | align-left | Left alignment. | | align-center | Center alignment. centre and align-centre are accepted aliases. | | align-right | Right alignment. | | align-justify | Justified alignment. | | inline-code | Inline code formatting. | | superscript | Superscript text. | | subscript | Subscript text. | | blockquote | Blockquote formatting. | | code-block | Preformatted code block. | | link | Opens a Loomi modal with URL and display text inputs. | | image | Opens a Loomi modal with URL, alt text, and a loomi-filepicker image option. | | video | Opens a Loomi modal with URL and a loomi-filepicker video option. YouTube and Vimeo URLs are normalized to embed URLs. | | ai | Shows an AI generate button and dispatches loomi-ai-generate for your app to handle. generate and ai-generate are accepted aliases. |

Tool Groups

Groups let you keep templates readable.

| Group | Expands to | | ------------ | ---------------------------------------------------------------------- | | default | basic, heading, lists, align, embed | | basic | bold, italic, underline, strikethrough | | marks | basic, inline-code, superscript, subscript | | colors | font-color, highlight-color | | font | font-family, font-size | | typography | heading, font-family, font-size, font-color, highlight-color | | lists | bullet-list, ordered-list | | align | align-left, align-center, align-right, align-justify | | script | superscript, subscript | | code | inline-code, code-block | | blocks | blockquote, code-block | | embed | link, image, video | | media | image, video | | all | Everything listed above, including ai | | none | No toolbar |

You can mix groups and individual values:

<loomi-text-editor
  label="Article body"
  tools="typography,basic,lists,blockquote,link,image"
></loomi-text-editor>

Duplicate tools are ignored, and the toolbar keeps a consistent Loomi order.

Headings vs Font Size

The editor includes H1-H6 through the heading tool, even though font-size also exists. They are not the same thing:

  • Use headings when the text has document structure, such as article titles, section headings, or email headings.
  • Use font size when you only want visual emphasis inside otherwise normal content.

This keeps submitted HTML more useful for accessibility, search, server-side rendering, and later content processing.

Icons and Tooltips

Toolbar buttons use <loomi-icon> where the shared icon registry has a suitable icon. Every toolbar icon control is wrapped in <loomi-tooltip>, so compact controls still explain themselves on hover or keyboard focus.

Some text-formatting controls, such as superscript and subscript, use short text labels when that is clearer than forcing an unrelated icon.

Labels, Height, and Validation

label renders above the editor. rows controls the minimum editor height before content pushes it taller.

<loomi-text-editor
  name="bio"
  label="Bio"
  rows="6"
  required
  show-error-inline
  error-message="Tell us a little about yourself"
></loomi-text-editor>

validate() returns true or false:

const editor = document.querySelector("loomi-text-editor");

saveButton.addEventListener("click", () => {
  if (!editor.validate()) return;
  // continue with a valid value
});

Reading Values

value is always HTML:

const editor = document.querySelector("loomi-text-editor");

editor.addEventListener("input", () => {
  console.log(editor.value);
});

Example value:

<h2>Project update</h2>
<p>The <strong>new dashboard</strong> is ready for review.</p>
<ul>
  <li>Confirm the final copy.</li>
  <li>Share feedback before Friday.</li>
</ul>

Submitting Values

<loomi-text-editor> is form-associated. Give it a name, and it submits with native FormData just like an input:

<form id="post-form">
  <loomi-text-editor
    name="body"
    label="Post body"
    tools="all"
    required
  ></loomi-text-editor>

  <button>Publish</button>
</form>

<script type="module">
  import "@loomidev/text-editor";

  const form = document.querySelector("#post-form");

  form.addEventListener("submit", (event) => {
    event.preventDefault();

    const data = new FormData(form);
    const html = data.get("body");

    console.log(html);
  });
</script>

Handling Submitted HTML Safely

Because the value is HTML, treat it as user-generated HTML.

Recommended flow:

  1. Validate that required content exists in the browser with required, validate(), or your form library.
  2. Submit value or new FormData(form).get(name) to your server.
  3. Sanitize the HTML on the server with your platform's trusted HTML sanitizer.
  4. Store the sanitized HTML, or store both the original and sanitized versions if your moderation workflow needs that.
  5. When rendering saved content, render only sanitized HTML.

Do not trust client-side sanitizing as your only protection. The browser can be modified, requests can be replayed, and value can be assigned directly from JavaScript.

For simple previews where you only need plain text, convert HTML to text in your app:

function htmlToText(html) {
  const div = document.createElement("div");
  div.innerHTML = html;
  return div.textContent || "";
}

Links, Images, and Videos

The link, image, and video tools open a <loomi-modal> instead of using browser prompts.

  • Link inserts use <loomi-input> fields for the URL and optional display text.
  • Image inserts use <loomi-input> for an image URL and alt text, plus <loomi-filepicker> for choosing an image file.
  • Video inserts use <loomi-input> for a video URL, plus <loomi-filepicker> for choosing a video file.

Links are hardened with target="_blank" and rel="noopener noreferrer". Image and video URL fields accept HTTP, HTTPS, and relative URLs. YouTube and Vimeo links render as iframe embeds.

By default, a file chosen through loomi-filepicker is inserted as a data URL. That works for immediate previews and simple forms, but it inlines the whole file into the saved value and is stripped outright by any sanitizer whose media allowlist is HTTP/HTTPS only. Set uploadHandler to upload the file instead and insert the URL you get back.

Uploading picked files

uploadHandler is a property, not an attribute — a function can't be expressed in markup, so assign it in JavaScript (the same shape as <loomi-input>'s dynamicMask).

const editor = document.querySelector("loomi-text-editor");

editor.uploadHandler = async (file, kind) => {
  const body = new FormData();
  body.append("file", file);

  // `kind` is "image" or "video" — route each to the endpoint that validates it.
  const response = await fetch(`/api/media/${kind}`, { method: "POST", body });
  if (!response.ok) throw new Error("Upload failed. Please try again.");

  const { url } = await response.json();
  return url; // inserted as the <img>/<video> src
};

| Argument | Description | | -------- | ------------------------------------------------------------------ | | file | The File the user picked. | | kind | "image" or "video", matching the embed dialog that was opened. |

Resolve the URL to insert, or resolve undefined to insert nothing. If the handler rejects or resolves undefined, the editor inserts nothing, leaves the dialog open, and shows a <loomi-notification> error toast — a thrown Error's message is used as the toast body, so throw something you're happy showing the author. Because the handler is your own code, its return value isn't held to the HTTP/HTTPS allowlist that user-typed URLs are: relative storage paths and blob: preview URLs are inserted as-is, and only executable schemes (javascript:, vbscript:) are rejected.

Turning file upload off

Add no-file-upload to drop the file picker from the image and video dialogs, leaving URL entry only. Use it when the app accepts media library URLs exclusively, rather than showing a control that can't do anything useful.

<loomi-text-editor tools="basic,embed" no-file-upload></loomi-text-editor>

If your product needs a full media library or custom link picker, keep image, video, or link out of tools and provide your own buttons outside the editor. Those buttons can update editor.value or use your own app-level insertion flow.

AI Generate Option

Add ai to tools to show a sparkles button in the toolbar. Aliases generate and ai-generate are also accepted when configuring tools.

<loomi-text-editor tools="basic,lists,ai"></loomi-text-editor>

When clicked, the editor dispatches loomi-ai-generate. LoomiUI does not call any AI provider itself — your app listens for the event, runs the request against OpenAI, Anthropic, a local model, or your own backend, then inserts the returned HTML through event.detail.insert(html).

editor.addEventListener("loomi-ai-generate", async (event) => {
  const { html, selection, insert } = event.detail;

  const prompt = selection
    ? `Improve this selected text while keeping the same meaning:\n\n${selection}`
    : "Write a short introduction paragraph for this document.";

  const result = await generateText({
    html,
    selection,
    prompt,
  });

  insert(result.html);
});

Event detail

| Detail | Description | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | html | The editor's current HTML value. Use this for full-document prompts such as summarize, expand, or rewrite. | | selection | The plain-text selection at click time, if any. Empty when the caret is collapsed or nothing is selected. | | insert(html) | Helper that restores the saved selection and inserts generated HTML at that point. If the user had text selected, replace or wrap that range in your handler before calling insert. |

Selection behavior

The editor saves the current range when the sparkles button is clicked. Call event.detail.insert(html) after your async request finishes and the generated markup will land at that saved position. This works whether the user selected a sentence, placed the caret mid-paragraph, or clicked with no selection (insertion happens at the caret).

Typical flows:

  • Selection present: send selection (and optionally surrounding html) to your model, then call insert() with the rewritten fragment.
  • No selection: treat html as document context and insert new content at the caret.
  • Replace vs append: insert() uses document.execCommand("insertHTML") under the hood. Pass only the fragment you want added or swapped in.

Integration notes

  • The button is disabled when the editor is disabled or readonly.
  • Handle errors in your listener — the editor will not show a built-in AI error state.
  • Sanitize model output before insertion if your provider can return raw HTML.
  • Keep prompts, API keys, and rate limiting in application code so the component stays provider-neutral.

This keeps LoomiUI provider-neutral while still giving users a real toolbar affordance.

Field appearance

Use variant="minimal" for a bottom-border-only editor:

<loomi-text-editor label="Notes" variant="minimal"></loomi-text-editor>

Use label-position="inside" to keep a compact label inside the top of the editor, with the toolbar and editable text displayed beneath it:

<loomi-text-editor label="Notes" label-position="inside"></loomi-text-editor>

Accessibility

For the library-wide baseline, see Foundations — Accessibility.

Responsive behavior

For the shared container and viewport rules, see Foundations — Responsive behavior.

Dark mode

For theme activation, token overrides, and contrast guidance, see Foundations — Dark mode.

Attributes and Properties

| Attribute / property | Default | Description | | -------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | name | (blank) | Submitted with the nearest form. | | label | (blank) | Label above the editor. | | label-position | default | default keeps the label above the editor; inside keeps a compact label inside its top edge. | | placeholder | (blank) | Placeholder text shown when the editor is empty. | | value | (blank) | Current value as HTML. | | tools | default | Comma-separated string attribute, or string array property. | | rows | 3 | Minimum height in text rows. | | required | false | Marks the editor required. | | disabled | false | Disables editing and toolbar controls. | | readonly | false | Makes content readable but not editable. | | error-message | (blank) | Message used when validation fails. | | show-error-inline | false | Shows error-message under the field. | | no-clearing | false | Removes the default bottom margin. | | variant | default | default | minimal (bottom border only, no box) | | no-file-upload | false | Hides the file picker in the image and video dialogs, leaving URL entry only. | | .uploadHandler | (unset) | Property only. (file, kind) => Promise<string \| undefined> — uploads a picked file and returns the URL to insert. Unset, files are inlined as data URLs. |

Methods: focus(), validate(), checkValidity(), reportValidity().

CSS parts: field, toolbar, editor.

Events

| Event | Description | | ------------------- | ------------------------------------------------- | | change | Fired when the value is committed or changed. | | input | Fired while the value is edited. | | loomi-ai-generate | Fired when the AI generation action is requested. |

Framework integration

<loomi-text-editor> is a standard custom element, so it works in plain HTML, Blade, React, Vue, Angular, Svelte, Astro, and most other frameworks. Import the package once before the tag renders.

Choose your framework

<script type="importmap">
  { "imports": { "lit": "https://esm.sh/[email protected]", "lit/": "https://esm.sh/[email protected]/" } }
</script>
<script type="module" src="https://esm.sh/@loomidev/text-editor"></script>

<loomi-text-editor name="notes" label="Notes" tools="basic,lists,embed"></loomi-text-editor>

React can render the custom element directly. If you want to pass an array to tools, assign it with a ref after mount.

import { useEffect, useRef } from "react";
import "@loomidev/text-editor";

export function Editor() {
  const ref = useRef(null);

  useEffect(() => {
    ref.current.tools = ["basic", "colors", "lists", "embed"];
  }, []);

  return <loomi-text-editor ref={ref} name="body" label="Body" />;
}
<script setup>
import "@loomidev/text-editor";
</script>

<template>
  <loomi-text-editor name="body" label="Body" tools="all" />
</template>

Add CUSTOM_ELEMENTS_SCHEMA, then use the tag in your template.

import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "@loomidev/text-editor";

@Component({
  selector: "app-root",
  standalone: true,
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
  template: `<loomi-text-editor name="body" label="Body" tools="all"></loomi-text-editor>`,
})
export class AppComponent {}
<script>
  import "@loomidev/text-editor";
</script>

<loomi-text-editor name="body" label="Body" tools="typography,basic,embed"></loomi-text-editor>
---
import "@loomidev/text-editor";
---

<loomi-text-editor name="body" label="Body" tools="typography,basic,embed"></loomi-text-editor>

Server-side rendering notes

Frameworks such as Next.js, Nuxt, SvelteKit, and Astro may render HTML before browser-only custom elements run. If a framework complains, move the import to client-side code. In Next.js that usually means a component with "use client"; in Nuxt it often means a .client.ts plugin.

Developing This Package

Run commands from the top-level components workspace:

pnpm --filter @loomidev/text-editor build
pnpm --filter @loomidev/text-editor typecheck

Dependencies

  • @loomidev/core
  • @loomidev/filepicker
  • @loomidev/icon
  • @loomidev/input
  • @loomidev/modal
  • @loomidev/select
  • @loomidev/theme
  • @loomidev/tooltip