Skip to content

armchair-traveller/sashui

Repository files navigation

Sash UI

Short for Svelte Action Stores & Headless UI.

Installation: simply run npm i -D sashui

Play with the API in an online IDE

Alternatives If you're looking a component library closer to Headless-UI (something that isn't action based, a little bit more verbose), see https://github.com/rgossiaux/svelte-headlessui . If you're not specifically looking for an API for Tailwind UI integration, [shadcn-svelte](https://github.com/huntabyte/shadcn-svelte) and [melt-ui](https://github.com/melt-ui/melt-ui) are great choices. Any work on Sashui is currently done for personal deployments. While it exposes many powerful internal APIs, it should be noted that actions aren't SSR'd (though it doesn't matter given all accessible interactivity needs JS as they're not using native HTML interactivity) and only the native element plus their associated attributes defined by the user will be present through SSR. Additionally, actions are a large departure from Headless-UI and therefore uses up more time to decode requirements for components. Expect Sashui to contain few components until adapting components from libraries like Radix.

Status

✔ Menu
✔ Switch (A toggle isn't a switch! These use checked aria attributes instead of pressed.)
✔ Toggle
✔ Dialog (Modal)
✔ Listbox (Select) - Large similarities to Menu in usage. Docs in JSdoc.

On the chopping block:

  • Development is on hold while I'm paying close attention to shadcn and potential integration with Tailwind UI, which have conflicting implementations but aren't completely unrelated in their goals. melt-ui & shadcn (unofficial) are present in Svelte. Also looking forward to AI usage e.g. v0.dev

🛣 Roadmap

  • Refactor Listbox code into Menu (they're essentially the same implementations with naming/orientation differences). Possibly extract utils/helpers used in other similar components (e.g. Combobox).
  • Update search logic after above refactor
  • Radio Group (low priority in favor of <input type="radio">s)
  • Disclosure (low priority in favor of <summary>)
  • Possibly use inspiration for more components in libs like Radix/Chakra/Shadcn, just checking behavior & attributes (excluding data prop), ignoring code... a sort of one-stop shop for renderless APIs.
  • Plans to include and expose basic, but robust action utitilies e.g. portals/clickOutside.

Components that won't be added to the library (likely because there're great alternatives): Popover, tooltip.

Why?

Headless UI is a spin on the concept of renderless components and if you're not in the know:

A renderless component is a component that doesn't render any of its own HTML.
Instead it only manages state and behavior, exposing a single scoped slot that gives the parent/consumer complete control over what should actually be rendered. source

They're used to inject logic into HTML we're used to working with.

But there's a problem with this in Svelte. You lose access to element directives (e.g. transitions, class) and bindings inherent to those elements. They will have to be redefined in the component API that the maintainer exposes, and that is by no means an easy feat (would also be very bloated unless it is compiled/preprocessed via build config).

Introducing a Svelte feature that lets the consumer enjoy renderless components while giving the ability to define their own elements... (Have thy cake and eat it, too!) An action is defined as:

A function that is called when an element is created, taking the element and optional parameters as arguments.

And you can do all sorts of things with actions. It's a simple concept that can allow you to do the same thing managing state and behaviors with elements, and in my humble opinion is a better step forward towards the main goals of renderless components. Since it's just a function performed on an element, you can coordinate all sorts of crazy things and create interop with your own state. And speaking of state, we have a simple solution: stores. All while having the same access to elements that makes Svelte powerful (directives, bindings, etc), but without the extra fluff/bloat or suffering from constraints.

Use what you already know from Svelte and Sashui will be your friend — there's no unexpected new component API to learn, most of which is a thinly veiled attempt to replicate the Svelte conveniences all over again, but with slight differences just to get it working for components.

So that's what this library is about. A Headless UI port for Svelte, using actions and stores. Sometimes it uses components for their slot props, especially when it doesn't make sense to manually manage each piece of state in a list, but most of the time all it is actions and stores.

Where's the Trade-Off?

While actions are, in my opinion, a better solution for the consumer of the API... people may be wondering if it's even a departure from how Headless UI components work given how they're 1:1 with their elements (each component is one element). It's mostly because of the degree of control over the render logic that Headless has in its implementation, which has no easy equivalent in Svelte. With actions and stores, the render logic doesn't really have to be considered as the consumer gets to build with good ol' elements. For the maintainer, it's a lot of verbose vanilla DOM code to write, but hey the consumer gets to keep all the element directives, power, and conveniences Svelte offers. This is the tradeoff: An API the consumer can appreciate, for the cost of the maintainer needing to write DOM manipulation code.

Notes This is a Svelte project adapting Headless-UI's (React) functionality to Svelte. Its end goal is just to have the functionality and accessibility of Headless-UI as a few components with predefined unstyled elements.

There was goal of a demo form, mostly serving as examples of accessible components for reference, rather than as a library... however it turns out that an actual implementation was a more attractive proposition. I had a repo called IncluSvelte that was aiming to be a demo, but since headless-ui is more clearly defined I opted for this library.

Current status: Most abstractions are perfectly working, save for SSR. Well... don't quote me on that as there're no tests in code, only manual tests (however I did iron out some logic that could cause issues). There're a few differences that're made intentionally by design, sometimes for UX reasons, but mainly to fit the usecase of "enhancing" elements with interactivity, rather than straight up giving components. This way, all the power of Svelte element directives are available to the consumer.

On the topic of SSR: The optimal solution is SSR attributes for actions, as detailed in Relevant Links. Another possible solution is to provide to consumer attributes to spread or tell the consumer the aria attributes required. It's not a big issue since ALL of the components require JavaScript and will simply not work without. So the main element that triggers the interactivity will be the only one that needs SSR attributes, possibly only introducing an inconvenience of spreading once per component. To some extent, the server-side component API may help but I don't believe it's a valid solution.

Some additional comments... I find it pretty interesting the amount of vanilla DOM needed from a maintainer's perspective. I've even employed a hack in SlotEl.svelte to create a utility that gets the element reference to a slotted element without cluttering the DOM... by adding and removing an invisible div. That was pretty funny.

Docs

Not too fleshed out at the moment, feel free to ask questions so that usecases can be added to the docs. Most actions/components have JSdoc examples and notes for usage. This section will only cover basic code snippets, which should be sufficient to piece together if you have basic knowledge of elements and have seen the Headless-UI React docs/snippets. Please use GitHub's table of contents navigation feature for an overview and to quickly zip to parts you need.

Transition implementation is skipped in favor of Svelte's native transitions / animations.

It's important to understand that the library's implementation is using actions to enhance the raw elements the consumer provides. It utilizes the native interaction benefits of HTML when possible, instead of masquerading or overriding them. This means less package size and better performance, and well... elements are the most battle tested and stable APIs a web developer can have when it comes to components. Many elements have their own specific interactions and semantic HTML purposes. This means that you should use the correct markup, usually provided in examples/docs. This is inline with how Svelte elements work -- for example you can only bind to valid attributes on any particular HTML element, and they offer different conveniences depending on the element. This library isn't an excuse to abuse and throw a bunch of divs on your page or disregard any semblance of semantic HTML, merely to provide an easy, quick entrypoint without overloading your brain with the component's implementation details. A simple docs minimal markup copy+paste is the only required work that has to be done. It's like working with normal semantic HTML, so if you're already familiar with that then you will feel at home, perhaps with little use for the docs very quickly.

That said, this library attempts to be flexible where it makes sense, and provide conveniences where/when it is possible. You aren't strictly bound to a certain element for everything, e.g. <button>, <input type="submit">, <input type="button"> will often be sufficiently interchangeable depending on context, and sometimes custom events are dispatched on elements for consistency. (Keep in mind that it's certainly easy to JSDoc/TS valid elements for each action/component later on which would be very convenient, if time allows work to be done on that...)

Other general implementation differences:

  • Any time you see a <Description> component in Headless UI's docs, know that it isn't implemented here as it's simply a aria-describedby on the root el set to the id of the description element. Literally two attributes. This is left for the consumer to manage in the off chance that they need it, there's simply no need for an extra action/component to manage this. In the case that the ID is dynamically generated by Sashui, the action store will expose the necessary id for aria-describedby as a store via a property e.g. const {menuId} = Menu.
  • In order to keep things simple, there's no nested anything. It's bad UX anyway but if there's a compelling reason I'm unaware of it can be done.

Menu

Simple Example

<script>
import { useMenu } from 'sashui'
const Menu = useMenu()
</script>

<button use:Menu.button>open</button>

{#if $Menu}
  <menu use:Menu>
    <Menu.Item let:active>
      <button class="{active ? 'bg-red-400' : ''} text-black">hi</button>
    </Menu.Item>
  </menu>
{/if}

API

useMenu(initOpen?: Boolean) initial open state. Default false. Returns Menu action store.

use:Menu={{ autofocus?: Boolean }} autofocus on menu open. Default true.

$Menu makes use of Svelte's auto-subscription syntax. Default false. You can also use it to open and close the menu programatically (e.g. $Menu = false), though closing events are already automatically managed by the menu.

Menu also has some programmatic helpers you can invoke (that're used internally):

  • Menu.selected: A writable store with the current selected menuitem element. You can also set the selected menuitem programatically, which will enable active on it, or set it to null for no selection. (Note: This doesn't reset the currently selected tree walker, so it's better to use Menu.reset() if setting menuitem. This is more for reading current selections and reacting to them via subscription, you can progrogramatically select and .click() items.)
  • Menu.open(): (async) Opens the menu and focuses it
  • While menu is on the DOM (open), you can use these menu helpers (they don't make any checks for open state so you'll have to guard it yourself if needed):
    • Menu.reset(el?: HTMLElement) resets the selected el, or if an el is passed in changes currently selected to it.
    • Menu.gotoItem(idx?: number) sets current selected item to the item index passed in, accepts negative indexing. By default uses first item. Wraps if null.
    • Menu.close() (async) closes the menu and focuses the menu button afterwards, which is the default behavior of most events causing the menu to close.
    • Menu.nextItem() Select next item
    • Menu.prevItem() Select previous item

Note: It is possible to expose the search method if there's a use case for it! It just involves a little bit of function param/state manipulation because it currently relies on closure scopes. Open a discussion/issue if you have one in mind or have suggestions!

Listbox

Simple Example

<script>
import { useListbox } from 'sashui'
const Listbox = useListbox()
</script>

<button use:Listbox.button>open</button>

{#if $Listbox}
  <ul use:Listbox aria-orientation="horizontal">
    <Listbox.Option let:active>
      <li class="{active ? 'bg-red-400' : ''} text-black">hi</li>
    </Listbox.Option>
    <Listbox.Option let:active>
      <li aria-disabled class="{active ? 'bg-red-400' : ''} text-black">this one's disabled</li>
    </Listbox.Option>
  </ul>
{/if}

The Listbox has a nearly identical API to the Menu, save for:

  • an ability to change orientation by adding an aria-orientation="horizontal" (vertical can be set too but it's the default anyway so you'd only explicitly do that if setting back to vertical from horizontal).
  • for any option, use aria-disabled on the element instead of simply disabled as it's possible that a button isn't used. A merged API with Menu is being considered for this specific difference.

Dialog (Modal)

To keep the modal accessible, the portal is managed for you. This isn't the case for any other Sash components, as creating your own portal utility (should you require it) is easily doable in Svelte. Dialog modal state isn't managed for you. close events are dispatched on the element with the trigger cause in event.detail.

useDialog(initOpen?: Boolean) initial open state. Default false. Returns dialog action store.
$dialog Boolean representing dialog open state. Default false. Set it to open/close the modal.
use:dialog={{ initialFocus?: HTMLElement }} if an initial focus is set, must be a valid focusable element within the modal.

<script>
import { useDialog } from 'sashui'
const dialog = useDialog(true)
</script>

{#if $dialog}
  <div use:dialog on:close={() => ($dialog = false)} aria-describedby="dialog-desc">
    <div use:dialog.overlay />

    <h2 use:dialog.title>Deactivate account</h2>
    <div id="dialog-desc">This will permanently deactivate your account</div>

    <p>
      Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot
      be undone.
    </p>

    <button on:click={() => ($dialog = false)}>Deactivate</button>
    <button on:click={() => ($dialog = false)}>Cancel</button>
  </div>
{/if}

Note: Nested modals are not supported as mentioned, to avoid UX antipatterns. See this article for a list of alternatives (e.g. popovers, tabbed modals, etc). If it's absolutely necessary, you can get it working with some difficulty by controlling the state programatically, and it would be easier using multiple roots instead of nesting inside the modal.

Toggle / Switch

Toggles are distinct from switches in that switches have on/off text indicators. See MDN <label> docs for label usage. Switches have similar usage, except imported as Switch (pascalcase due to switch being a reserved keyword).

<!-- Basic usage -->
<script>
import { toggle } from 'sashui'
let pressed
</script>
<button on:change={()=>(pressed = !pressed)} use:toggle={pressed} />

<!-- For labels, use it like how you'd normally use a label. -->
<label>
  My toggle
  <button on:change={()=>(pressed = !pressed)} use:toggle={pressed} />
</label>

<!-- Alternatively -->
<label for="sueve-toggle">My toggle</label>
<button id="sueve-toggle" on:change={()=>(pressed = !pressed)} use:toggle={pressed} />

Relevant Links

Headless-UI React - for reference and headlessui.dev for API

renderless-svelte - for reference

svelte:element - could provide as-like API.

Spread events - mentions headless/react-aria. The API for a headless implementation would be much cleaner with spreadable events. Actions have issues due to no SSR. OR...

on:* - all event forwarding. Without these, implementing in Svelte without actions would mean you explicitly declare all events for the consumer, which will bulk up the library and make it harder to maintain, as well as requiring extra documentation.

SSR attributes for actions - This would allow actions to set SSR-specific attributes, making actions a very viable solution... In fact I'd argue it's better suited than components for this library's usecase. As a commenter has said:

main focus for users of actions[...]abstractions of element logic that can be shared

... Which is basically the focus of renderless component APIs.

Alt

Multiple components per file - non-issue. This is just a convenience feature for the maintainer, and it's possible to just include multiple components on a component just by adding object properties w/ imports.

https://mobile.twitter.com/leander__g/status/1363100744350597123

https://mobile.twitter.com/opensas/status/1346236765380759552

Publish notes: Sp far, only entrypoint is from ./index.js. Only dependency should be svelte as a peerDependency... Or you know, just copy paste your previous package.json. No shame, that's better and faster.

About

Svelte Action Stores + Headless UI.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published