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

@freelygive/canvas-utils

v0.5.0

Published

Runtime utilities and test helpers for Drupal Canvas code components.

Readme

@freelygive/canvas-utils

Runtime utilities and test helpers for Drupal Canvas projects.

These are plain functions, hooks and one small component exported from the package. Import them directly from the package in your code and stories.

Installation

npm install @freelygive/canvas-utils

Peer dependencies (install the ones you use): react, and for useMainEntity, drupal-canvas, drupal-jsonapi-params and swr.

Import these utilities straight from the package subpaths (…/slots, …/editor, …/navigation, …/entity) as you would react or swr — not by re-exporting them through your own files.

Runtime utilities

Each module is its own entry point — import from the specific subpath:

| Import from | Export | Kind | Description | | ------------------------------------- | ---------------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | | @freelygive/canvas-utils/slots | getSlotChildren(slot) | function | Parse a Canvas slot into an array of items you can read and rebuild (see below). | | @freelygive/canvas-utils/editor | isCanvasEditorMode() | function | true when rendering inside the Canvas page editor. | | @freelygive/canvas-utils/editor | EditorNote | component | Renders its children in a pre-styled dashed-border note box. Gate it behind isCanvasEditorMode() to show editor-only guidance. | | @freelygive/canvas-utils/editor | useEditorFullHeight(enabled) | hook | { minHeight } for full-height sections that min-h-screen breaks in the editor iframe; else undefined. | | @freelygive/canvas-utils/navigation | toSlug(name) | function | URL-friendly slug from a string (used for hash navigation). | | @freelygive/canvas-utils/navigation | getIndexFromHash(items, getName) | function | Index of the item whose toSlug(getName(item)) matches the current URL hash, or -1. | | @freelygive/canvas-utils/navigation | useHashNav({ items, getName }) | hook | [activeIndex, setHashIndex], kept in sync with the URL hash via replaceState (no scroll jump). | | @freelygive/canvas-utils/entity | useMainEntity(type, options?) | hook | SWR fetch of the current page's main entity via JSON:API. |

import {
  EditorNote,
  isCanvasEditorMode,
} from '@freelygive/canvas-utils/editor';
import { useMainEntity } from '@freelygive/canvas-utils/entity';
import { toSlug, useHashNav } from '@freelygive/canvas-utils/navigation';
import { getSlotChildren } from '@freelygive/canvas-utils/slots';

getSlotChildren(slot) — reading and rebuilding slot children

At runtime Canvas delivers a slot as <canvas-island> markup, not as React children. getSlotChildren returns one entry per top-level island as { type, props }, where:

  • type is the component's human name (from the island's opts.name, i.e. the component.yml name: — the machine name is not in the markup), and
  • props holds the component's own props plus each of its slots as a Canvas slot element ({ name, value } — the same shape Canvas delivers a slot in), keyed by slot name.

When the slot is plain React children instead (e.g. Storybook), it returns those children unchanged. So a parent that splits/controls slot children should read metadata from the parsed items and rebuild each child — a React element as-is, or <Child {...item.props} /> for a parsed island, which enters the child component exactly as if composed directly. In the Canvas editor the slot must be output directly (not rebuilt) so the editor can manage the slotted children:

import React from 'react';
import { isCanvasEditorMode } from '@freelygive/canvas-utils/editor';
import { toSlug, useHashNav } from '@freelygive/canvas-utils/navigation';
import { getSlotChildren } from '@freelygive/canvas-utils/slots';

import TabItem from './tab-item';

const getTitle = (item) => item?.props?.title ?? '';

const Tabs = ({ content }) => {
  const editor = isCanvasEditorMode();
  const items = getSlotChildren(content);
  // Switching is disabled in the editor, so there's no active tab to track.
  const [active, setActive] = useHashNav({
    items: editor ? [] : items,
    getName: getTitle,
  });

  const renderChild = (item) =>
    React.isValidElement(item) ? item : <TabItem {...item.props} />;

  return (
    <>
      <nav>
        {items.map((item, i) =>
          // Editor: inert labels. Live: buttons that switch the active tab.
          editor ? (
            <span key={i}>{getTitle(item)}</span>
          ) : (
            <button
              key={i}
              aria-selected={i === active}
              onClick={() => setActive(i)}
            >
              {getTitle(item)}
            </button>
          ),
        )}
      </nav>

      {/* Editor: output the slot directly so Canvas manages every child.
          Live: render only the active reconstructed panel. */}
      {editor
        ? content
        : items.map((item, i) => (
            <div key={i} id={toSlug(getTitle(item))} hidden={i !== active}>
              {renderChild(item)}
            </div>
          ))}
    </>
  );
};

Because each slot comes back as a renderable element, a child just outputs its slot prop like any component — no string handling, the same code path whether it was composed directly or reconstructed from an island:

const TabItem = ({ content }) => <div className="tabs-panel">{content}</div>;

useEditorFullHeight(enabled)

The editor renders components in an iframe whose height is stretched to fit content, which breaks min-h-screen / h-screen. This hook returns { minHeight } set to 80% of the real viewport (or undefined outside the editor, letting CSS handle it).

import {
  isCanvasEditorMode,
  useEditorFullHeight,
} from '@freelygive/canvas-utils/editor';

const Hero = () => {
  const editorStyle = useEditorFullHeight(isCanvasEditorMode());
  return (
    <div className="min-h-screen" style={editorStyle}>
      …
    </div>
  );
};

useMainEntity(type, options?)

options is { includes?: string[], fields?: Record<string, string[]> }includes maps to JSON:API include, and fields to sparse fieldsets (fields[type]). Returns the SWR result ({ data, isLoading, error, … }).

import { useMainEntity } from '@freelygive/canvas-utils/entity';

const { data, isLoading } = useMainEntity('node--article', {
  fields: { 'node--article': ['title'] },
});

Building Canvas tests

Tests are Storybook stories with a play function, kept under src/stories/tests/ with autodocs disabled (tags: ['!autodocs']). They run in a real browser via @storybook/addon-vitest:

npm run test                       # all stories
npm run test -- src/stories/tests/tabs.stories.tsx

Assertions use expect, within, userEvent from storybook/test. Use the test helpers below to feed components realistic Canvas input and to simulate the editor.

Hydration caveat: a <canvas-island> only renders once Canvas's runtime hydrates it, which Storybook doesn't load — so islands output directly (e.g. a slot rendered as-is in editor mode) stay inert: the custom elements sit in the DOM but their component bodies never render. Plain HTML inside a slot, though, renders normally (it is just innerHTML). So when a parent parses islands and rebuilds children whose slot bodies are plain-HTML fixtures, that content does render and you can assert on it; reach for plain React children only when you need a child component to actually mount (e.g. to drive interaction on it).

import {
  createCanvasIsland,
  createCanvasSlot,
} from '@freelygive/canvas-utils/testing/canvas-slots';
import { withEditorMode } from '@freelygive/canvas-utils/testing/editor-mode';
import { expect, userEvent, within } from 'storybook/test';
import type { Meta, StoryObj } from '@storybook/react-vite';

import Tabs from './tabs';

const meta = {
  title: 'Tests/Tabs',
  component: Tabs,
  tags: ['!autodocs'],
} satisfies Meta<typeof Tabs>;
export default meta;
type Story = StoryObj<typeof meta>;

Simulating Canvas slots — testing/canvas-slots

createCanvasIsland({ props, name, slots }) builds the <canvas-island> markup Canvas emits for one slotted component, and createCanvasSlot(html, name?) wraps a string of islands into the Canvas slot element ({ name, value }, the same shape Canvas delivers a slot in) that getSlotChildren parses — name (default "content") mirrors the component's slot prop:

  • props — the component's own props (encoded as the ["raw", value] tuples Canvas uses; rawProp(value) is exported if you need a tuple by hand).
  • name — the component's component.yml name: (surfaced as the parsed item's type via opts.name).
  • slots — a map of slot name → inner HTML, emitted as <template data-astro-template="…">, so the child is fully reconstructable.
const slot = createCanvasSlot(
  createCanvasIsland({
    props: { title: 'First tab' },
    name: 'Tab Item',
    slots: { content: '<p>First panel body.</p>' },
  }) +
    createCanvasIsland({
      props: { title: 'Second tab' },
      name: 'Tab Item',
      slots: { content: '<p>Second panel body.</p>' },
    }),
);

export const FromCanvasIsland: Story = {
  args: { content: slot },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getAllByRole('tab')).toHaveLength(2);
  },
};

Simulating editor mode — testing/editor-mode

withEditorMode is a decorator that turns on editor mode before the first render and cleans up on unmount (so state is restored even if a test is interrupted). enableEditorMode() / disableEditorMode() are exported for direct control.

export const EditorRender: Story = {
  decorators: [withEditorMode],
  args: { content: slot },
  play: async ({ canvasElement }) => {
    await expect(within(canvasElement).getByText(/editor/i)).toBeVisible();
  },
};

If a slot-parsing component is involved, add a test that simulates the parse path with createCanvasIsland (the format that would otherwise only be exercised in production).