A hooks-based UI component library for building master-detail views with list, split, and queue navigation modes. Built on @pionjs/pion and lit-html.
What is a "queue"? Not a data structure -- a UI pattern. Users work through a list of items one-by-one, like processing a queue of tasks. The component provides three view modes: a data table, a side-by-side split, and a full-screen sequential navigator.
npm install @neovici/cosmoz-queuePeer dependencies: @pionjs/pion, lit-html, i18next.
The queue() factory is the recommended high-level API. It composes all the internal hooks and renders the complete queue UI -- tabs, navigation, list, and detail view.
import { queue } from '@neovici/cosmoz-queue';
import { spread } from '@open-wc/lit-helpers';
import { component, useCallback } from '@pionjs/pion';
import { html } from 'lit-html';
import { ref } from 'lit-html/directives/ref.js';
import { fetchOrderDetails$ } from './api';
import type { OrderListItem } from './types';
const OrderListQueue = ({ heading }: { heading: string }) =>
queue<OrderListItem>({
// Title displayed above the list
heading,
// Unique key for persisting table column settings
settingsId: 'order-list',
// URL hash parameter names (enables deep-linking and back/forward)
idHashParam: 'id',
tabHashParam: 'qtab',
// Async function that fetches full details for a list item.
// Results are memoized per item identity.
details: useCallback(
(item: OrderListItem) => fetchOrderDetails$(item.id),
[],
),
// Render the list component. `thru` contains bindings that wire
// the omnitable events to queue state (items, selection, clicks).
// Spread them onto your list component.
list: (thru, { onRef }) =>
html`<order-list-core ${spread(thru)} ${ref(onRef)}></order-list-core>`,
// Render the detail view. `nav` contains prev/next buttons --
// place them in a slot or wherever navigation belongs in your view.
view: (thru, { nav }) =>
html`<order-view-core ${spread(thru)}>${nav}</order-view-core>`,
// Render a loading skeleton shown while `details` resolves.
loader: (thru, { nav }) =>
html`<order-view-skeleton ${spread(thru)}>${nav}</order-view-skeleton>`,
});
customElements.define('order-list-queue', component(OrderListQueue));| Prop | Type | Default | Description |
|---|---|---|---|
heading |
string |
-- | Title displayed above the tabs |
settingsId |
string? |
-- | Key for persisting omnitable column settings |
idHashParam |
string |
'qid' |
URL hash param for the selected item ID |
tabHashParam |
string |
'qtab' |
URL hash param for the active tab |
details |
(item: I) => PromiseLike<D> |
-- | Fetches full details for a list item |
api |
(id: string, item: I) => string |
-- | Alternative to details: returns a URL, queue fetches JSON internally |
list |
(thru, props) => TemplateResult |
-- | Renders the list component |
view |
(thru, props) => TemplateResult |
-- | Renders the detail view |
loader |
(thru, props) => TemplateResult |
-- | Renders the loading skeleton |
pagination |
Pagination? |
-- | Server-side pagination config |
fallback |
string? |
-- | Default tab when none is set in URL hash |
split |
SplitOpts? |
-- | Split.js configuration (sizes, min sizes) |
afterHeading |
unknown? |
-- | Extra content rendered after the heading |
Use details when you control the fetch (most common). Use api when you want the queue to handle fetching via URL.
The list, view, and loader render functions receive a thru object as their first argument. This object contains property and event bindings that wire the child component to queue state:
For list:
.settingsId,.exposedParts-- table configuration@visible-items-changed,@selected-items-changed,@total-available-changed-- sync items/selection back to queue@omnitable-item-click-- item click handler@async-simple-action-- action completion handler
For view:
.item-- the current detail item (list item or resolved detail).hideActions-- whether to hide actions (true when items are selected in split mode)@async-simple-action-- action completion handler
Spread these onto your component with ${spread(thru)} from @open-wc/lit-helpers.
The queue provides three tab-based view modes:
| Mode | Tab name | Behavior |
|---|---|---|
| List | overview |
Full-width data table (cosmoz-omnitable). The default view. |
| Split | split |
Side-by-side: resizable list on the left, detail view on the right. Disabled on mobile. |
| Queue | queue |
Full-screen detail view with prev/next navigation and keyboard arrow keys. |
The active tab is synced to the URL hash (e.g. #qtab=split&id=abc-123), enabling deep-linking and browser back/forward navigation.
On mobile viewports, the split tab is disabled and falls back to queue mode automatically.
The @neovici/cosmoz-queue/list entry point provides a managed cosmoz-omnitable with built-in state management, data fetching, pagination, and action rendering.
The high-level factory that combines useListCore() + renderListCore():
import { itemClick } from '@neovici/cosmoz-queue';
import { column, listCore, style } from '@neovici/cosmoz-queue/list';
import { component, html } from '@pionjs/pion';
import { t } from 'i18next';
import type { OrderListItem } from './types';
interface Props {
exposedParts: string;
api: (params: { pathLocator: string }) => Promise<{
items: OrderListItem[];
metaData: { totalAvailable: number };
}>;
}
const OrderListCore = ({ exposedParts, api }: Props) => {
return listCore({
// Unique key for persisting column settings
settingsId: 'order-list-core',
// CSS parts to expose for external styling
exposedParts,
// When false, omnitable applies its own local filtering
noLocal: false,
// Column definitions. The tuple is [factory, deps] -- same pattern
// as useMemo. The factory returns an object where each key becomes
// a column name.
columns: [
() => ({
title: column({
render: ({ name }) =>
html`<cosmoz-omnitable-column
name="${name}"
title=${t('Title')}
flex="2"
.renderCell=${(
_col: unknown,
{ item, index }: { item: OrderListItem; index: number },
) =>
html`<a
@click="${itemClick({
index,
activate: ['split', 'queue'],
})}"
>${item.title}</a
>`}
></cosmoz-omnitable-column>`,
}),
status: column({
render: ({ name }) =>
html`<cosmoz-omnitable-column-autocomplete
name="${name}"
title=${t('Status')}
value-path="status.name"
></cosmoz-omnitable-column-autocomplete>`,
}),
createdAt: column({
render: ({ name }) =>
html`<cosmoz-omnitable-column-date
name="${name}"
title=${t('Date created')}
></cosmoz-omnitable-column-date>`,
}),
}),
[], // dependency array (empty = compute once)
],
// Reactive query parameters. Recomputed when deps change,
// which triggers a new data fetch.
params: [() => ({ pathLocator: '/some/path' }), []],
// Data fetcher. Receives { params, page, pageSize }.
// Must return { items, total }.
list$: [
({ params }) =>
api(params).then((r) => ({
items: r.items ?? [],
total: r.metaData?.totalAvailable ?? 0,
})),
[api],
],
// Batch actions shown when items are selected
actions: [myAction()],
});
};
customElements.define(
'order-list-core',
component(OrderListCore, { styleSheets: [style] }),
);| Prop | Type | Description |
|---|---|---|
settingsId |
string |
Persistence key for column settings |
exposedParts |
string? |
CSS exportparts value |
noLocal |
boolean |
Skip omnitable local filtering (default: true) |
columns |
[() => Columns, deps[]] |
Column definitions (memoized) |
params |
[(opts) => Params, deps[]] |
Query parameters (memoized). The opts include { filters, descending, sortOn, columns } |
list$ |
[(props) => Promise<{ items, total }>, deps[]] |
Data fetcher. Receives { params, page, pageSize } |
pageSize |
number? |
Items per page for "load more" (default: 50) |
actions |
Action[]? |
Batch actions |
content |
(opts) => Renderable |
Extra content inside the omnitable |
hashParam |
string? |
URL hash param for omnitable state |
csvFilename |
string? |
Filename for CSV export |
enabledColumns |
string[]? |
Initially visible columns |
Type-safe column definition helper:
import { column } from '@neovici/cosmoz-queue/list';
const myColumn = column({
// Optional ordering hint
order: 1,
// Optional sort key
sort: 'name',
// Optional filter transform: receives the raw filter value,
// returns the value sent to the API
filter: (value: string) => ({ name: value }),
// Render function -- receives { name } where name is the object key
render: ({ name }) =>
html`<cosmoz-omnitable-column
name="${name}"
title="Name"
></cosmoz-omnitable-column>`,
});For cases where you need more control over the list without renderListCore():
useListCore(props)-- manages columns, params, data fetching, pagination, and form dialog state. Returns{ data$, columns, loadMore, dialog, open, ...state }.useListCoreState(defaults?)-- lower-level state:filters,sortOn,descending,groupOn,selectedItems, plus their setters andsetTotalAvailable.
The @neovici/cosmoz-queue/actions entry point provides a declarative system for defining user actions that open form dialogs.
import { action, Action, defaultButton } from '@neovici/cosmoz-queue/actions';
import { t } from 'i18next';
import { when } from 'lit-html/directives/when.js';
// action() is an identity function that provides type inference
export const approveOrder = action<OrderItem, ApproveFields>({
// Label shown on the button
title: () => t('Approve'),
// Optional: filter which items this action applies to.
// If provided, non-applicable items are excluded and the button
// shows a count badge like "Approve (3/5)".
applicable: (item) => item.status === 'pending',
// Optional: custom button renderer. Defaults to `defaultButton()`.
// Return `nothing` to hide the button conditionally.
button: (opts) =>
when(opts.items.length >= 1, () => defaultButton(opts)),
// Dialog configuration. Opens a `cosmoz-form` dialog.
// `items` contains only the applicable items.
dialog: ({ items, title }) => ({
heading: title,
description: t('Approve the selected orders'),
fields: [
// cosmoz-form field definitions
],
initial: {},
onSave: async (values) => {
await approveOrders(items.map((i) => i.id), values);
},
}),
});Actions are rendered automatically by listCore() when passed as the actions prop. For manual rendering (e.g. in a detail view's bottom bar):
import { renderActions } from '@neovici/cosmoz-queue/actions';
import { approveOrder } from './actions';
// In a view component:
const bottomBar = renderActions({ items: [currentItem], open })([
approveOrder,
]);interface Action<TItem, TDialog> {
title: () => string;
applicable?: (item: TItem) => boolean;
button?: (opts: Action & ActionOpts) => unknown;
dialog: (opts: DialogOpts) => Dialogable | Promise<Dialogable>;
}
interface ActionOpts<TItem> {
items: TItem[];
open: (dialog: Dialogable) => void;
slot?: string;
}When queue() is too opinionated, use the individual hooks directly.
import { useQueue, renderQueue, renderNav } from '@neovici/cosmoz-queue';
const MyQueue = ({ heading }: { heading: string }) => {
const {
index, mobile, tabnav, items, setItems,
setSelected, setTotalAvailable, totalAvailable,
onItemClick, nav,
} = useQueue<MyItem>({
idHashParam: 'id',
tabHashParam: 'tab',
});
return renderQueue({
heading,
mobile,
index,
items,
tabnav,
totalAvailable,
nav,
list: html`<my-list
@visible-items-changed=${updateWith(setItems)}
@selected-items-changed=${updateWith(setSelected)}
@total-available-changed=${updateWith(setTotalAvailable)}
@omnitable-item-click=${onItemClick}
></my-list>`,
renderItem: ({ item, nav }) =>
html`<my-view .item=${item}>${nav}</my-view>`,
renderLoader: ({ item, nav }) =>
html`<my-skeleton .item=${item}>${nav}</my-skeleton>`,
});
};| Hook | Import | Purpose |
|---|---|---|
useTabs({ items, hashParam, mobile, fallback }) |
@neovici/cosmoz-queue |
Manages overview/split/queue tabs. Returns { tabnav, activeTab }. |
useDataNav(items, opts) |
@neovici/cosmoz-queue |
Item navigation with prev/next, URL hash sync. Returns { item, setItem, next, prev, forward, index }. |
useSplit({ activeTab, ...splitOpts }) |
@neovici/cosmoz-queue |
Initializes Split.js when in split mode. |
useListState() |
@neovici/cosmoz-queue |
Creates items, selected, totalAvailable state with setters. |
useListSSE({ entity, params, list$ }) |
@neovici/cosmoz-queue |
Subscribes to Server-Sent Events (cosmoz-${entity}-updated) for real-time list updates. |
useFetchActions({ pathLocator, selected, api }) |
@neovici/cosmoz-queue |
Fetches available actions for selected items from an API. Returns { actions, actionRows, actionsFetching }. |
useAsyncAction(nav) |
@neovici/cosmoz-queue |
Handles async action completion with automatic item removal from the list. Returns { listRef, onAsyncSimpleAction }. |
usePagination() |
@neovici/cosmoz-queue |
URL hash-based page state. Returns { page, onPage }. |
Pre-configured fetch wrapper with CORS and credentials:
import {
fetch,
setBaseInit,
handleJSON,
RequestError,
} from '@neovici/cosmoz-queue/util/fetch';
// Configure default headers (call once at app startup)
setBaseInit({
headers: { 'X-Custom-Header': 'value' },
// Or use dynamic headers:
getHeaders: () => ({ Authorization: `Bearer ${getToken()}` }),
});
// fetch() includes mode: 'cors', credentials: 'include' by default
const response = await fetch('/api/orders');
const data = await handleJSON(response);RequestError extends Error with .response and .data properties for structured error handling.
Makes list cells clickable, dispatching omnitable-item-click events that the queue listens for:
import { itemClick } from '@neovici/cosmoz-queue';
// In an omnitable cell renderer:
html`<a @click="${itemClick({ index, activate: ['split', 'queue'] })}">
${item.title}
</a>`;The activate option specifies which tab to switch to. The queue picks the first non-disabled tab from the array.
| Function | Description |
|---|---|
getItems(items, selected) |
Returns selected if non-empty, otherwise items |
touch(list, id) |
Forces an omnitable item refresh by replacing it with a shallow copy |
queue() factory
|
+---> useQueue() State orchestration
| |
| +---> useListState() items, selected, totalAvailable
| +---> useTabs() overview | split | queue
| +---> useDataNav() current item, prev/next, URL hash
| +---> useKeyNav() arrow key navigation
| +---> useSplit() Split.js initialization
| +---> useUpdates() list-item-remove events
|
+---> useAsyncAction() Post-action item removal
|
+---> renderQueue() Template composition
|
+---> cosmoz-tabs-next Tab bar (List / Split / Queue)
+---> renderStats() "3-5 of 120"
+---> renderPagination() Page prev/next
+---> <div.split>
+---> list cosmoz-omnitable (user-provided)
+---> cosmoz-slider Animated detail view
+---> renderSlide() --> renderView()
|
+---> details() Async fetch
+---> renderItem or renderLoader
| Import path | Description |
|---|---|
@neovici/cosmoz-queue |
Main: queue(), useQueue(), renderQueue(), navigation hooks, SSE, utilities |
@neovici/cosmoz-queue/actions |
action(), renderActions(), defaultButton(), actionCount() |
@neovici/cosmoz-queue/list |
listCore(), column(), useListCore(), useListCoreState(), renderListCore() |
@neovici/cosmoz-queue/list/more |
useMore() -- progressive "load more" pagination |
@neovici/cosmoz-queue/list/more/render |
renderLoadMore() -- "Load more" button |
@neovici/cosmoz-queue/util/fetch |
fetch(), setBaseInit(), handleJSON(), RequestError |
The api property on useQueue() / queue() is deprecated and will be
removed in v2.0.0. Use details instead:
// Before (deprecated)
queue({ api: (id, item) => apiUrl(`items/${id}`) })
// After
queue({ details: (item) => fetch(apiUrl(`items/${item.id}`)).then(r => r.json()) })See Migration guide for more patterns.