@kernhq/module-inventory 0.3.0 → 0.4.0
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 +7 -5
- package/dist/contract/models.d.ts +24 -1
- package/dist/contract/models.d.ts.map +1 -1
- package/dist/contract/models.js +35 -3
- package/dist/contract/models.js.map +1 -1
- package/dist/contract/router.d.ts +58 -2
- package/dist/contract/router.d.ts.map +1 -1
- package/dist/contract/router.js +29 -1
- package/dist/contract/router.js.map +1 -1
- package/dist/server/router.d.ts +57 -2
- package/dist/server/router.d.ts.map +1 -1
- package/dist/server/router.js +23 -1
- package/dist/server/router.js.map +1 -1
- package/dist/server/schema.d.ts.map +1 -1
- package/dist/server/schema.js +11 -0
- package/dist/server/schema.js.map +1 -1
- package/dist/server/services/categories.d.ts +110 -10
- package/dist/server/services/categories.d.ts.map +1 -1
- package/dist/server/services/categories.js +198 -13
- package/dist/server/services/categories.js.map +1 -1
- package/migrations/0008_category_order_unique.sql +71 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/client/errors.test.ts +30 -0
- package/src/client/errors.ts +31 -3
- package/src/client/messages.ts +82 -19
- package/src/client/mock.test.ts +71 -1
- package/src/client/mock.ts +51 -12
- package/src/client/module.ts +19 -1
- package/src/client/reorder.test.ts +100 -0
- package/src/client/reorder.ts +79 -0
- package/src/client/sequence.test.ts +248 -0
- package/src/client/sequence.ts +185 -0
- package/src/client/settings/CategoriesSettings.svelte +430 -105
- package/src/contract/models.ts +36 -3
- package/src/contract/router.ts +29 -0
- package/src/module.test.ts +23 -0
- package/src/server/inventory.int.test.ts +545 -10
- package/src/server/migrations.test.ts +140 -2
- package/src/server/router.ts +25 -1
- package/src/server/schema.ts +11 -0
- 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,
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
|
|
106
|
-
|
|
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()
|
|
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
|
|
241
|
-
<div class="srow"
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
280
|
-
{
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
.
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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:
|
|
729
|
+
display: flex;
|
|
403
730
|
align-items: center;
|
|
731
|
+
justify-content: space-between;
|
|
404
732
|
gap: 12px;
|
|
405
|
-
padding:
|
|
406
|
-
border-
|
|
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;
|