@neovici/cosmoz-queue 1.6.1 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,510 @@
1
- # cosmoz-queue
1
+ # @neovici/cosmoz-queue
2
2
 
3
- A pionjs component
3
+ A hooks-based UI component library for building **master-detail views** with list, split, and queue navigation modes. Built on [`@pionjs/pion`](https://github.com/pionjs/pion) and [`lit-html`](https://lit.dev/docs/libraries/standalone-templates/).
4
+
5
+ > **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.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install @neovici/cosmoz-queue
11
+ ```
12
+
13
+ Peer dependencies: `@pionjs/pion`, `lit-html`, `i18next`.
14
+
15
+ ## Quick start
16
+
17
+ 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.
18
+
19
+ ```ts
20
+ import { queue } from '@neovici/cosmoz-queue';
21
+ import { spread } from '@open-wc/lit-helpers';
22
+ import { component, useCallback } from '@pionjs/pion';
23
+ import { html } from 'lit-html';
24
+ import { ref } from 'lit-html/directives/ref.js';
25
+ import { fetchOrderDetails$ } from './api';
26
+ import type { OrderListItem } from './types';
27
+
28
+ const OrderListQueue = ({ heading }: { heading: string }) =>
29
+ queue<OrderListItem>({
30
+ // Title displayed above the list
31
+ heading,
32
+
33
+ // Unique key for persisting table column settings
34
+ settingsId: 'order-list',
35
+
36
+ // URL hash parameter names (enables deep-linking and back/forward)
37
+ idHashParam: 'id',
38
+ tabHashParam: 'qtab',
39
+
40
+ // Async function that fetches full details for a list item.
41
+ // Results are memoized per item identity.
42
+ details: useCallback(
43
+ (item: OrderListItem) => fetchOrderDetails$(item.id),
44
+ [],
45
+ ),
46
+
47
+ // Render the list component. `thru` contains bindings that wire
48
+ // the omnitable events to queue state (items, selection, clicks).
49
+ // Spread them onto your list component.
50
+ list: (thru, { onRef }) =>
51
+ html`<order-list-core ${spread(thru)} ${ref(onRef)}></order-list-core>`,
52
+
53
+ // Render the detail view. `nav` contains prev/next buttons --
54
+ // place them in a slot or wherever navigation belongs in your view.
55
+ view: (thru, { nav }) =>
56
+ html`<order-view-core ${spread(thru)}>${nav}</order-view-core>`,
57
+
58
+ // Render a loading skeleton shown while `details` resolves.
59
+ loader: (thru, { nav }) =>
60
+ html`<order-view-skeleton ${spread(thru)}>${nav}</order-view-skeleton>`,
61
+ });
62
+
63
+ customElements.define('order-list-queue', component(OrderListQueue));
64
+ ```
65
+
66
+ ### `queue()` props
67
+
68
+ | Prop | Type | Default | Description |
69
+ |---|---|---|---|
70
+ | `heading` | `string` | -- | Title displayed above the tabs |
71
+ | `settingsId` | `string?` | -- | Key for persisting omnitable column settings |
72
+ | `idHashParam` | `string` | `'qid'` | URL hash param for the selected item ID |
73
+ | `tabHashParam` | `string` | `'qtab'` | URL hash param for the active tab |
74
+ | `details` | `(item: I) => PromiseLike<D>` | -- | Fetches full details for a list item |
75
+ | `api` | `(id: string, item: I) => string` | -- | Alternative to `details`: returns a URL, queue fetches JSON internally |
76
+ | `list` | `(thru, props) => TemplateResult` | -- | Renders the list component |
77
+ | `view` | `(thru, props) => TemplateResult` | -- | Renders the detail view |
78
+ | `loader` | `(thru, props) => TemplateResult` | -- | Renders the loading skeleton |
79
+ | `pagination` | `Pagination?` | -- | Server-side pagination config |
80
+ | `fallback` | `string?` | -- | Default tab when none is set in URL hash |
81
+ | `split` | `SplitOpts?` | -- | Split.js configuration (sizes, min sizes) |
82
+ | `afterHeading` | `unknown?` | -- | Extra content rendered after the heading |
83
+
84
+ Use **`details`** when you control the fetch (most common). Use **`api`** when you want the queue to handle fetching via URL.
85
+
86
+ ### The `thru` bindings
87
+
88
+ 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:
89
+
90
+ **For `list`:**
91
+ - `.settingsId`, `.exposedParts` -- table configuration
92
+ - `@visible-items-changed`, `@selected-items-changed`, `@total-available-changed` -- sync items/selection back to queue
93
+ - `@omnitable-item-click` -- item click handler
94
+ - `@async-simple-action` -- action completion handler
95
+
96
+ **For `view`:**
97
+ - `.item` -- the current detail item (list item or resolved detail)
98
+ - `.hideActions` -- whether to hide actions (true when items are selected in split mode)
99
+ - `@async-simple-action` -- action completion handler
100
+
101
+ Spread these onto your component with `${spread(thru)}` from `@open-wc/lit-helpers`.
102
+
103
+ ## View modes
104
+
105
+ The queue provides three tab-based view modes:
106
+
107
+ | Mode | Tab name | Behavior |
108
+ |---|---|---|
109
+ | **List** | `overview` | Full-width data table (`cosmoz-omnitable`). The default view. |
110
+ | **Split** | `split` | Side-by-side: resizable list on the left, detail view on the right. Disabled on mobile. |
111
+ | **Queue** | `queue` | Full-screen detail view with prev/next navigation and keyboard arrow keys. |
112
+
113
+ The active tab is synced to the URL hash (e.g. `#qtab=split&id=abc-123`), enabling deep-linking and browser back/forward navigation.
114
+
115
+ On mobile viewports, the split tab is disabled and falls back to queue mode automatically.
116
+
117
+ ## List module
118
+
119
+ The `@neovici/cosmoz-queue/list` entry point provides a managed `cosmoz-omnitable` with built-in state management, data fetching, pagination, and action rendering.
120
+
121
+ ### `listCore()`
122
+
123
+ The high-level factory that combines `useListCore()` + `renderListCore()`:
124
+
125
+ ```ts
126
+ import { itemClick } from '@neovici/cosmoz-queue';
127
+ import { column, listCore, style } from '@neovici/cosmoz-queue/list';
128
+ import { component, html } from '@pionjs/pion';
129
+ import { t } from 'i18next';
130
+ import type { OrderListItem } from './types';
131
+
132
+ interface Props {
133
+ exposedParts: string;
134
+ api: (params: { pathLocator: string }) => Promise<{
135
+ items: OrderListItem[];
136
+ metaData: { totalAvailable: number };
137
+ }>;
138
+ }
139
+
140
+ const OrderListCore = ({ exposedParts, api }: Props) => {
141
+ return listCore({
142
+ // Unique key for persisting column settings
143
+ settingsId: 'order-list-core',
144
+
145
+ // CSS parts to expose for external styling
146
+ exposedParts,
147
+
148
+ // When false, omnitable applies its own local filtering
149
+ noLocal: false,
150
+
151
+ // Column definitions. The tuple is [factory, deps] -- same pattern
152
+ // as useMemo. The factory returns an object where each key becomes
153
+ // a column name.
154
+ columns: [
155
+ () => ({
156
+ title: column({
157
+ render: ({ name }) =>
158
+ html`<cosmoz-omnitable-column
159
+ name="${name}"
160
+ title=${t('Title')}
161
+ flex="2"
162
+ .renderCell=${(
163
+ _col: unknown,
164
+ { item, index }: { item: OrderListItem; index: number },
165
+ ) =>
166
+ html`<a
167
+ @click="${itemClick({
168
+ index,
169
+ activate: ['split', 'queue'],
170
+ })}"
171
+ >${item.title}</a
172
+ >`}
173
+ ></cosmoz-omnitable-column>`,
174
+ }),
175
+
176
+ status: column({
177
+ render: ({ name }) =>
178
+ html`<cosmoz-omnitable-column-autocomplete
179
+ name="${name}"
180
+ title=${t('Status')}
181
+ value-path="status.name"
182
+ ></cosmoz-omnitable-column-autocomplete>`,
183
+ }),
184
+
185
+ createdAt: column({
186
+ render: ({ name }) =>
187
+ html`<cosmoz-omnitable-column-date
188
+ name="${name}"
189
+ title=${t('Date created')}
190
+ ></cosmoz-omnitable-column-date>`,
191
+ }),
192
+ }),
193
+ [], // dependency array (empty = compute once)
194
+ ],
195
+
196
+ // Reactive query parameters. Recomputed when deps change,
197
+ // which triggers a new data fetch.
198
+ params: [() => ({ pathLocator: '/some/path' }), []],
199
+
200
+ // Data fetcher. Receives { params, page, pageSize }.
201
+ // Must return { items, total }.
202
+ list$: [
203
+ ({ params }) =>
204
+ api(params).then((r) => ({
205
+ items: r.items ?? [],
206
+ total: r.metaData?.totalAvailable ?? 0,
207
+ })),
208
+ [api],
209
+ ],
210
+
211
+ // Batch actions shown when items are selected
212
+ actions: [myAction()],
213
+ });
214
+ };
215
+
216
+ customElements.define(
217
+ 'order-list-core',
218
+ component(OrderListCore, { styleSheets: [style] }),
219
+ );
220
+ ```
221
+
222
+ ### `listCore()` props
223
+
224
+ | Prop | Type | Description |
225
+ |---|---|---|
226
+ | `settingsId` | `string` | Persistence key for column settings |
227
+ | `exposedParts` | `string?` | CSS `exportparts` value |
228
+ | `noLocal` | `boolean` | Skip omnitable local filtering (default: `true`) |
229
+ | `columns` | `[() => Columns, deps[]]` | Column definitions (memoized) |
230
+ | `params` | `[(opts) => Params, deps[]]` | Query parameters (memoized). The `opts` include `{ filters, descending, sortOn, columns }` |
231
+ | `list$` | `[(props) => Promise<{ items, total }>, deps[]]` | Data fetcher. Receives `{ params, page, pageSize }` |
232
+ | `pageSize` | `number?` | Items per page for "load more" (default: `50`) |
233
+ | `actions` | `Action[]?` | Batch actions |
234
+ | `content` | `(opts) => Renderable` | Extra content inside the omnitable |
235
+ | `hashParam` | `string?` | URL hash param for omnitable state |
236
+ | `csvFilename` | `string?` | Filename for CSV export |
237
+ | `enabledColumns` | `string[]?` | Initially visible columns |
238
+
239
+ ### `column()`
240
+
241
+ Type-safe column definition helper:
242
+
243
+ ```ts
244
+ import { column } from '@neovici/cosmoz-queue/list';
245
+
246
+ const myColumn = column({
247
+ // Optional ordering hint
248
+ order: 1,
249
+
250
+ // Optional sort key
251
+ sort: 'name',
252
+
253
+ // Optional filter transform: receives the raw filter value,
254
+ // returns the value sent to the API
255
+ filter: (value: string) => ({ name: value }),
256
+
257
+ // Render function -- receives { name } where name is the object key
258
+ render: ({ name }) =>
259
+ html`<cosmoz-omnitable-column
260
+ name="${name}"
261
+ title="Name"
262
+ ></cosmoz-omnitable-column>`,
263
+ });
264
+ ```
265
+
266
+ ### `useListCore()` / `useListCoreState()`
267
+
268
+ For cases where you need more control over the list without `renderListCore()`:
269
+
270
+ - **`useListCore(props)`** -- manages columns, params, data fetching, pagination, and form dialog state. Returns `{ data$, columns, loadMore, dialog, open, ...state }`.
271
+ - **`useListCoreState(defaults?)`** -- lower-level state: `filters`, `sortOn`, `descending`, `groupOn`, `selectedItems`, plus their setters and `setTotalAvailable`.
272
+
273
+ ## Actions module
274
+
275
+ The `@neovici/cosmoz-queue/actions` entry point provides a declarative system for defining user actions that open form dialogs.
276
+
277
+ ### Defining an action
278
+
279
+ ```ts
280
+ import { action, Action, defaultButton } from '@neovici/cosmoz-queue/actions';
281
+ import { t } from 'i18next';
282
+ import { when } from 'lit-html/directives/when.js';
283
+
284
+ // action() is an identity function that provides type inference
285
+ export const approveOrder = action<OrderItem, ApproveFields>({
286
+ // Label shown on the button
287
+ title: () => t('Approve'),
288
+
289
+ // Optional: filter which items this action applies to.
290
+ // If provided, non-applicable items are excluded and the button
291
+ // shows a count badge like "Approve (3/5)".
292
+ applicable: (item) => item.status === 'pending',
293
+
294
+ // Optional: custom button renderer. Defaults to `defaultButton()`.
295
+ // Return `nothing` to hide the button conditionally.
296
+ button: (opts) =>
297
+ when(opts.items.length >= 1, () => defaultButton(opts)),
298
+
299
+ // Dialog configuration. Opens a `cosmoz-form` dialog.
300
+ // `items` contains only the applicable items.
301
+ dialog: ({ items, title }) => ({
302
+ heading: title,
303
+ description: t('Approve the selected orders'),
304
+ fields: [
305
+ // cosmoz-form field definitions
306
+ ],
307
+ initial: {},
308
+ onSave: async (values) => {
309
+ await approveOrders(items.map((i) => i.id), values);
310
+ },
311
+ }),
312
+ });
313
+ ```
314
+
315
+ ### Rendering actions
316
+
317
+ Actions are rendered automatically by `listCore()` when passed as the `actions` prop. For manual rendering (e.g. in a detail view's bottom bar):
318
+
319
+ ```ts
320
+ import { renderActions } from '@neovici/cosmoz-queue/actions';
321
+ import { approveOrder } from './actions';
322
+
323
+ // In a view component:
324
+ const bottomBar = renderActions({ items: [currentItem], open })([
325
+ approveOrder,
326
+ ]);
327
+ ```
328
+
329
+ ### Action types
330
+
331
+ ```ts
332
+ interface Action<TItem, TDialog> {
333
+ title: () => string;
334
+ applicable?: (item: TItem) => boolean;
335
+ button?: (opts: Action & ActionOpts) => unknown;
336
+ dialog: (opts: DialogOpts) => Dialogable | Promise<Dialogable>;
337
+ }
338
+
339
+ interface ActionOpts<TItem> {
340
+ items: TItem[];
341
+ open: (dialog: Dialogable) => void;
342
+ slot?: string;
343
+ }
344
+ ```
345
+
346
+ ## Advanced: composing with hooks
347
+
348
+ When `queue()` is too opinionated, use the individual hooks directly.
349
+
350
+ ### `useQueue()` + `renderQueue()`
351
+
352
+ ```ts
353
+ import { useQueue, renderQueue, renderNav } from '@neovici/cosmoz-queue';
354
+
355
+ const MyQueue = ({ heading }: { heading: string }) => {
356
+ const {
357
+ index, mobile, tabnav, items, setItems,
358
+ setSelected, setTotalAvailable, totalAvailable,
359
+ onItemClick, nav,
360
+ } = useQueue<MyItem>({
361
+ idHashParam: 'id',
362
+ tabHashParam: 'tab',
363
+ });
364
+
365
+ return renderQueue({
366
+ heading,
367
+ mobile,
368
+ index,
369
+ items,
370
+ tabnav,
371
+ totalAvailable,
372
+ nav,
373
+ list: html`<my-list
374
+ @visible-items-changed=${updateWith(setItems)}
375
+ @selected-items-changed=${updateWith(setSelected)}
376
+ @total-available-changed=${updateWith(setTotalAvailable)}
377
+ @omnitable-item-click=${onItemClick}
378
+ ></my-list>`,
379
+ renderItem: ({ item, nav }) =>
380
+ html`<my-view .item=${item}>${nav}</my-view>`,
381
+ renderLoader: ({ item, nav }) =>
382
+ html`<my-skeleton .item=${item}>${nav}</my-skeleton>`,
383
+ });
384
+ };
385
+ ```
386
+
387
+ ### Individual hooks
388
+
389
+ | Hook | Import | Purpose |
390
+ |---|---|---|
391
+ | `useTabs({ items, hashParam, mobile, fallback })` | `@neovici/cosmoz-queue` | Manages overview/split/queue tabs. Returns `{ tabnav, activeTab }`. |
392
+ | `useDataNav(items, opts)` | `@neovici/cosmoz-queue` | Item navigation with prev/next, URL hash sync. Returns `{ item, setItem, next, prev, forward, index }`. |
393
+ | `useSplit({ activeTab, ...splitOpts })` | `@neovici/cosmoz-queue` | Initializes Split.js when in split mode. |
394
+ | `useListState()` | `@neovici/cosmoz-queue` | Creates `items`, `selected`, `totalAvailable` state with setters. |
395
+ | `useListSSE({ entity, params, list$ })` | `@neovici/cosmoz-queue` | Subscribes to Server-Sent Events (`cosmoz-${entity}-updated`) for real-time list updates. |
396
+ | `useFetchActions({ pathLocator, selected, api })` | `@neovici/cosmoz-queue` | Fetches available actions for selected items from an API. Returns `{ actions, actionRows, actionsFetching }`. |
397
+ | `useAsyncAction(nav)` | `@neovici/cosmoz-queue` | Handles async action completion with automatic item removal from the list. Returns `{ listRef, onAsyncSimpleAction }`. |
398
+ | `usePagination()` | `@neovici/cosmoz-queue` | URL hash-based page state. Returns `{ page, onPage }`. |
399
+
400
+ ## Utilities
401
+
402
+ ### Fetch helpers (`@neovici/cosmoz-queue/util/fetch`)
403
+
404
+ Pre-configured `fetch` wrapper with CORS and credentials:
405
+
406
+ ```ts
407
+ import {
408
+ fetch,
409
+ setBaseInit,
410
+ handleJSON,
411
+ RequestError,
412
+ } from '@neovici/cosmoz-queue/util/fetch';
413
+
414
+ // Configure default headers (call once at app startup)
415
+ setBaseInit({
416
+ headers: { 'X-Custom-Header': 'value' },
417
+ // Or use dynamic headers:
418
+ getHeaders: () => ({ Authorization: `Bearer ${getToken()}` }),
419
+ });
420
+
421
+ // fetch() includes mode: 'cors', credentials: 'include' by default
422
+ const response = await fetch('/api/orders');
423
+ const data = await handleJSON(response);
424
+ ```
425
+
426
+ `RequestError` extends `Error` with `.response` and `.data` properties for structured error handling.
427
+
428
+ ### `itemClick()`
429
+
430
+ Makes list cells clickable, dispatching `omnitable-item-click` events that the queue listens for:
431
+
432
+ ```ts
433
+ import { itemClick } from '@neovici/cosmoz-queue';
434
+
435
+ // In an omnitable cell renderer:
436
+ html`<a @click="${itemClick({ index, activate: ['split', 'queue'] })}">
437
+ ${item.title}
438
+ </a>`;
439
+ ```
440
+
441
+ The `activate` option specifies which tab to switch to. The queue picks the first non-disabled tab from the array.
442
+
443
+ ### Other utilities
444
+
445
+ | Function | Description |
446
+ |---|---|
447
+ | `getItems(items, selected)` | Returns `selected` if non-empty, otherwise `items` |
448
+ | `touch(list, id)` | Forces an omnitable item refresh by replacing it with a shallow copy |
449
+
450
+ ## Architecture
451
+
452
+ ```
453
+ queue() factory
454
+ |
455
+ +---> useQueue() State orchestration
456
+ | |
457
+ | +---> useListState() items, selected, totalAvailable
458
+ | +---> useTabs() overview | split | queue
459
+ | +---> useDataNav() current item, prev/next, URL hash
460
+ | +---> useKeyNav() arrow key navigation
461
+ | +---> useSplit() Split.js initialization
462
+ | +---> useUpdates() list-item-remove events
463
+ |
464
+ +---> useAsyncAction() Post-action item removal
465
+ |
466
+ +---> renderQueue() Template composition
467
+ |
468
+ +---> cosmoz-tabs-next Tab bar (List / Split / Queue)
469
+ +---> renderStats() "3-5 of 120"
470
+ +---> renderPagination() Page prev/next
471
+ +---> <div.split>
472
+ +---> list cosmoz-omnitable (user-provided)
473
+ +---> cosmoz-slider Animated detail view
474
+ +---> renderSlide() --> renderView()
475
+ |
476
+ +---> details() Async fetch
477
+ +---> renderItem or renderLoader
478
+ ```
479
+
480
+ ## Entry points
481
+
482
+ | Import path | Description |
483
+ |---|---|
484
+ | `@neovici/cosmoz-queue` | Main: `queue()`, `useQueue()`, `renderQueue()`, navigation hooks, SSE, utilities |
485
+ | `@neovici/cosmoz-queue/actions` | `action()`, `renderActions()`, `defaultButton()`, `actionCount()` |
486
+ | `@neovici/cosmoz-queue/list` | `listCore()`, `column()`, `useListCore()`, `useListCoreState()`, `renderListCore()` |
487
+ | `@neovici/cosmoz-queue/list/more` | `useMore()` -- progressive "load more" pagination |
488
+ | `@neovici/cosmoz-queue/list/more/render` | `renderLoadMore()` -- "Load more" button |
489
+ | `@neovici/cosmoz-queue/util/fetch` | `fetch()`, `setBaseInit()`, `handleJSON()`, `RequestError` |
490
+
491
+ ## Deprecation notices
492
+
493
+ ### `api` property → `details`
494
+
495
+ The `api` property on `useQueue()` / `queue()` is **deprecated** and will be
496
+ removed in v2.0.0. Use `details` instead:
497
+
498
+ ```ts
499
+ // Before (deprecated)
500
+ queue({ api: (id, item) => apiUrl(`items/${id}`) })
501
+
502
+ // After
503
+ queue({ details: (item) => fetch(apiUrl(`items/${item.id}`)).then(r => r.json()) })
504
+ ```
505
+
506
+ See [Migration guide](docs/migration-api-to-details.md) for more patterns.
507
+
508
+ ## License
509
+
510
+ [Apache-2.0](LICENSE)
@@ -5,6 +5,25 @@ interface Opts<I> extends ReturnType<typeof useListState<I>>, Pick<UseTabsOption
5
5
  tabHashParam?: string;
6
6
  idHashParam?: string;
7
7
  onActivate?: (name: string) => void;
8
+ /**
9
+ * @deprecated Use the `details` property instead. The `api` property will be removed in v2.0.0.
10
+ *
11
+ * The `api` property uses deprecated utilities and is less flexible than `details`.
12
+ * With `details`, you have full control over the fetch operation and can return any Promise.
13
+ *
14
+ * Migration example:
15
+ * ```ts
16
+ * // Before:
17
+ * useQueue({
18
+ * api: (id, item) => apiUrl(`api/items/${id}`)
19
+ * })
20
+ *
21
+ * // After:
22
+ * useQueue({
23
+ * details: (item) => fetch(apiUrl(`api/items/${item.id}`)).then(res => res.json())
24
+ * })
25
+ * ```
26
+ */
8
27
  api?: (id: string, item: I) => string;
9
28
  id?: (i: I) => string;
10
29
  split?: SplitOpts;
@@ -1 +1 @@
1
- {"version":3,"file":"use-queue.d.ts","sourceRoot":"","sources":["../../src/queue/use-queue.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAiB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAClD,OAAgB,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,YAAY,CAAC;AA8BhE,UAAU,IAAI,CAAC,CAAC,CACf,SACC,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,EAClC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;IACtC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,SAAS,CAAC;CAClB;AAED,QAAA,MAAM,QAAQ,GAAI,CAAC,EAAE,8EASlB,IAAI,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA2CH,KAAK;oBAtBA,MAAM;;;;;;;;;;;;;;;;;;CAkDjB,CAAC;AAEF,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,IAAI,CAC7B,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACjC,MAAM,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,CACxC,CAAC;yBAEc,CAAC,EAAE,MAAM,QAAQ,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAnC7B,KAAK;oBAtBA,MAAM;;;;;;;;;;;;;;;;;;;AAyDlB,wBACgD"}
1
+ {"version":3,"file":"use-queue.d.ts","sourceRoot":"","sources":["../../src/queue/use-queue.ts"],"names":[],"mappings":"AAQA,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC1C,OAAiB,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAClD,OAAgB,EAAE,OAAO,IAAI,cAAc,EAAE,MAAM,YAAY,CAAC;AAwDhE,UAAU,IAAI,CAAC,CAAC,CACf,SACC,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,EAClC,IAAI,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC;IACpC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;;;;;;;;;;;;;;;OAkBG;IACH,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC;IACtC,EAAE,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC,KAAK,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,SAAS,CAAC;CAClB;AAED,QAAA,MAAM,QAAQ,GAAI,CAAC,EAAE,8EASlB,IAAI,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBA2CH,KAAK;oBAtBA,MAAM;;;;;;;;;;;;;;;;;;CAkDjB,CAAC;AAEF,MAAM,MAAM,QAAQ,CAAC,CAAC,IAAI,IAAI,CAC7B,UAAU,CAAC,OAAO,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EACjC,MAAM,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC,CAAC,CAAC,CACxC,CAAC;yBAEc,CAAC,EAAE,MAAM,QAAQ,CAAC,CAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;qBAnC7B,KAAK;oBAtBA,MAAM;;;;;;;;;;;;;;;;;;;AAyDlB,wBACgD"}
@@ -12,6 +12,17 @@ import useUpdates from './use-updates';
12
12
  import { getItems, normalizeHeaders } from './util';
13
13
  const _id = (item) => item['id'];
14
14
  const useQNav = ({ id, api, items, ...thru }) => {
15
+ // Deprecation warning
16
+ /* eslint-disable no-console, no-template-curly-in-string */
17
+ if (api && typeof console !== 'undefined' && console.warn) {
18
+ console.warn('[cosmoz-queue] DEPRECATED: The `api` property is deprecated and will be removed in v2.0.0. ' +
19
+ 'Please migrate to the `details` property for better control and performance.\n\n' +
20
+ 'Migration guide:\n' +
21
+ ' Before: api: (id, item) => apiUrl(\'api/items/${id}\')\n' +
22
+ ' After: details: (item) => fetch(apiUrl(\'api/items/${item.id}\')).then(res => res.json())\n\n' +
23
+ 'See: https://github.com/Neovici/cosmoz-queue#migration-from-api-to-details');
24
+ }
25
+ /* eslint-enable no-console, no-template-curly-in-string */
15
26
  const details = useMemo(() => api && memoize((item) => json(api(id(item), item))), [id, api]), pass = useDataNav(items, {
16
27
  ...thru,
17
28
  id,
@@ -26,7 +26,7 @@ export declare const setBaseInit: <T extends BaseInitOptions>(init: T) => Partia
26
26
  headers: {};
27
27
  };
28
28
  export declare const fetch: (url: string, opts?: RequestInit) => Promise<Response>;
29
- export declare const handleJSON: (res: Response) => Promise<any> | "";
29
+ export declare const handleJSON: (res: Response) => "" | Promise<any>;
30
30
  /**
31
31
  * @deprecated
32
32
  */
@@ -1 +1 @@
1
- {"version":3,"file":"path.test.d.ts","sourceRoot":"","sources":["../../../src/util/test/path.test.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"path.test.d.ts","sourceRoot":"","sources":["../../src/util/path.test.ts"],"names":[],"mappings":""}
@@ -1,84 +1,79 @@
1
- import { assert } from '@open-wc/testing';
2
- import { get, normalize, split } from '../path';
1
+ import { describe, expect, it } from 'vitest';
2
+ import { get, normalize, split } from './path';
3
3
  describe('path', () => {
4
4
  describe('normalize', () => {
5
5
  it('returns string path as-is', () => {
6
- assert.equal(normalize('foo.bar.0.baz'), 'foo.bar.0.baz');
6
+ expect(normalize('foo.bar.0.baz')).toBe('foo.bar.0.baz');
7
7
  });
8
8
  it('returns empty string as-is', () => {
9
- assert.equal(normalize(''), '');
9
+ expect(normalize('')).toBe('');
10
10
  });
11
11
  it('converts array path to flattened string', () => {
12
- assert.equal(normalize(['foo.bar', 0, 'baz']), 'foo.bar.0.baz');
12
+ expect(normalize(['foo.bar', 0, 'baz'])).toBe('foo.bar.0.baz');
13
13
  });
14
14
  it('handles array with single element', () => {
15
- assert.equal(normalize(['foo']), 'foo');
15
+ expect(normalize(['foo'])).toBe('foo');
16
16
  });
17
17
  it('handles array with dotted strings and numbers', () => {
18
- assert.equal(normalize(['a.b', 1, 'c.d', 2]), 'a.b.1.c.d.2');
18
+ expect(normalize(['a.b', 1, 'c.d', 2])).toBe('a.b.1.c.d.2');
19
19
  });
20
20
  it('handles empty array', () => {
21
- assert.equal(normalize([]), '');
21
+ expect(normalize([])).toBe('');
22
22
  });
23
23
  });
24
24
  describe('split', () => {
25
25
  it('splits string path into array', () => {
26
- assert.deepEqual(split('foo.bar.0.baz'), ['foo', 'bar', '0', 'baz']);
26
+ expect(split('foo.bar.0.baz')).toEqual(['foo', 'bar', '0', 'baz']);
27
27
  });
28
28
  it('splits array path into flat array', () => {
29
- assert.deepEqual(split(['foo.bar', 0, 'baz']), [
30
- 'foo',
31
- 'bar',
32
- '0',
33
- 'baz',
34
- ]);
29
+ expect(split(['foo.bar', 0, 'baz'])).toEqual(['foo', 'bar', '0', 'baz']);
35
30
  });
36
31
  it('handles single segment string', () => {
37
- assert.deepEqual(split('foo'), ['foo']);
32
+ expect(split('foo')).toEqual(['foo']);
38
33
  });
39
34
  it('handles array with single element', () => {
40
- assert.deepEqual(split(['foo']), ['foo']);
35
+ expect(split(['foo'])).toEqual(['foo']);
41
36
  });
42
37
  it('handles empty string', () => {
43
- assert.deepEqual(split(''), ['']);
38
+ expect(split('')).toEqual(['']);
44
39
  });
45
40
  });
46
41
  describe('get', () => {
47
42
  it('retrieves nested value with string path', () => {
48
43
  const obj = { foo: { bar: { baz: 'value' } } };
49
- assert.equal(get(obj, 'foo.bar.baz'), 'value');
44
+ expect(get(obj, 'foo.bar.baz')).toBe('value');
50
45
  });
51
46
  it('retrieves nested value with array path', () => {
52
47
  const obj = { foo: { bar: { baz: 'value' } } };
53
- assert.equal(get(obj, ['foo', 'bar', 'baz']), 'value');
48
+ expect(get(obj, ['foo', 'bar', 'baz'])).toBe('value');
54
49
  });
55
50
  it('retrieves nested value with dotted string in array path', () => {
56
51
  const obj = { foo: { bar: { baz: 'value' } } };
57
- assert.equal(get(obj, ['foo.bar', 'baz']), 'value');
52
+ expect(get(obj, ['foo.bar', 'baz'])).toBe('value');
58
53
  });
59
54
  it('retrieves array element by index', () => {
60
55
  const obj = { items: ['a', 'b', 'c'] };
61
- assert.equal(get(obj, 'items.1'), 'b');
56
+ expect(get(obj, 'items.1')).toBe('b');
62
57
  });
63
58
  it('retrieves array element with array path', () => {
64
59
  const obj = { items: ['a', 'b', 'c'] };
65
- assert.equal(get(obj, ['items', 0]), 'a');
60
+ expect(get(obj, ['items', 0])).toBe('a');
66
61
  });
67
62
  it('returns undefined for non-existent path', () => {
68
63
  const obj = { foo: { bar: 'value' } };
69
- assert.equal(get(obj, 'foo.baz.qux'), undefined);
64
+ expect(get(obj, 'foo.baz.qux')).toBeUndefined();
70
65
  });
71
66
  it('returns undefined when intermediate property is undefined', () => {
72
67
  const obj = { foo: undefined };
73
- assert.equal(get(obj, 'foo.bar'), undefined);
68
+ expect(get(obj, 'foo.bar')).toBeUndefined();
74
69
  });
75
70
  it('returns undefined when intermediate property is null', () => {
76
71
  const obj = { foo: null };
77
- assert.equal(get(obj, 'foo.bar'), undefined);
72
+ expect(get(obj, 'foo.bar')).toBeUndefined();
78
73
  });
79
74
  it('returns undefined with empty string path', () => {
80
75
  const obj = { foo: 'bar' };
81
- assert.equal(get(obj, ''), undefined);
76
+ expect(get(obj, '')).toBeUndefined();
82
77
  });
83
78
  it('handles complex nested structure', () => {
84
79
  const obj = {
@@ -87,13 +82,13 @@ describe('path', () => {
87
82
  { name: 'Bob', address: { city: 'LA' } },
88
83
  ],
89
84
  };
90
- assert.equal(get(obj, 'users.1.address.city'), 'LA');
85
+ expect(get(obj, 'users.1.address.city')).toBe('LA');
91
86
  });
92
87
  it('returns undefined for null root', () => {
93
- assert.equal(get(null, 'foo'), undefined);
88
+ expect(get(null, 'foo')).toBeUndefined();
94
89
  });
95
90
  it('returns undefined for undefined root', () => {
96
- assert.equal(get(undefined, 'foo'), undefined);
91
+ expect(get(undefined, 'foo')).toBeUndefined();
97
92
  });
98
93
  });
99
94
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neovici/cosmoz-queue",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "description": "A reusable queue component for master-detail views with list, split, and queue modes",
5
5
  "keywords": [
6
6
  "web-components",
@@ -29,8 +29,10 @@
29
29
  "lint": "tsc && eslint --cache .",
30
30
  "build": "tsc -p tsconfig.build.json",
31
31
  "start": "wds",
32
- "test": "wtr --coverage",
33
- "test:watch": "wtr --watch",
32
+ "test": "vitest --run",
33
+ "test:unit": "vitest --project=unit --run",
34
+ "test:storybook": "vitest --project=storybook --run",
35
+ "test:watch": "vitest",
34
36
  "check:duplicates": "check-duplicate-components",
35
37
  "dev": "npm run storybook:start",
36
38
  "storybook:start": "storybook dev -p 8000",
@@ -91,26 +93,27 @@
91
93
  "@commitlint/config-conventional": "^20.0.0",
92
94
  "@eslint/eslintrc": "^2.0.0",
93
95
  "@neovici/cfg": "^2.8.0",
94
- "@neovici/testing": "^2.0.0",
95
- "@open-wc/testing": "^4.0.0",
96
- "@open-wc/testing-helpers": "^3.0.1",
96
+ "@neovici/testing": "^2.2.0",
97
97
  "@semantic-release/changelog": "^6.0.0",
98
98
  "@semantic-release/git": "^10.0.0",
99
99
  "@storybook/addon-links": "^10.0.0",
100
+ "@storybook/addon-vitest": "^10.0.0",
100
101
  "@storybook/web-components": "^10.0.0",
101
102
  "@storybook/web-components-vite": "^10.0.0",
102
- "@types/mocha": "^10.0.6",
103
103
  "@types/node": "^22.10.2",
104
+ "@types/react": "^19.2.13",
104
105
  "@types/split.js": "^1.6.0",
105
- "@web/dev-server-esbuild": "^1.0.4",
106
- "@web/test-runner-playwright": "^0.11.1",
106
+ "@vitest/browser": "^4.0.0",
107
+ "@vitest/browser-playwright": "^4.0.0",
107
108
  "esbuild": "^0.27.0",
108
109
  "http-server": "^14.1.1",
109
110
  "husky": "^9.0.11",
111
+ "jsdom": "^26.0.0",
112
+ "playwright": "^1.52.0",
110
113
  "semantic-release": "^25.0.0",
111
- "sinon": "^19.0.0",
112
114
  "storybook": "^10.0.0",
113
- "typescript": "^5.4.3"
115
+ "typescript": "^5.4.3",
116
+ "vitest": "^4.0.0"
114
117
  },
115
118
  "overrides": {
116
119
  "conventional-changelog-conventionalcommits": ">= 8.0.0",
@@ -1,2 +0,0 @@
1
- export const snapshots: typeof snapshots;
2
- //# sourceMappingURL=render.test.snap.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"render.test.snap.d.ts","sourceRoot":"","sources":["../../../../src/queue/test/__snapshots__/render.test.snap.js"],"names":[],"mappings":"AACA,yCAA4B"}
@@ -1,64 +0,0 @@
1
- /* @web/test-runner snapshot v1 */
2
- export const snapshots = {};
3
- snapshots['queue > render renderNav'] = `<button
4
- class="button-nav prev"
5
- disabled=""
6
- slot="extra"
7
- >
8
- </button>
9
- `;
10
- /* end snapshot queue > render renderNav */
11
- snapshots['queue > render renderPagination'] = `<div class="tabn-pagination">
12
- <button class="button-page page-prev">
13
- </button>
14
- <button class="button-page page-next">
15
- </button>
16
- </div>
17
- `;
18
- /* end snapshot queue > render renderPagination */
19
- snapshots['queue > render renderNav'] = `<button
20
- class="button-nav prev"
21
- disabled=""
22
- name="Previous item"
23
- slot="extra"
24
- >
25
- </button>
26
- `;
27
- /* end snapshot queue > render renderNav */
28
- snapshots['queue > render renderPagination'] = `<div class="tabn-pagination">
29
- <button
30
- class="button-page page-prev"
31
- name="Previous page"
32
- >
33
- </button>
34
- <button
35
- class="button-page page-next"
36
- name="Next page"
37
- >
38
- </button>
39
- </div>
40
- `;
41
- /* end snapshot queue > render renderPagination */
42
- snapshots['queue > render renderNav'] = `<button
43
- class="button-nav prev"
44
- disabled=""
45
- slot="extra"
46
- title=""
47
- >
48
- </button>
49
- `;
50
- /* end snapshot queue > render renderNav */
51
- snapshots['queue > render renderPagination'] = `<div class="tabn-pagination">
52
- <button
53
- class="button-page page-prev"
54
- title=""
55
- >
56
- </button>
57
- <button
58
- class="button-page page-next"
59
- title=""
60
- >
61
- </button>
62
- </div>
63
- `;
64
- /* end snapshot queue > render renderPagination */
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=item-click.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"item-click.test.d.ts","sourceRoot":"","sources":["../../../src/queue/test/item-click.test.ts"],"names":[],"mappings":""}
@@ -1,28 +0,0 @@
1
- import { assert, oneEvent } from '@open-wc/testing';
2
- import { oneDefaultPreventedEvent } from '@open-wc/testing-helpers';
3
- import { itemClick } from '../item-click';
4
- describe('item-click', () => {
5
- it('fires event', async () => {
6
- const el = document.createElement('div');
7
- el.addEventListener('click', itemClick({ index: 2, activate: 'queue' }));
8
- setTimeout(() => el.click());
9
- const { detail } = await oneEvent(el, 'omnitable-item-click');
10
- assert.equal(detail.index, 2);
11
- assert.equal(detail.activate, 'queue');
12
- });
13
- it('prevents default', async () => {
14
- const el = document.createElement('div');
15
- const ev = new MouseEvent('click');
16
- el.addEventListener('click', itemClick({ index: 3, activate: 'list' }));
17
- setTimeout(() => el.dispatchEvent(ev));
18
- const { detail } = await oneDefaultPreventedEvent(el, 'omnitable-item-click');
19
- assert.equal(detail.index, 3);
20
- assert.equal(detail.activate, 'list');
21
- });
22
- it('does not fire event', async () => {
23
- const el = document.createElement('div');
24
- const ev = new MouseEvent('click', { ctrlKey: true });
25
- el.addEventListener('click', itemClick({ index: 3, activate: 'list' }));
26
- setTimeout(() => el.dispatchEvent(ev));
27
- });
28
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=render.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"render.test.d.ts","sourceRoot":"","sources":["../../../src/queue/test/render.test.ts"],"names":[],"mappings":""}
@@ -1,27 +0,0 @@
1
- import { expect, fixture } from '@open-wc/testing';
2
- import { nothing } from 'lit-html';
3
- import { spy } from 'sinon';
4
- import { renderNav, renderPagination } from '../render';
5
- describe('queue > render', () => {
6
- it('renderNav', async () => {
7
- const el = await fixture(renderNav({}));
8
- await expect(el).dom.to.equalSnapshot();
9
- });
10
- it('renderPagination nothing', async () => {
11
- expect(renderPagination()).to.equal(nothing);
12
- });
13
- it('renderPagination', async () => {
14
- const onPage = spy();
15
- const el = await fixture(renderPagination({
16
- totalPages: 10,
17
- pageNumber: 3,
18
- onPage,
19
- }));
20
- await expect(el).dom.to.equalSnapshot();
21
- el.querySelector('.page-next')?.click();
22
- expect(onPage).to.have.been.calledWith(4);
23
- onPage.resetHistory();
24
- el.querySelector('.page-prev')?.click();
25
- expect(onPage).to.have.been.calledWith(2);
26
- });
27
- });
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=use-pref.test.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"use-pref.test.d.ts","sourceRoot":"","sources":["../../../src/queue/test/use-pref.test.ts"],"names":[],"mappings":""}
@@ -1,16 +0,0 @@
1
- import { renderHook } from '@neovici/testing';
2
- import { assert } from '@open-wc/testing';
3
- import { usePref } from '../use-pref';
4
- describe('use-pref', () => {
5
- it('default pref', async () => {
6
- const { result } = await renderHook(() => usePref('some', 'asdad'));
7
- assert.equal(result.current[0], 'asdad');
8
- });
9
- it('update pref', async () => {
10
- const { result, nextUpdate } = await renderHook(() => usePref('somethingelse'));
11
- assert.equal(result.current[0], undefined);
12
- setTimeout(() => result.current[1]('dads'));
13
- await nextUpdate();
14
- assert.equal(result.current[0], 'dads');
15
- });
16
- });