@kernhq/module-inventory 0.3.0 → 0.4.1

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.
Files changed (42) hide show
  1. package/README.md +7 -5
  2. package/dist/contract/models.d.ts +24 -1
  3. package/dist/contract/models.d.ts.map +1 -1
  4. package/dist/contract/models.js +35 -3
  5. package/dist/contract/models.js.map +1 -1
  6. package/dist/contract/router.d.ts +58 -2
  7. package/dist/contract/router.d.ts.map +1 -1
  8. package/dist/contract/router.js +29 -1
  9. package/dist/contract/router.js.map +1 -1
  10. package/dist/server/router.d.ts +64 -393
  11. package/dist/server/router.d.ts.map +1 -1
  12. package/dist/server/router.js +23 -1
  13. package/dist/server/router.js.map +1 -1
  14. package/dist/server/schema.d.ts.map +1 -1
  15. package/dist/server/schema.js +11 -0
  16. package/dist/server/schema.js.map +1 -1
  17. package/dist/server/services/categories.d.ts +110 -10
  18. package/dist/server/services/categories.d.ts.map +1 -1
  19. package/dist/server/services/categories.js +198 -13
  20. package/dist/server/services/categories.js.map +1 -1
  21. package/migrations/0008_category_order_unique.sql +71 -0
  22. package/migrations/meta/_journal.json +7 -0
  23. package/package.json +4 -3
  24. package/src/client/errors.test.ts +30 -0
  25. package/src/client/errors.ts +31 -3
  26. package/src/client/messages.ts +82 -19
  27. package/src/client/mock.test.ts +71 -1
  28. package/src/client/mock.ts +51 -12
  29. package/src/client/module.ts +19 -1
  30. package/src/client/reorder.test.ts +100 -0
  31. package/src/client/reorder.ts +79 -0
  32. package/src/client/sequence.test.ts +248 -0
  33. package/src/client/sequence.ts +185 -0
  34. package/src/client/settings/CategoriesSettings.svelte +430 -105
  35. package/src/contract/models.ts +36 -3
  36. package/src/contract/router.ts +29 -0
  37. package/src/module.test.ts +23 -0
  38. package/src/server/inventory.int.test.ts +545 -10
  39. package/src/server/migrations.test.ts +140 -2
  40. package/src/server/router.ts +25 -1
  41. package/src/server/schema.ts +11 -0
  42. package/src/server/services/categories.ts +221 -20
@@ -6,36 +6,62 @@ import {
6
6
  DropdownMenu,
7
7
  EmptyState,
8
8
  Field,
9
+ Icon,
9
10
  IconButton,
10
11
  Input,
11
12
  type MenuItem,
13
+ messageLocale,
12
14
  navigation,
13
15
  SettingsPage,
14
16
  SettingsSection,
15
17
  Skeleton,
16
18
  Switch,
17
19
  session,
18
- Table,
19
- TableCell,
20
- TableHeader,
21
- TableRow,
22
20
  toast,
23
21
  } from '@kernhq/ui'
24
22
  import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
23
+ import { untrack } from 'svelte'
24
+ import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action'
25
25
  import type { Category } from '../../contract/index.js'
26
26
  import { getInventoryApi } from '../api-instance.js'
27
27
  import { isolated } from '../bidi.js'
28
- import { errorMessage } from '../errors.js'
28
+ import { errorMessage, reasonOf } from '../errors.js'
29
29
  import { t } from '../i18n.js'
30
30
  import { INVENTORY_PERMISSIONS } from '../permissions.js'
31
31
  import { inventoryKeys } from '../query.js'
32
+ import { placementOf } from '../reorder.js'
33
+ import {
34
+ consider as considered,
35
+ finalize as finalized,
36
+ move as moved,
37
+ refused,
38
+ reseed,
39
+ type Sequence,
40
+ type Step,
41
+ saved,
42
+ seed,
43
+ start,
44
+ } from '../sequence.js'
32
45
 
33
46
  /**
34
- * How a workspace groups what it owns.
47
+ * How a workspace groups what it owns, and the order it groups them in.
35
48
  *
36
49
  * `assets.list` has taken a `categoryId` filter since the module existed and nothing could create a
37
50
  * category, so the filter had exactly one possible answer — this page is the other half of it.
38
51
  *
52
+ * **The order is dragged, not typed.** This page used to carry a *Position* field: a number box on
53
+ * the add-and-rename dialog, with a hint explaining that lower comes first and that two categories
54
+ * sharing a number fall back to their names. That is a database column with a form around it.
55
+ * Nobody arranges their filing by integer, the field invited the one state it then had to explain,
56
+ * and moving a category two places meant working out a number that would land it there. It is a
57
+ * list you drag now, and `categories.reorder` writes the whole sequence in one transaction.
58
+ *
59
+ * **A drag is a gesture, never a requirement.** It is unreachable by keyboard and by anybody who
60
+ * cannot hold a pointer steady, so every row carries move-up and move-down buttons that do exactly
61
+ * the same thing, and the result is spoken into a live region rather than moving in silence. Those
62
+ * buttons are the *only* keyboard route: the drag library ships one of its own, and shipping both
63
+ * left both half-working, so it is switched off — see `keepKeyboardOnTheButtons`.
64
+ *
39
65
  * **Nothing here deletes.** `assets.category_id` carries no foreign key, so removing a row would
40
66
  * leave every asset filed under it pointing at nothing: a blank column, and a timeline entry saying
41
67
  * the category changed *to* a name it can no longer print. Archiving takes it out of every picker
@@ -59,7 +85,7 @@ const canManage = $derived(session.can(INVENTORY_PERMISSIONS.categories))
59
85
  let showArchived = $state(false)
60
86
 
61
87
  /**
62
- * One query for every category, archived ones included, filtered here.
88
+ * One query for every category, archived ones included, split here.
63
89
  *
64
90
  * Two things come out of that. It is the key `AssetsPage` and `AssetDetailPanel` already use, so
65
91
  * arriving at this page after either of them costs no request — and, the reason it changed, it is
@@ -74,62 +100,240 @@ const categoriesQuery = createQuery(() => ({
74
100
  enabled: Boolean(workspaceId),
75
101
  }))
76
102
  const everything = $derived<Category[]>(categoriesQuery.data ?? [])
77
- const categories = $derived(showArchived ? everything : everything.filter((row) => !row.archivedAt))
103
+
104
+ /**
105
+ * The two lists, and why they are two.
106
+ *
107
+ * `live` is the sequence: it is what a person arranges, what every picker and filter shows, and the
108
+ * exact set `categories.reorder` insists on being handed. An archived category is in none of those
109
+ * places, so it has no position to arrange — putting it in the same draggable list would let
110
+ * somebody carefully place a row that nobody but this page will ever see, between two rows it does
111
+ * not sit between anywhere else. They get their own group, in their own order.
112
+ *
113
+ * Sorted with the reader's own collation rather than the runtime's: `localeCompare` with no locale
114
+ * sorts Persian and Turkish names by whatever the browser happens to default to.
115
+ */
116
+ const live = $derived(everything.filter((row) => !row.archivedAt))
117
+ const collator = $derived(new Intl.Collator(messageLocale()))
118
+ const archived = $derived(
119
+ everything.filter((row) => row.archivedAt).sort((a, b) => collator.compare(a.name, b.name)),
120
+ )
78
121
  /** Rows exist, and the switch is hiding all of them. A different sentence from having none. */
79
- const allArchived = $derived(everything.length > 0 && categories.length === 0)
122
+ const allArchived = $derived(live.length === 0 && archived.length > 0 && !showArchived)
123
+
124
+ // ------------------------------------------------------------------------------- the sequence
125
+
126
+ const FLIP = 140
127
+
128
+ /**
129
+ * The order on screen, the two snapshots behind it, and the two flags — one value, in `sequence.ts`.
130
+ *
131
+ * It lives next door rather than here because a `.svelte` file cannot be unit-tested in this package,
132
+ * and every defect this screen has had was an *ordering* rather than a calculation: a keyboard drag
133
+ * ending on a different event from a pointer drag, a refusal rolling back to a list the server had
134
+ * already rejected, a keypress arriving while the last one was still being written. Each of those is
135
+ * three assertions in `sequence.test.ts` and three careful readings here.
136
+ *
137
+ * `$state.raw` and not a plain `$state`: a deep-reactive proxy hands the drag library a different
138
+ * object on every read, and it reads that as an endless stream of changes.
139
+ */
140
+ let sequence = $state.raw<Sequence<Category>>(start<Category>([]))
141
+ let announcements = $state<[string, string]>(['', ''])
142
+ let pulse = false
143
+
144
+ /**
145
+ * The library's own types want a mutable array, and its keyboard action really does splice the one
146
+ * it is given — which is the route this screen switches off below. Nothing here ever mutates it.
147
+ */
148
+ const rows = $derived(sequence.rows as Category[])
149
+
150
+ /**
151
+ * Seed the sequence from the query, and *only* from it.
152
+ *
153
+ * `seed` declines while a drag or a save is in progress, so a refetch landing mid-gesture cannot pull
154
+ * the list out from under the pointer or replace the optimistic order with data the write has not
155
+ * reached yet. The flags are read inside `seed` rather than here, which is also what keeps them out
156
+ * of this effect's dependencies: reading one directly would re-run the effect when it cleared, and
157
+ * re-seed from a query the write has not reached.
158
+ *
159
+ * A skipped seed is dropped rather than queued, and the two ways back are deliberate: a successful
160
+ * write answers with the sequence it wrote, and a refusal re-seeds from what the server actually has.
161
+ *
162
+ * **`untrack` around the read, or this effect feeds itself.** `seed` returns a new object, so an
163
+ * effect that both reads and writes `sequence` invalidates its own dependency and Svelte stops it
164
+ * with `effect_update_depth_exceeded` — at runtime, on a screen that type-checks perfectly. The one
165
+ * thing this is allowed to react to is the query.
166
+ */
167
+ $effect(() => {
168
+ const next = live
169
+ sequence = untrack(() => seed(sequence, next))
170
+ })
171
+
172
+ const isShadow = (category: Category) =>
173
+ (category as unknown as Record<string, unknown>)[SHADOW_ITEM_MARKER_PROPERTY_NAME] === true
174
+
175
+ /** The reason token the server sends when the list no longer describes the workspace. */
176
+ const ORDER_STALE = 'inventory.category.order_stale'
177
+
178
+ const reorder = createMutation(() => ({
179
+ mutationFn: (categoryIds: readonly string[]) =>
180
+ api.categories.reorder({ workspaceId, categoryIds: [...categoryIds] }),
181
+ onSuccess: (written: Category[]) => {
182
+ // The server answers the sequence it wrote, so there is nothing to guess and no flash: the
183
+ // optimistic list is replaced by the same list. When somebody kept pressing an arrow key while
184
+ // this was in flight, `saved` hands back the list those presses add up to and it goes now —
185
+ // one more request for any number of presses, and never one per keypress arriving out of order.
186
+ take(saved(sequence, written))
187
+ // Refreshes the picker on the asset form and the filter on the list, which read the same query.
188
+ void queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
189
+ },
190
+ /**
191
+ * Say why, and — for the one refusal a person can act on — leave them able to act on it.
192
+ *
193
+ * A rollback alone is what made `order_stale` unrecoverable. It restores `settled`, which is the
194
+ * list the server has just refused, and the seeding effect cannot replace it: that effect is keyed
195
+ * on the query's data, and the refetch after the invalidation returns the value it already skipped
196
+ * while the save was in flight. Same value, no change, no re-run — so every retry sent the same
197
+ * stale list and earned the same refusal, under a message telling the reader to try again.
198
+ *
199
+ * So the invalidation is awaited and the list is re-seeded from what actually came back. Read out
200
+ * of the cache rather than out of `live`, because that is the value this screen is about to be
201
+ * given and reading it directly does not depend on anything having changed.
202
+ */
203
+ onError: async (error: unknown) => {
204
+ toast.error(errorMessage(error, t))
205
+ const stale = reasonOf(error) === ORDER_STALE
206
+ sequence = refused(sequence)
207
+ await queryClient.invalidateQueries({ queryKey: inventoryKeys.all })
208
+ if (!stale) return
209
+ const fresh = queryClient.getQueryData<Category[]>(inventoryKeys.categories(workspaceId, true))
210
+ if (fresh) sequence = reseed(fresh.filter((row) => !row.archivedAt))
211
+ },
212
+ }))
213
+
214
+ /**
215
+ * Adopt a transition, and do the work it leaves behind: post a list, speak a sentence, or neither.
216
+ *
217
+ * The three decisions themselves are in `sequence.ts`, with a test each. This is the wiring.
218
+ */
219
+ function take(step: Step<Category>) {
220
+ sequence = step.next
221
+ if (step.announce) announce(step.announce.list, step.announce.id)
222
+ if (step.save) reorder.mutate(step.save)
223
+ }
224
+
225
+ function move(category: Category, delta: number) {
226
+ take(moved(sequence, category.id, delta))
227
+ }
228
+
229
+ function consider(event: CustomEvent<{ items: Category[]; info: { trigger: string } }>) {
230
+ take(considered(sequence, event.detail.items, event.detail.info.trigger))
231
+ }
232
+
233
+ function finalize(event: CustomEvent<{ items: Category[]; info: { id: string } }>) {
234
+ take(
235
+ finalized(
236
+ sequence,
237
+ event.detail.items.filter((category) => !isShadow(category)),
238
+ event.detail.info.id,
239
+ ),
240
+ )
241
+ }
242
+
243
+ /**
244
+ * The keys `svelte-dnd-action` claims on a row, held back before they ever reach it.
245
+ *
246
+ * **This screen has one keyboard route to reordering, and it is the two buttons on each row.**
247
+ *
248
+ * The library ships a second one, and shipping both left both half-working. Its keyboard drag put a
249
+ * tab stop on every row — a stop that announces nothing and does nothing until you know to press
250
+ * Enter on it — and then fired a `finalize` on *every arrow key*, so moving a category three places
251
+ * was three writes, of which the guard discarded two. It also ends on a `consider` rather than a
252
+ * `finalize`, which is the event asymmetry `sequence.ts` exists to absorb.
253
+ *
254
+ * The house rule is that a drag must have a **non-drag equivalent**, not that the library's drag must
255
+ * also be driveable from the keyboard. *Move up* and *move down* are that equivalent: they are real
256
+ * buttons with real names, they are reachable in the same tab order as everything else on the page,
257
+ * and one press is one move whatever the network is doing. So the library's keyboard route is turned
258
+ * off rather than left as a worse duplicate of them — `zoneItemTabIndex: -1` takes the rows out of the
259
+ * tab order, and this takes the trigger keys away from a row that has been focused by a click, which
260
+ * is the one way left to reach it.
261
+ *
262
+ * In the capture phase on the list, because the library listens on each row: a capture handler on the
263
+ * ancestor runs first, and `stopPropagation` there means the row's own listener never sees the key. It
264
+ * only ever fires for a key pressed on a **row**; a key on a button inside one is somebody using the
265
+ * controls, and passes straight through.
266
+ */
267
+ const LIBRARY_DRAG_KEYS = new Set(['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'])
268
+
269
+ function keepKeyboardOnTheButtons(event: KeyboardEvent) {
270
+ const target = event.target as HTMLElement | null
271
+ if (!target || target.parentElement !== event.currentTarget) return
272
+ if (LIBRARY_DRAG_KEYS.has(event.key)) event.stopPropagation()
273
+ }
274
+
275
+ /**
276
+ * What a screen reader is told, and why it never contains a number.
277
+ *
278
+ * "position 4 of 9" asks somebody to hold two numbers in their head to work out what a neighbour's
279
+ * name says outright — and a number is the thing this page stopped showing. The sentence describes
280
+ * where the row *is* rather than what just happened, so it is still true when the answer is that
281
+ * the row could not move: pressing *move up* on the first row says it is first.
282
+ */
283
+ function announce(list: readonly Category[], id: string) {
284
+ const name = list.find((category) => category.id === id)?.name
285
+ const spot = placementOf(list, id)
286
+ if (name === undefined || spot.at === 'gone') return
287
+ const sentence =
288
+ spot.at === 'after'
289
+ ? t('category_position_after', isolated({ name, other: spot.previous.name }))
290
+ : t(spot.at === 'first' ? 'category_position_first' : 'category_position_last', isolated({ name }))
291
+ /**
292
+ * Two regions, written alternately, because one would fall silent.
293
+ *
294
+ * A live region announces a *change* to its text, and pressing *move down* twice on the row that
295
+ * is already last produces the same sentence twice — so the second press would say nothing, which
296
+ * reads as a broken button to the one person who cannot see that nothing moved. Filling one region
297
+ * while emptying the other makes every announcement a change, whatever the words are. The
298
+ * alternative trick is a trailing zero-width space, and it puts a character nobody can see into
299
+ * the source for somebody to delete by accident.
300
+ */
301
+ pulse = !pulse
302
+ announcements = pulse ? [sentence, ''] : ['', sentence]
303
+ }
80
304
 
81
305
  // ------------------------------------------------------------------ the add / rename dialog
82
306
 
83
307
  let editing = $state<Category | null>(null)
84
308
  let dialogOpen = $state(false)
85
309
  let name = $state('')
86
- let orderText = $state('')
87
310
 
88
311
  function openCreate() {
89
312
  editing = null
90
313
  name = ''
91
- orderText = '0'
92
314
  dialogOpen = true
93
315
  }
94
316
 
95
317
  function openEdit(category: Category) {
96
318
  editing = category
97
319
  name = category.name
98
- orderText = String(category.order)
99
320
  dialogOpen = true
100
321
  }
101
322
 
323
+ const canSubmit = $derived(canManage && Boolean(name.trim()))
324
+
102
325
  /**
103
- * A Persian keyboard produces ۱۲۳ and an Arabic one ١٢٣, and `Number` reads neither so somebody
104
- * typing the digits of their own language would be told their input is not a whole number.
105
- */
106
- const toLatinDigits = (value: string) =>
107
- value
108
- .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660))
109
- .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0))
110
-
111
- const order = $derived.by(() => {
112
- const digits = toLatinDigits(orderText.trim())
113
- return /^\d{1,4}$/.test(digits) ? Number(digits) : Number.NaN
114
- })
115
- /**
116
- * A hint and an error are two different sentences, and this field passed the hint as both.
117
- *
118
- * `hint` explains how positions sort — "Lower comes first. Categories sharing a position fall back
119
- * to their names." — and typing `x` restated exactly that, in red, under `aria-invalid`. It told
120
- * somebody nothing about what was wrong with what they had typed, and it made the field's ordinary
121
- * hint look like a failure the first time they read it. The error says what this box will accept.
326
+ * Set in the same tick as the click, for the reason `sequence.saving` above is: the attribute from
327
+ * `disabled={mutation.isPending}` reaches the button on the next render, and two quick clicks are one
328
+ * render apart — so a double-click would file the same category twice. Guarded rather than disabled,
329
+ * because disabling the control somebody is standing on throws their focus out to the page.
122
330
  */
123
- const orderError = $derived(Number.isNaN(order) ? t('category_order_invalid') : null)
124
- const canSubmit = $derived(canManage && Boolean(name.trim()) && !Number.isNaN(order))
125
-
126
- /** Set in the same tick as the click: `disabled={mutation.isPending}` is one render too late. */
127
331
  let saving = $state(false)
128
332
 
129
333
  const save = createMutation(() => ({
130
334
  mutationFn: () => {
131
335
  const row = editing
132
- const values = { name: name.trim(), order }
336
+ const values = { name: name.trim() }
133
337
  return row
134
338
  ? api.categories.update({ workspaceId, categoryId: row.id, ...values })
135
339
  : api.categories.create({ workspaceId, ...values })
@@ -219,10 +423,60 @@ function actionsFor(category: Category): MenuItem[] {
219
423
  return items
220
424
  }
221
425
 
222
- const COLUMNS = $derived(canManage ? 'minmax(0, 1fr) 96px 44px' : 'minmax(0, 1fr) 96px')
223
426
  const SKELETON_ROWS = [0, 1, 2, 3]
224
427
  </script>
225
428
 
429
+ <!--
430
+ One row, drawn the same whether it is part of the sequence or sitting in the archived group. The
431
+ grip, the two arrows and the drag itself belong only to the sequence: an archived category is in
432
+ no picker and no filter, so it has no position for anybody to arrange.
433
+ -->
434
+ {#snippet row(category: Category, sortable: boolean)}
435
+ <li
436
+ class="row"
437
+ class:sortable
438
+ class:shadow={isShadow(category)}
439
+ aria-label={sortable ? category.name : undefined}
440
+ >
441
+ {#if sortable}
442
+ <span class="grip" aria-hidden="true"><Icon name="grip-vertical" size={14} strokeWidth={1.8} /></span>
443
+ {/if}
444
+ <span class="cell">
445
+ <span class="name">{category.name}</span>
446
+ <!-- Only the archived state gets a badge. A "live" badge on every other row would be a column
447
+ of the same word, and the one it would have to borrow — `status_in_stock` — describes an
448
+ asset sitting in a cupboard, not a category. -->
449
+ {#if category.archivedAt}<Badge tone="grey">{t('archived')}</Badge>{/if}
450
+ </span>
451
+ {#if sortable}
452
+ <IconButton
453
+ icon="chevron-up"
454
+ size={28}
455
+ label={t('category_move_up', isolated({ name: category.name }))}
456
+ onclick={() => move(category, -1)}
457
+ />
458
+ <IconButton
459
+ icon="chevron-down"
460
+ size={28}
461
+ label={t('category_move_down', isolated({ name: category.name }))}
462
+ onclick={() => move(category, 1)}
463
+ />
464
+ {/if}
465
+ {#if canManage}
466
+ <DropdownMenu items={actionsFor(category)} align="end">
467
+ {#snippet trigger(props)}
468
+ <IconButton
469
+ {...props}
470
+ icon="ellipsis"
471
+ size={28}
472
+ label={t('row_actions', isolated({ name: category.name }))}
473
+ />
474
+ {/snippet}
475
+ </DropdownMenu>
476
+ {/if}
477
+ </li>
478
+ {/snippet}
479
+
226
480
  <SettingsPage title={t('settings_categories')} description={t('settings_categories_desc')}>
227
481
  {#snippet actions()}
228
482
  {#if canManage}
@@ -232,15 +486,19 @@ const SKELETON_ROWS = [0, 1, 2, 3]
232
486
 
233
487
  <SettingsSection flush>
234
488
  <div class="bar">
489
+ <!-- The hint earns its place only where the gesture is available and there is something to
490
+ reorder. On a one-category workspace it would explain a thing that cannot be done. -->
491
+ {#if canManage && live.length > 1}
492
+ <p class="hint">{t('category_reorder_hint')}</p>
493
+ {/if}
235
494
  <Switch bind:checked={showArchived} size="sm" label={t('show_archived')} />
236
495
  </div>
237
496
 
238
497
  {#if categoriesQuery.isPending}
239
498
  <div class="skeleton">
240
- {#each SKELETON_ROWS as row (row)}
241
- <div class="srow" style:grid-template-columns={COLUMNS}>
499
+ {#each SKELETON_ROWS as skeleton (skeleton)}
500
+ <div class="srow">
242
501
  <Skeleton height="12px" width="52%" />
243
- <Skeleton height="12px" width="30%" />
244
502
  {#if canManage}<Skeleton height="12px" width="16px" />{/if}
245
503
  </div>
246
504
  {/each}
@@ -253,6 +511,19 @@ const SKELETON_ROWS = [0, 1, 2, 3]
253
511
  </Button>
254
512
  {/snippet}
255
513
  </EmptyState>
514
+ {:else if everything.length === 0}
515
+ <!--
516
+ Reachable, and worth keeping: `onWorkspaceEnabled` seeds five categories, but it only runs
517
+ when a workspace switches the module on — an instance upgraded with Inventory already
518
+ enabled never ran it, and neither does a workspace whose seeding half failed.
519
+ -->
520
+ <EmptyState icon="tag" title={t('categories_empty')} description={t('categories_empty_desc')}>
521
+ {#snippet actions()}
522
+ {#if canManage}
523
+ <Button onclick={openCreate}>{t('category_new')}</Button>
524
+ {/if}
525
+ {/snippet}
526
+ </EmptyState>
256
527
  {:else if allArchived}
257
528
  <!--
258
529
  Not "No categories yet": this workspace has categories and is looking at none of them. The
@@ -270,59 +541,75 @@ const SKELETON_ROWS = [0, 1, 2, 3]
270
541
  </Button>
271
542
  {/snippet}
272
543
  </EmptyState>
273
- {:else if categories.length === 0}
544
+ {:else}
545
+ {#if canManage}
546
+ <!--
547
+ The drag is a pointer gesture here, and the arrows on each row are the keyboard equivalent.
548
+
549
+ `autoAriaDisabled`, because the library speaks its own English to screen readers and this
550
+ product promises five languages; every sentence a reader hears here is one of ours, in the
551
+ live region below. `zoneTabIndex: -1` and `zoneItemTabIndex: -1` take the list and its rows
552
+ out of the tab order — stops that announce nothing and do nothing, now that the library is
553
+ neither describing them nor driving them — and `onkeydowncapture` takes the trigger keys
554
+ away from a row focused by a click, which is the one way left into the library's own
555
+ keyboard drag. See `keepKeyboardOnTheButtons` for why that route is off rather than fixed.
556
+ -->
557
+ <ul
558
+ class="rows"
559
+ role="list"
560
+ aria-label={t('settings_categories')}
561
+ aria-busy={sequence.saving}
562
+ use:dndzone={{
563
+ items: rows,
564
+ type: 'inventory-categories',
565
+ flipDurationMs: FLIP,
566
+ dropTargetStyle: {},
567
+ autoAriaDisabled: true,
568
+ zoneTabIndex: -1,
569
+ zoneItemTabIndex: -1,
570
+ }}
571
+ onconsider={consider}
572
+ onfinalize={finalize}
573
+ onkeydowncapture={keepKeyboardOnTheButtons}
574
+ >
575
+ {#each rows as category (category.id)}
576
+ {@render row(category, true)}
577
+ {/each}
578
+ </ul>
579
+ {:else}
580
+ <ul class="rows" role="list" aria-label={t('settings_categories')}>
581
+ {#each rows as category (category.id)}
582
+ {@render row(category, false)}
583
+ {/each}
584
+ </ul>
585
+ {/if}
586
+
274
587
  <!--
275
- Reachable, and worth keeping: `onWorkspaceEnabled` seeds five categories, but it only runs
276
- when a workspace switches the module on — an instance upgraded with Inventory already
277
- enabled never ran it, and neither does a workspace whose seeding half failed.
588
+ Archived categories, in their own group and in their own order.
589
+
590
+ Not part of the sequence above, because they are in no picker and no filter — there is
591
+ nothing for a position to be a position *in*. Sorted by name rather than by the number they
592
+ happened to leave with, which is the one ordering somebody can predict. `h2`, not `h3`: this
593
+ section is passed no title, so the page's `h1` is the level directly above it.
278
594
  -->
279
- <EmptyState icon="tag" title={t('categories_empty')} description={t('categories_empty_desc')}>
280
- {#snippet actions()}
281
- {#if canManage}
282
- <Button onclick={openCreate}>{t('category_new')}</Button>
283
- {/if}
284
- {/snippet}
285
- </EmptyState>
286
- {:else}
287
- <Table columns={COLUMNS} ariaLabel={t('settings_categories')}>
288
- <TableHeader>
289
- <TableCell header>{t('category')}</TableCell>
290
- <TableCell header>{t('category_order')}</TableCell>
291
- {#if canManage}<TableCell header end></TableCell>{/if}
292
- </TableHeader>
293
- {#each categories as category (category.id)}
294
- <TableRow>
295
- <TableCell>
296
- <span class="cell">
297
- <span class="name">{category.name}</span>
298
- <!-- Only the archived state gets a badge. A "live" badge on every other row would
299
- be a column of the same word, and the one it would have to borrow —
300
- `status_in_stock` — describes an asset sitting in a cupboard, not a category. -->
301
- {#if category.archivedAt}<Badge tone="grey">{t('archived')}</Badge>{/if}
302
- </span>
303
- </TableCell>
304
- <TableCell><span class="muted">{category.order}</span></TableCell>
305
- {#if canManage}
306
- <TableCell end>
307
- <DropdownMenu items={actionsFor(category)} align="end">
308
- {#snippet trigger(props)}
309
- <IconButton
310
- {...props}
311
- icon="ellipsis"
312
- size={28}
313
- label={t('row_actions', isolated({ name: category.name }))}
314
- />
315
- {/snippet}
316
- </DropdownMenu>
317
- </TableCell>
318
- {/if}
319
- </TableRow>
320
- {/each}
321
- </Table>
595
+ {#if showArchived && archived.length > 0}
596
+ <h2 class="kern-sublabel group">{t('archived')}</h2>
597
+ <ul class="rows" role="list" aria-label={t('archived')}>
598
+ {#each archived as category (category.id)}
599
+ {@render row(category, false)}
600
+ {/each}
601
+ </ul>
602
+ {/if}
322
603
  {/if}
323
604
  </SettingsSection>
324
605
  </SettingsPage>
325
606
 
607
+ <!-- Rendered always, and empty until there is something to say: a live region that appears at the
608
+ same moment as its text is a region most screen readers never announce. Two of them, written
609
+ alternately, so the same sentence twice in a row is still a change — see `announce`. -->
610
+ <p class="kern-sr-only" aria-live="polite">{announcements[0]}</p>
611
+ <p class="kern-sr-only" aria-live="polite">{announcements[1]}</p>
612
+
326
613
  <Dialog
327
614
  bind:open={dialogOpen}
328
615
  size="sm"
@@ -334,13 +621,6 @@ const SKELETON_ROWS = [0, 1, 2, 3]
334
621
  <Input {id} bind:value={name} maxlength={120} />
335
622
  {/snippet}
336
623
  </Field>
337
- <Field label={t('category_order')} id="inv-cat-order" hint={t('category_order_hint')}>
338
- {#snippet children(id)}
339
- <div class="narrow">
340
- <Input {id} bind:value={orderText} inputmode="numeric" maxlength={4} mono error={orderError} />
341
- </div>
342
- {/snippet}
343
- </Field>
344
624
  </div>
345
625
 
346
626
  {#snippet footer()}
@@ -368,13 +648,61 @@ const SKELETON_ROWS = [0, 1, 2, 3]
368
648
  <style>
369
649
  .bar {
370
650
  display: flex;
651
+ align-items: center;
371
652
  justify-content: flex-end;
653
+ gap: 16px;
372
654
  padding: 10px 12px;
373
655
  }
656
+ .hint {
657
+ margin: 0;
658
+ min-width: 0;
659
+ /* Logical, so the switch stays at the trailing edge in Persian and Arabic too. */
660
+ margin-inline-end: auto;
661
+ font-size: 12px;
662
+ line-height: 1.5;
663
+ /* A colour rather than opacity, which fades text against the page whatever token it names. */
664
+ color: var(--kern-ink-500);
665
+ }
666
+ .rows {
667
+ list-style: none;
668
+ margin: 0;
669
+ padding: 0;
670
+ display: flex;
671
+ flex-direction: column;
672
+ }
673
+ .row {
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 8px;
677
+ min-height: 48px;
678
+ padding: 4px 12px;
679
+ border-top: 1px solid var(--kern-border-hairline);
680
+ font-size: 13px;
681
+ color: var(--kern-ink-600);
682
+ background: var(--kern-surface-raised);
683
+ }
684
+ .row.sortable {
685
+ cursor: grab;
686
+ }
687
+ .row.sortable:active {
688
+ cursor: grabbing;
689
+ }
690
+ /* The placeholder the drag library keeps under the cursor, marking where the row will land.
691
+ Hidden rather than faded: it still holds its space, so the gap opens exactly where the row is
692
+ going, and a half-transparent copy of a row that is already on screen under the pointer reads
693
+ as a rendering fault rather than as a target. */
694
+ .row.shadow {
695
+ visibility: hidden;
696
+ }
697
+ .grip {
698
+ display: inline-flex;
699
+ color: var(--kern-ink-400);
700
+ }
374
701
  .cell {
375
702
  display: flex;
376
703
  align-items: center;
377
704
  gap: 8px;
705
+ flex: 1;
378
706
  min-width: 0;
379
707
  }
380
708
  .name {
@@ -388,30 +716,27 @@ const SKELETON_ROWS = [0, 1, 2, 3]
388
716
  keeps its own trailing punctuation instead of donating it to the paragraph. */
389
717
  unicode-bidi: plaintext;
390
718
  }
391
- .muted {
392
- /* A colour rather than opacity, which fades text against the page whatever token it names. */
393
- color: var(--kern-ink-500);
394
- font-variant-numeric: tabular-nums;
719
+ .group {
720
+ margin: 0;
721
+ padding: 16px 12px 6px;
722
+ border-top: 1px solid var(--kern-border-hairline);
395
723
  }
396
724
  .skeleton {
397
725
  display: flex;
398
726
  flex-direction: column;
399
- gap: 1px;
400
727
  }
401
728
  .srow {
402
- display: grid;
729
+ display: flex;
403
730
  align-items: center;
731
+ justify-content: space-between;
404
732
  gap: 12px;
405
- padding: 14px 12px;
406
- border-bottom: 1px solid var(--kern-border-hairline);
733
+ padding: 18px 12px;
734
+ border-top: 1px solid var(--kern-border-hairline);
407
735
  }
408
736
  .form {
409
737
  display: grid;
410
738
  gap: 14px;
411
739
  }
412
- .narrow {
413
- max-inline-size: 140px;
414
- }
415
740
  .dialog-body {
416
741
  margin: 0;
417
742
  font-size: 13.5px;