@mkatogui/uds-primitives
v0.2.1
Published
Unstyled, accessible component primitives — Dialog, Combobox, Tabs, Menu, Tooltip
Maintainers
Readme
@mkatogui/uds-primitives
Unstyled, accessible component primitives for the Universal Design System.
Framework-agnostic headless components that handle the 5 hardest accessibility patterns: Dialog, Combobox, Tabs, Menu, and Tooltip. Zero CSS, zero visual opinions -- just correct keyboard navigation, focus management, and ARIA attributes.
Works with vanilla JavaScript, React refs, Vue refs, Svelte actions, or any DOM-based framework.
Installation
npm install @mkatogui/uds-primitivesPrimitives
Dialog
Modal dialog with focus trap, Escape to close, backdrop click to close, scroll lock, and focus restoration.
Accessibility features:
role="dialog"andaria-modal="true"aria-labelledbypointing to the dialog title- Focus trap: Tab/Shift+Tab cycle within the dialog
- Focus restoration: returns focus to the trigger element on close
- Escape key to close
- Backdrop click to close (configurable)
- Body scroll lock when open (configurable)
<button id="trigger">Open Dialog</button>
<div id="backdrop"></div>
<div id="dialog">
<h2 id="dialog-title">Confirm Action</h2>
<p>Are you sure you want to proceed?</p>
<button id="confirm">Confirm</button>
<button id="cancel">Cancel</button>
</div>import { createDialog } from '@mkatogui/uds-primitives/dialog';
const dialog = createDialog(document.getElementById('dialog'), {
triggerElement: document.getElementById('trigger'),
backdropElement: document.getElementById('backdrop'),
titleElement: document.getElementById('dialog-title'),
closeOnEscape: true,
closeOnBackdropClick: true,
lockScroll: true,
onOpen: () => console.log('opened'),
onClose: () => console.log('closed'),
});
// Programmatic control
dialog.open();
dialog.close();
dialog.isOpen(); // boolean
// Cleanup
dialog.destroy();Combobox
Autocomplete/typeahead input with keyboard navigation, filtering, and virtual focus via aria-activedescendant.
Accessibility features:
role="combobox"on the input,role="listbox"on options containerrole="option"on each optionaria-expanded,aria-controls,aria-autocomplete="list"aria-activedescendantfor virtual focus (no actual DOM focus change on options)- Arrow key navigation (Up/Down) through visible options
- Home/End to jump to first/last option
- Enter to select the active option
- Escape to close the listbox
- Default text filtering or custom filter function
<input id="search" type="text" />
<ul id="listbox">
<li role="option">Apple</li>
<li role="option">Banana</li>
<li role="option">Cherry</li>
<li role="option">Date</li>
</ul>import { createCombobox } from '@mkatogui/uds-primitives/combobox';
const combobox = createCombobox(
document.getElementById('search'),
document.getElementById('listbox'),
{
autoSelect: true,
onSelect: (optionEl, value) => {
console.log('Selected:', value);
},
onFilter: (query) => {
// Custom filtering logic (optional -- default uses textContent matching)
},
}
);
combobox.open();
combobox.close();
combobox.getActiveOption(); // HTMLElement | null
combobox.destroy();Tabs
Tabbed interface with roving tabindex, arrow key navigation, and automatic or manual activation.
Accessibility features:
role="tablist"on the container,role="tab"on triggers,role="tabpanel"on panelsaria-selectedon the active tabaria-controlsandaria-labelledbylinking tabs to panels- Roving tabindex: only the active tab is in the Tab order
- Left/Right arrow keys for horizontal, Up/Down for vertical
- Home/End to jump to first/last tab
- Automatic mode: arrow keys both focus and activate
- Manual mode: arrow keys move focus, Enter/Space activates
<div id="tablist">
<button role="tab">Tab 1</button>
<button role="tab">Tab 2</button>
<button role="tab">Tab 3</button>
</div>
<div>
<div role="tabpanel">Content for Tab 1</div>
<div role="tabpanel">Content for Tab 2</div>
<div role="tabpanel">Content for Tab 3</div>
</div>import { createTabs } from '@mkatogui/uds-primitives/tabs';
const tabs = createTabs(document.getElementById('tablist'), {
orientation: 'horizontal', // or 'vertical'
activation: 'automatic', // or 'manual'
defaultIndex: 0,
onChange: (tab, panel, index) => {
console.log('Active tab:', index);
},
});
tabs.activateTab(2); // Switch to the third tab
tabs.getActiveIndex(); // number
tabs.getActiveTab(); // HTMLElement | null
tabs.getActivePanel(); // HTMLElement | null
tabs.refresh(); // Re-link after DOM changes
tabs.destroy();Menu
Dropdown menu with keyboard navigation, first-letter jumping, and focus management.
Accessibility features:
role="menu"on the container,role="menuitem"on each itemaria-haspopup="true"andaria-expandedon the trigger button- Arrow key navigation (Up/Down) with wrapping
- Home/End to jump to first/last item
- First-letter navigation: typing a character jumps to the next matching item
- Multi-character buffered search (500ms timeout)
- Enter/Space to activate the focused item
- Escape to close and return focus to the trigger
- Clicking outside closes the menu
<button id="menu-trigger">Actions</button>
<div id="menu">
<button role="menuitem">Edit</button>
<button role="menuitem">Duplicate</button>
<button role="menuitem">Delete</button>
</div>import { createMenu } from '@mkatogui/uds-primitives/menu';
const menu = createMenu(
document.getElementById('menu-trigger'),
document.getElementById('menu'),
{
closeOnSelect: true,
onSelect: (item) => {
console.log('Selected:', item.textContent);
},
onOpen: () => console.log('menu opened'),
onClose: () => console.log('menu closed'),
}
);
menu.open();
menu.close();
menu.toggle();
menu.isOpen(); // boolean
menu.refresh(); // Re-init items after DOM changes
menu.destroy();Tooltip
Informational tooltip that shows on hover and focus, with configurable delay and Escape to dismiss.
Accessibility features:
role="tooltip"on the tooltip elementaria-describedbyon the trigger, linking to the tooltip- Shows on
mouseenter(with 300ms delay) and onfocusin(immediately) - Hides on
mouseleave,focusout, and Escape key - Pointer can move over the tooltip without dismissing it
- Does NOT trap focus -- tooltips are purely informational
<button id="btn">Save</button>
<span id="tip">Save your changes (Ctrl+S)</span>import { createTooltip } from '@mkatogui/uds-primitives/tooltip';
const tooltip = createTooltip(
document.getElementById('btn'),
document.getElementById('tip'),
{
showDelay: 300,
hideDelay: 0,
onShow: () => console.log('tooltip visible'),
onHide: () => console.log('tooltip hidden'),
}
);
tooltip.show(); // Show immediately (no delay)
tooltip.hide(); // Hide immediately
tooltip.isVisible(); // boolean
tooltip.destroy();Importing
Import all primitives at once:
import { createDialog, createCombobox, createTabs, createMenu, createTooltip } from '@mkatogui/uds-primitives';Or import individually for tree-shaking:
import { createDialog } from '@mkatogui/uds-primitives/dialog';
import { createCombobox } from '@mkatogui/uds-primitives/combobox';
import { createTabs } from '@mkatogui/uds-primitives/tabs';
import { createMenu } from '@mkatogui/uds-primitives/menu';
import { createTooltip } from '@mkatogui/uds-primitives/tooltip';Design Principles
Zero CSS -- These primitives add no styles. You bring your own design system, Tailwind classes, or CSS-in-JS.
Framework-agnostic -- Pure DOM manipulation. Works with vanilla JS, React refs, Vue template refs, Svelte
bind:this, Web Components, or any other framework.Correct by default -- ARIA attributes, keyboard navigation, and focus management follow WAI-ARIA Authoring Practices 1.2. Each primitive handles the edge cases that are easy to get wrong.
Destroyable -- Every primitive returns a
destroy()method that removes all event listeners and ARIA attributes, preventing memory leaks in SPA frameworks.Minimal API surface -- Each primitive takes a DOM element and an options object. No build step, no dependencies, no runtime framework.
License
MIT
