@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-utilsPeer 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 wouldreactorswr— 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:
typeis the component's human name (from the island'sopts.name, i.e. thecomponent.ymlname:— the machine name is not in the markup), andpropsholds 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.tsxAssertions 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 justinnerHTML). 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'scomponent.ymlname:(surfaced as the parsed item'stypeviaopts.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).
