Here is the counter, starting at 5
❤️
Astro islands using hyperapp. Read on or check out
the demo.
To get started, install it and import in your astro.config.mjs
> npm install @zxlabs/astrojs-hyperapp
// astro.config.mjs
import { defineConfig } from "astro/config"
import hyperapp from "@zxlabs/astrojs-hyperapp"
export default defineConfig({
integrations: [hyperapp()],
})
If you're using jsx for your components, and also use other frameworks that use jsx, you need to explicitly tell the integrations which files it should include or exclude using picomatch patterns.
This might require you to use some naming convention or folder structure to make it possible to separate components from different frameworks.
// astro.config.mjs
import { defineConfig } from "astro/config"
import hyperapp from "@zxlabs/astrojs-hyperapp"
import preact from "@astrojs/preact"
export default defineConfig({
integrations: [
hyperapp({ include: ["**/hyperapp/*"] }), //exclude option also possible
preact({ include: ["**/preact/*"] }), //exclude option also possible
],
})
Authoring islands
In astrojs, an 'island' is a part of a page that is hooked up to a client-side framework
for interactivity. Each of our islands will be activated (or "hydrated") with a call to
Hyperapp's app(...)
function.
An island module needs to default-export a function that optionally takes some server-side
props, and returns an object we could pass to Hyperapp's app()
– except the node
prop,
which will be provided by this integration.
Using islands on a page
With a island thus defined, the we can use hyperapp to render the component (both server side and hydrate client side), in our astro-components like this:
Note how the props passed in to the island in the astro-component,
are available in the serverProps
we defined in the counter.
// components/counter.jsx
const incr = x => x + 1
const decr = x => x - 1
export default const serverProps => ({
init: serverProps.startCount || 0,
view: value => (
<div class="counter">
<h1>{value}</h1>
<button onclick={decr}> - </button>
<button onclick={incr}> + </button>
</div>
),
// could also define these props:
// subscriptions: ...
// dispatch: ...
})
---
// pages/index.astro
import Counter from '../components/counter.jsx'
---
<!doctype html>
<html>
<head></head>
<body>
<p> Here is the counter, starting at 5</p>
<Counter client:load startCount={5} />
</body>
</html>
Passing static content to islands
You can pass static content from astro components in to your islands, for example to control hiding/showing the content.
---
import ContentToggle from '../components/ContentToggle.jsx'
---
<p> Click to reveal contents:</p>
<ContentToggle client:load>
<p style="color: #ffaa33">
This orange text is hidden by default.
</p>
</ContentToggle>
Click to reveal contents:
This orange text is hidden by default.
This is the default behavior for all content not specifically slotted. You can also
set a html node to appear in a specific slot with the slot
attribute.
---
import ContentToggle from '../components/ContentToggle.jsx'
---
<ContentToggle client:load>
<p style="color: #33ffff">
This turquoise text is hidden by default.
</p>
<p slot="footer">
This text is always visible
</p>
</ContentToggle>
Click to reveal contents:
This text is always visible
This turquoise text is hidden by default.
Content for the default slot is passed to the island as a second argument. Named slots are passed to the component as properties:
Slot-names need to be given as snake/kebab case (e.g slot="kebab-case"
or slot="snake_case"
)
in .astro
files (in order to be html-compliant). But for your convenience, such names are transformed to
camelCase (e.g. props.kebabCase
or props.snakeCase
) in the props passed to the island.
// components/ContentToggle.jsx
const toggle = showing => !showing
export default (props, content) => ({
init: false,
view: showing => (
<div style={{ outline: "1px #ffffff33 solid" }}>
<button onclick={toggle}>
{showing ? "Hide" : "Show"}
</button>
{showing && content}
{props.footer}
</div>
),
})
Sharing state between islands
Since each island will be it's own instance of a hyperapp-app they will not share state. Astro recommends using nanostores for sharing states, and that is a perfectly good option. You will just have to write your own effects/subscriptions.
Another option is to use state synchronization utility shipped with this integration. With this,
you define a headless "master-app" by providing an init
prop and optionally
subscriptions
and dispatch
(for middleware).
// components/master.js
import syncedIslands from "@zxlabs/astrojs-hyperapp/synced-islands"
export default syncedIslands({
init: { name: "Person McNameson", fruit: "apples" },
//subscriptions: ...
//dispatch: ...
})
It will return a function which islands can use to provide just their views in a convenient way. Although they will be rendered as separate islands (and technically individual app-instances), they will share the same state, and rerender whenever it is updated.
// components/island1.js
import master from "./master.js"
const setFruit = (state, ev) => ({
...state,
fruit: ev.target.value,
})
export default () =>
master(state => (
<div class="synced-demo">
<p>
Hello <span class="data">{state.name}</span>!
What's your favorite fruit?
<select onchange={setFruit}>
<option selected={state.fruit === "apples"}>apples</option>
<option selected={state.fruit === "oranges"}>oranges</option>
<option selected={state.fruit === "bananas"}>bananas</option>
</select>
</p>
</div>
))
Hello Person McNameson! What's your favorite fruit?
// components/island2.js
import master from "./master.js"
const setName = (state, ev) => ({
...state,
name: ev.target.value,
})
export default () =>
master(state => (
<div class="synced-demo">
<p>
I hear you like <span class="data">{state.fruit}</span>!
What's your name?
<input type="text" value={state.name} oninput={setName} />
</p>
</div>
))
I hear you like apples! What's your name?