@lofi-ui/lofi
v0.1.4
Published
Simple, unstyled, web elements for common UI components
Downloads
2
Readme
Lofi
The web's missing UI primitives.
Install it
Lofi was written in vanilla JavaScript modules with no build process whatsoever. Simply include index.js
on your page and you're off:
<head>
<!-- ... -->
<script src="./index.js" type="module"></script>
</head>
Use it
To get a glimpse of how lofi works, paste the following HTML into a page and watch it sing...
<ui-invoke anchor>
<button type="button">Open</button>
<ui-menu popover>
<ui-item>Foo</ui-item>
<ui-item>Bar</ui-item>
<ui-item>Baz</ui-item>
</ui-menu>
</ui-invoke>
Components
Lofi ships with the following components:
- Radio Group
- Disclosure Call it Accordion? Chop it?
- Dropdown
- Select
- Combobox
- Tooltip
- Menu
- Switch
- Radio
- Checkbox
- Modal
- Field
- Tabs
Radio Group
<ui-radio-group>
<ui-radio value="foo" checked>Foo</ui-radio>
<ui-radio value="bar">Bar</ui-radio>
<ui-radio value="baz">Baz</ui-radio>
</ui-radio-group>
Disclosure
<ui-disclosure>
<button>...</button>
<div class="hidden data-[open]:block">
<!-- ... -->
</div>
</ui-disclosure>
Combobox
<ui-combobox>
<input type="text">
<ui-listbox>
<ui-option value="foo"> <button>Foo</button> </ui-option>
<ui-option value="bar"> <button>Bar</button> </ui-option>
<ui-option value="baz"> <button>Baz</button> </ui-option>
</ui-listbox>
</ui-combobox>
Tooltip
<ui-tooltip>
<button>Tooltip</button>
<div popover>
<!-- ... -->
</div>
</ui-tooltip>
Listbox
<ui-listbox>
<ui-option value="foo">Foo</ui-option>
<ui-option value="bar">Bar</ui-option>
<ui-option value="baz">Baz</ui-option>
</ui-listbox>
Switch
<ui-switch></ui-switch>
Dropdown
<ui-dropdown>
<button>Open</button>
<div popover>
<!-- ... -->
</div>
</ui-dropdown>
Modal
<ui-modal>
<button>Open</button>
<dialog>
<!-- ... -->
</dialog>
</ui-modal>
Field
<ui-field>
<ui-label>Something</ui-label>
<ui-description>Some description</ui-description>
<input type="text">
</ui-field>
Tabs
<ui-tabs>
<ui-tab-group>
<ui-tab panel="foo">
<button>Foo</button>
</ui-tab>
<ui-tab panel="bar">
<button>Bar</button>
</ui-tab>
<ui-tab panel="baz">
<button>Baz</button>
</ui-tab>
</ui-tab-group>
<ui-tab-panel name="foo">
<div>...</div>
</ui-tab-panel>
<ui-tab-panel name="bar">
<div>...</div>
</ui-tab-panel>
<ui-tab-panel name="baz">
<div>...</div>
</ui-tab-panel>
</ui-tabs>
Menu
<ui-menu>
<ui-item>
<button>Foo</button>
</ui-item>
<ui-item>
<button>Bar</button>
</ui-item>
<ui-item>
<button>Baz</button>
</ui-item>
</ui-menu>
<ui-combobox>
<input />
<ui-listbox>
<ui-option value="foo">
<button>Foo</button>
</ui-option>
<ui-option value="bar">
<button>Bar</button>
</ui-option>
<ui-option value="baz">
<button>Baz</button>
</ui-option>
</ui-listbox>
</ui-combobox>
Most basic example
Let's start with the most basic Lofi element. A simple switch that can be pressed to toggle it on and off:
<ui-switch>
<!-- ... -->
</ui-switch>
Lofi will handle all the accessibility features like labels, aria attributes, focusability, & keyboard interactivity.
You can style it in Tailwind using its data attributes:
<ui-switch class="group bg-white data-[checked]:bg-black">
<!-- ... -->
</ui-switch>
You can bind to its state from any framework as if it were a native input element:
<ui-switch x-model="notifications"> <!-- Alpine -->
<ui-switch v-model="notifications"> <!-- Vue -->
<ui-switch :value="notifications" onChange={setNotifications}> <!-- React, etc... -->
Here are some other elements of a similar nature.
A custom radio group component
<ui-radio-group>
<ui-radio value="table">Table</ui-radio>
<ui-radio value="grid">Grid</ui-radio>
<ui-radio value="list">List</ui-radio>
</ui-radio-group>
A tabs component
<ui-tabs>
<ui-tab-group>
<ui-tab panel="foo">Foo</ui-tab>
<ui-tab panel="bar">Bar</ui-tab>
<ui-tab panel="baz">Baz</ui-tab>
</ui-tab-group>
<ui-tab-panel name="foo">...</ui-tab-panel>
<ui-tab-panel name="bar">...</ui-tab-panel>
<ui-tab-panel name="baz">...</ui-tab-panel>
</ui-tabs>
Dialogs and popovers
When perusing lofi, you might notice an obvious lack of <ui-dialog>
or <ui-popover>
elements. This is because browsers now ship with native popovers and dialogs.
A key principle behind Lofi is: prefer native solutions; you get more for free.
However, there are still some gaps that Lofi can fill to make using them a more pleasant experience.
Let's first look at a truly native HTML popover:
<button popovertarget="dropdown">Open...</button>
<div popover id="dropdown">
<!-- Dropdown contents... -->
</div>
As you can see, the popover
attribute is simple. Attach it to an element, invoke it with a button using popovertarget
, and everything works like magic.
Briefly, here's what you get out of the box with a native popover:
- Light dismissal (press escape to close and focus invoke element)
- Top-layer rendering (no need for portals or z-index hacks)
- No need for extra aria attributes
However, there are two key hangups with going full native:
- You need to provide non-conflicting hardcoded IDs (this can be a problem in re-usable server-side templates)
- You have to "anchor" the popover's positioning to the button with JavaScript (Using something like Floating-UI)
Now let's look at a native dialog element:
<button onclick="this.nextElementSibling.showModal()">Open...</button>
<dialog>
<!-- Modal contents -->
</dialog>
Pros of the <dialog>
element:
- Automatically makes page "inert" while open
- Traps focus inside dialog
- Renders in the top-layer (no need for z-index hacks and what not)
This is great, but you can only invoke a modal dialog using element.showModal()
in JavaScript. There is no "dialogtarget" attribute like there is for popovers. (There is a proposal for a more universal API like invoketarget
to solve this)
Now that the stage is set, let me introduce you to Lofi's most powerful element: <ui-invoke>
The <ui-invoke>
element provides a way to declaratively link a invoke (often a button) with an invokeable target (often a popover or dialog).
Here's a native button/dialog combo declaratively linked by <ui-invoke>
:
<ui-invoke>
<button>Open...</button>
<dialog>
<!-- ... -->
</dialog>
</ui-invoke>
This API is an elegant alternative to a more traditional—albeit more agressive and less-composable—API like the following:
<!-- A dystopian vingette into what Lofi almost become... -->
<ui-dialog>
<ui-dialog-invoke>
<button>Open...</button>
</ui-dialog-invoke>
<ui-dialog-panel>
<dialog>
<!-- ... -->
</dialog>
</ui-dialog-panel>
</ui-dialog>
Let's tour more examples of <ui-invoke>
:
Here's a simple navigation menu using the semantic <nav>
element provided by browsers, in combination with the popover
attribute:
<ui-invoke>
<button>Open...</button>
<nav popover>
<a href="..."></a>
<a href="..."></a>
<a href="..."></a>
</nav>
</ui-invoke>
<ui-invoke>
even works with non-overlay elements. For example, you can use it in combination with Lofi's <ui-disclosure>
element to combose an accordion widget:
<ui-invoke>
<button>Do you accept refunds?</button>
<ui-disclosure class="hidden data-[open]:block">
Sure, if you ask nicely...
</ui-disclosure>
</ui-invoke>
Here's an example of using popover
in combination with Lofi's <ui-menu>
element to construct a robust action menu:
<ui-invoke>
<button>Open...</button>
<ui-menu popover>
<ui-item>Edit</ui-item>
<ui-item>Archive</ui-item>
<ui-item>Delete</ui-item>
</ui-menu>
</ui-invoke>
The beauty of the <ui-invoke>
and popover
attribute combo, is that you can "opt-out" of using components as overlays by placeing them outside <ui-invoke>
.
Here is the action menu from above rendered as a static action list on the page:
<ui-menu>
<ui-item onclick="...">Edit</ui-item>
<ui-item onclick="...">Archive</ui-item>
<ui-item onclick="...">Delete</ui-item>
</ui-menu>
Another component you might use this pattern with is: <ui-listbox>
(a stylable select menu of options). Here is a listbox statically displayed on a page:
<ui-listbox x-model="role" multiple>
<ui-option value="read">Read-only</ui-option>
<ui-option value="write">Edit</ui-option>
<ui-option value="admin">Admin</ui-option>
</ui-listbox>
Now we can make that a common select menu dropdown like so:
<ui-invoke>
<button>Open...</button>
<ui-listbox popover x-model="role" multiple>
<ui-option value="read">Read-only</ui-option>
<ui-option value="write">Edit</ui-option>
<ui-option value="admin">Admin</ui-option>
</ui-listbox>
</ui-invoke>
And of course, in addition to controlling it's value both externally and internally, you can style it with tailwind:
<ui-invoke>
<button>Open...</button>
<ui-listbox popover x-model="role" multiple>
<ui-option value="read" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Read-only</ui-option>
<ui-option value="write" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Edit</ui-option>
<ui-option value="admin" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Admin</ui-option>
</ui-listbox>
</ui-invoke>
To take it even further, we can compose a full autocomplete component by combining the listbox with a combobox text input like so:
<ui-combobox>
<input type="text" placeholder="Type to search...">
<ui-listbox popover x-model="role" multiple>
<ui-option value="read" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Read-only</ui-option>
<ui-option value="write" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Edit</ui-option>
<ui-option value="admin" class="data-[selected]:bg-blue-300 data-[active]:bg-gray-50">Admin</ui-option>
</ui-listbox>
</ui-combobox>
Or maybe you want to compose a command pallet in a dialog instead, you can do that too:
<ui-invoke>
<button>Open command...</button>
<dialog>
<ui-combobox>
<input type="text" placeholder="Type to search..." autofocus>
</ui-combobox>
<ui-listbox>
<ui-option>Dashboard</ui-option>
<ui-option>New Post</ui-option>
<ui-option>Log out</ui-option>
</ui-listbox>
</dialog>
</ui-invoke>
APIs:
For Sebastian
ui-invoke
-> limit it? (only popovers/dialogs) or expand (breaks down with combobox)ui-trigger
vsui-invoke
?- Abandon
ui-invoke
? in favor ofui-dialog
andui-popover
- What about non-modal dialogs? are those in
ui-popover?
maybe it should just beui-disclosure
? idk... - Now what about nested inside ui-dialog? to close the dialog?
- What about non-modal dialogs? are those in
- What about
x-combobox in combination with popover?
- Use
x-model
on non-form-controls likeui-tabs
ui-tabs/tablist
using independantly with a tags- Other things like menu items and options etc...
ui-dialog/popover
be used with plain divs and no popover attribute?- redundant aria attributes with something like popover?
points:
- lint for "dialog" and "popover" native stuff at runtime
- If I'm relying on dedicated children, I should error out when it's missing
Fundamental things:
- Should ui-checkbox still be x-modealable when inside x-checkbox-group?
- If so, should it still dispatch bubbling change events?
- If so, should they be intercepted by ui-checkbox-group to prevent double handling by x-model?
- If so, should it still dispatch bubbling change events?
- Which attributes/getter/setters should exist on wrappers, and which on child buttons, and which duplicated?
- How should "disabled" behavior be handled? What get's "disabled"
- Potentially disabled things: aria, interaction handlers, x-model/selectability?
- When to use data-active and when to use data-focus (and is data-focus the same as data-focus-visible)
- How is "morphing" handled (particularly IDs)
- How does it react when things like data-checked and checked are changed in the DOM?