Kitten

Learn how to break up your pages and apps into smaller, well-encapsulated, and easily maintainable components and fragments.

Topics covered

Better coding through components and fragments

The Fetchiverse example in the last tutorial is only about 50 lines of code in a single file. While that’s fine for something so simple, in larger projects, it would help us to maintain our code if we break it up into smaller components and fragments.

Let’s start by examining the layout of our list.

We have two major elements in each list item: the author’s avatar and the post content itself. These would be prime candidates to make into separate fragments or components.

So let’s do that:

export default async function route () {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()

  return kitten.html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => kitten.html`
        <li>
          <${Avatar} post=${post} />
          <${Content} post=${post} />
        </li>
      `)}
    </ul>
    <style>
      body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
      h1 { font-size: 2.5em; text-align: center; }
      ul { padding: 0; }
      li {
        display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
        margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
      }
    </style>
  `
}

const Avatar = ({ post }) => kitten.html`
  <a class='Avatar' href='${post.account.url}'>
    <img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
  </a>
  <style>
    .Avatar img {
      width: 8em;
      border-radius: 1em;
    }
  </style>
`

const Content = ({ post }) => kitten.html`
  <div class='Content'>
    ${kitten.safelyAddHtml(post.content)}
    ${post.media_attachments.map(media => (
      media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
    ))}
  </div>
  <style>
    .Content { flex: 1; }
    .Content p:first-of-type { margin-top: 0; }
    .Content p { line-height: 1.5; }
    .Content a:not(.Avatar) {
      text-decoration: none; background-color: rgb(139, 218, 255);
      border-radius: 0.25em; padding: 0.25em; color: black;
    }
    .Content img { max-width: 100%; }
    /* Make sure posts don’t overflow their containers. */
    .Content a {
      word-break: break-all;
    }
  </style>
`

Notice how we split up the styles also and encapsulated them in the components that they pertain to, scoping them to the component itself using class prefixes.

You might be wondering what happens when more than one copy of a component is included on the page. Do the style tags get replicated? The short answer is no. Kitten is smart enough to deduplicate the style tags in your components before rendering the page. In fact, it gathers all the styles on your page into a single, neat <style> tag in the <head> of your page.

Once you’ve separated your page into components and fragments, there’s no rule that says they must all be in the same file. Since they are just snippets of JavaScript, you can put each one in its own file and import them in.

🐈 In Kitten, we call a custom HTML element that can be included as a custom tag a component and any other snippet of HTML, CSS, or JavaScript a fragment. They go in .component.js and .fragment.js files, respectively.

💡 A good rule of thumb is that if an element will be reused in multiple places on a page and/or in multiple pages, it’s a component. If it is a part of page that might be rendered separately from it (or makes sense to separate for organisational reasons to make maintenance easier), it’s a fragment.

💡 If you’re using a component for layout, you can also use a .layout.js extension. Like every other Kitten-specific extension, these are just JavaScript files but Kitten knows that these are server-side routes and should not be served as client-side JavaScript.

So here’s one way we could organise the code:

index.page.js

import Avatar from './Avatar.component.js'
import Content from './Content.component.js'

export default async function route () {
  const postsResponse = await fetch('https://mastodon.ar.al/api/v1/timelines/public')
  const posts = await postsResponse.json()

  return kitten.html`
    <h1>Aral’s Public Fediverse Timeline</h1>
    <ul>
      ${posts.map(post => (
        kitten.html`
          <li>
            <${Avatar} post=${post} />
            <${Content} post=${post} />
          </li>
        `
      ))}
    </ul>
    <style>
      body { font-family: sans-serif; font-size: 1.25em; padding-left: 1.5em; padding-right: 1.5em; }
      h1 { font-size: 2.5em; text-align: center; }
      ul { padding: 0; }
      li {
        display: flex; align-items: flex-start; column-gap: 1em; padding: 1em;
        margin-bottom: 1em; background-color: #ccc; border-radius: 1em;
      }
    </style>
  `
}

Avatar.component.js

export default ({ post }) => kitten.html`
  <a class='Avatar' href='${post.account.url}'>
    <img src='${post.account.avatar}' alt='${post.account.username}’s avatar' />
  </a>
  <style>
    .Avatar img {
      width: 8em;
      border-radius: 1em;
    }
  </style>
`

Content.component.js

export default ({ post }) => kitten.html`
  <div class='Content'>
    ${kitten.safelyAddHtml(post.content)}
    ${post.media_attachments.map(media => (
      media.type === 'image' && kitten.html`<img src='${media.url}' alt='${media.description}'>`
    ))}
  </div>
  <style>
    .Content { flex: 1; }
    .Content p:first-of-type { margin-top: 0; }
    .Content p { line-height: 1.5; }
    .Content a:not(.Avatar) {
      text-decoration: none; background-color: rgb(139, 218, 255);
      border-radius: 0.25em; padding: 0.25em; color: black;
    }
    .Content img { max-width: 100%; }
    /* Make sure posts don’t overflow their containers. */
    .Content a {
      word-break: break-all;
    }
  </style>
`

💡 Separating your pages into components and fragments should make your sites easier to maintain but this does come at the expense of locality of behaviour.

Locality of behaviour is about keeping related functionality together so you can easily read through the code in a linear fashion.

In this example, I probably would have kept everything in a single file since it’s so little code and since I don’t need to include the Avatar or Content components from any other routes. Don’t be afraid to experiment with how you organise your own projects. Soon, you’ll develop a knack for knowing when you’ve hit the sweet spot.

Component idiom

The component idiom in Kitten is for your components to have a single root element and for that element to have a class name that’s used to scope classes to it.

🪤 You can have a component with multiple root elements but this will cause issues if you return just that component in an Ajax request to htmx. If you absolutely want to do this (not recommended), you must flatten the array returned by the component before sending it over the wire or else you’ll get the following error:

🞫 Error ERR_INVALID_ARG_TYPE: The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received an instance of Array.

HTML attributes

In addition to the custom properties (or “props”) you can define and set on your components, you can also pass all other props (HTML attributes) to an element of your choosing within your component.

This is very useful as it means that you can use regular HTML attributes to listen for events and even use frameworks like htmx and Alpine.js with your components.

Take this simple example of a custom button. Notice how we’re passing the rest of the props – obtained using the spread operator – to the button element. This allows us to listen for the onclick event and display an alert dialogue in response to it.

MyButton.component.js

export default function ({label = 'Missing label prop (label="…")', ...props}) {
  return kitten.html`
    <div class='MyButton'>
      <button class='MyButton' ...${props}>${label}</button>
      <style>
        .MyButton button {
          border: 2px solid darkviolet;
          color: darkviolet;
          border-radius: 0.25em;
          padding: 0.25em 0.5em;
          background: transparent;
          font-size: 2em;
        }
      </style>
    </div>
  `
}

index.page.js

import MyButton from './MyButton.component.js'

export default () => kitten.html`
  <h1>My button</h1>

  <${MyButton} label='Press me!' onclick='alert("Thank you for pressing me!")' />
`

If, instead you wanted to use Alpine.js, this is how your index page would look:

import MyButton from './MyButton.component.js'

export default () => kitten.html`
  <page alpinejs>

  <h1>My button</h1>
  <${MyButton}
    label='Press me!'
    x-data
    @click='alert("Thank you for pressing me!")'
  />
`

💡For more advanced components, you can, of course, use htmx and Alpine.js inside your component and expose just your custom interface.

Now that you have a taste for what you can do with components and fragments, let’s leave the Fetchiverse example aside for the time being and take a look at a powerful feature of components and fragments that you haven’t used yet: composition using slots.

Next tutorial: Slots