@nexxtmove/ui 1.31.1 → 1.32.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/dist/components/molecules/Tree/Tree.vue.d.ts +17 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1110 -1075
- package/package.json +1 -1
- package/src/components/molecules/Tree/Tree.vue +139 -6
- package/src/index.ts +5 -1
package/package.json
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
computed,
|
|
4
|
+
nextTick,
|
|
5
|
+
onMounted,
|
|
6
|
+
ref,
|
|
7
|
+
useId,
|
|
8
|
+
useTemplateRef,
|
|
9
|
+
watch,
|
|
10
|
+
type FunctionalComponent,
|
|
11
|
+
type VNodeChild,
|
|
12
|
+
} from 'vue'
|
|
3
13
|
import { useResizeObserver } from '@vueuse/core'
|
|
4
14
|
import NexxtIcon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
|
|
5
15
|
|
|
@@ -19,6 +29,11 @@ export interface NexxtTreeProps {
|
|
|
19
29
|
label: string
|
|
20
30
|
}
|
|
21
31
|
|
|
32
|
+
export type NexxtTreeSlots = {
|
|
33
|
+
/** Actions at the end of a row, e.g. a delete button. Hidden until the row is hovered or holds focus, and always shown on a coarse pointer. Clicks and keys inside it never reach the tree, so they neither select the row nor move focus. Put `tabindex` on every focusable element in it: it is `0` only on the row that is the tree's tab stop, which keeps one extra Tab stop for the whole tree. Keep the content at most 24px high, or the rows grow. */
|
|
34
|
+
'node-actions'?(props: { node: NexxtTreeNode; tabindex: 0 | -1 }): unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
interface Row {
|
|
23
38
|
node: NexxtTreeNode
|
|
24
39
|
depth: number
|
|
@@ -32,6 +47,7 @@ interface Row {
|
|
|
32
47
|
defineOptions({ name: 'NexxtTree' })
|
|
33
48
|
|
|
34
49
|
const props = defineProps<NexxtTreeProps>()
|
|
50
|
+
const slots = defineSlots<NexxtTreeSlots>()
|
|
35
51
|
|
|
36
52
|
const selected = defineModel<string | null>('selected', { default: null })
|
|
37
53
|
/** Ids of the nodes whose children are hidden. Tracking the collapsed ones makes "everything open" the default. */
|
|
@@ -165,10 +181,15 @@ onMounted(() =>
|
|
|
165
181
|
*/
|
|
166
182
|
const ENTER_TRANSITION = 'transform 200ms var(--ease-in-out-strong), opacity 80ms linear 120ms'
|
|
167
183
|
|
|
184
|
+
/** Ids of the rows that are still unfolding; `focusNode` holds its scroll until they have landed. */
|
|
185
|
+
const unfolding = new Set<string>()
|
|
186
|
+
|
|
168
187
|
const onEnter = (element: Element) => {
|
|
169
188
|
const row = element as HTMLElement
|
|
170
189
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
171
190
|
|
|
191
|
+
unfolding.add(row.dataset.treeId!)
|
|
192
|
+
|
|
172
193
|
const parent = rowElement(
|
|
173
194
|
rows.value.find((item) => item.node.id === row.dataset.treeId)?.parentId,
|
|
174
195
|
)
|
|
@@ -188,11 +209,21 @@ const onEnter = (element: Element) => {
|
|
|
188
209
|
row.style.opacity = ''
|
|
189
210
|
}
|
|
190
211
|
|
|
212
|
+
/** A row that `focusNode` focused while it was still unfolding, to scroll to once it has landed. */
|
|
213
|
+
let scrollOnLanding: string | null = null
|
|
214
|
+
|
|
191
215
|
const onEnterDone = (element: Element) => {
|
|
192
216
|
const row = element as HTMLElement
|
|
193
217
|
|
|
194
218
|
row.style.transition = ''
|
|
195
219
|
row.style.pointerEvents = ''
|
|
220
|
+
unfolding.delete(row.dataset.treeId!)
|
|
221
|
+
|
|
222
|
+
if (row.dataset.treeId !== scrollOnLanding) return
|
|
223
|
+
scrollOnLanding = null
|
|
224
|
+
|
|
225
|
+
// Only while it still has focus: a scroll after the user moved on would yank the panel away
|
|
226
|
+
if (document.activeElement === row) row.scrollIntoView({ block: 'nearest' })
|
|
196
227
|
}
|
|
197
228
|
|
|
198
229
|
// One tab stop for the whole tree: the focused row, else the selected one, else the first
|
|
@@ -205,12 +236,46 @@ const tabStopId = computed(() => {
|
|
|
205
236
|
return ids[0] ?? null
|
|
206
237
|
})
|
|
207
238
|
|
|
239
|
+
/** The row and the focusables in its actions share this, so they can never disagree on the tab stop. */
|
|
240
|
+
const tabindexOf = (id: string): 0 | -1 => (id === tabStopId.value ? 0 : -1)
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The row is named by its label alone: a treeitem otherwise takes its name from all of its
|
|
244
|
+
* content, which would append the `aria-label` of every action button to it. Encoded because an
|
|
245
|
+
* id with a space in it would read as two references.
|
|
246
|
+
*/
|
|
247
|
+
const uid = useId()
|
|
248
|
+
const labelId = (id: string) => `${uid}-label-${encodeURIComponent(id)}`
|
|
249
|
+
|
|
250
|
+
type NodeActionsProps = Parameters<NonNullable<NexxtTreeSlots['node-actions']>>[0]
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Renders the slot without a `<slot>` in the template. A slot outlet inside the TransitionGroup
|
|
254
|
+
* compiles its default slot as forwarded, which re-renders the group on every render of the tree;
|
|
255
|
+
* the tree re-renders mid-fold to steer the highlight, and each of those restarted the rows'
|
|
256
|
+
* move transition, leaving the highlight to slide off on its own. The slot content's own reactive
|
|
257
|
+
* reads are still tracked here, so it updates like any other slot.
|
|
258
|
+
*/
|
|
259
|
+
const NodeActions: FunctionalComponent<NodeActionsProps> = (actionProps) =>
|
|
260
|
+
// the slot type returns `unknown`, as every slot type in this library does; it renders vnodes
|
|
261
|
+
slots['node-actions']?.(actionProps) as VNodeChild
|
|
262
|
+
|
|
208
263
|
const toggle = (id: string) => {
|
|
209
264
|
collapsed.value = collapsed.value.includes(id)
|
|
210
265
|
? collapsed.value.filter((item) => item !== id)
|
|
211
266
|
: [...collapsed.value, id]
|
|
212
267
|
}
|
|
213
268
|
|
|
269
|
+
/**
|
|
270
|
+
* The chevron never takes focus, so focus stays where it was. Inside the tree that can be a row
|
|
271
|
+
* this fold is about to hide, which would drop focus onto the page; the folding row takes it
|
|
272
|
+
* over instead. Focus outside the tree is left alone.
|
|
273
|
+
*/
|
|
274
|
+
const onChevronClick = (id: string) => {
|
|
275
|
+
toggle(id)
|
|
276
|
+
if (root.value?.contains(document.activeElement)) focusRow(id)
|
|
277
|
+
}
|
|
278
|
+
|
|
214
279
|
const focusRow = async (id: string) => {
|
|
215
280
|
focusedId.value = id
|
|
216
281
|
await nextTick()
|
|
@@ -221,6 +286,56 @@ const focusRow = async (id: string) => {
|
|
|
221
286
|
?.focus()
|
|
222
287
|
}
|
|
223
288
|
|
|
289
|
+
/** Ids of the ancestors of `id`, outermost first; `null` when no node has that id. */
|
|
290
|
+
const ancestorsOf = (id: string): string[] | null => {
|
|
291
|
+
const search = (nodes: NexxtTreeNode[], path: string[]): string[] | null => {
|
|
292
|
+
for (const node of nodes) {
|
|
293
|
+
if (node.id === id) return path
|
|
294
|
+
|
|
295
|
+
const found = node.children ? search(node.children, [...path, node.id]) : null
|
|
296
|
+
if (found) return found
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return search(props.nodes, [])
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Moves keyboard focus to a node and makes it the tree's tab stop, unfolding its collapsed
|
|
307
|
+
* ancestors first through the `collapsed` model. Leaves `selected` alone. Meant for after the
|
|
308
|
+
* consumer removed the focused node, e.g. from a `node-actions` button, so focus does not fall
|
|
309
|
+
* back to the page.
|
|
310
|
+
*
|
|
311
|
+
* @param id - The id of the node to focus.
|
|
312
|
+
* @returns Whether the node now has focus: `false` for an id that is not in `nodes`, or when the
|
|
313
|
+
* tree cannot take focus, for instance while it is hidden.
|
|
314
|
+
*/
|
|
315
|
+
const focusNode = async (id: string): Promise<boolean> => {
|
|
316
|
+
const ancestors = ancestorsOf(id)
|
|
317
|
+
if (!ancestors) return false
|
|
318
|
+
|
|
319
|
+
const folded = ancestors.filter((ancestor) => collapsed.value.includes(ancestor))
|
|
320
|
+
if (folded.length) collapsed.value = collapsed.value.filter((item) => !folded.includes(item))
|
|
321
|
+
|
|
322
|
+
focusedId.value = id
|
|
323
|
+
// An unfolded row is in the DOM after this render; only its enter transition is still running
|
|
324
|
+
await nextTick()
|
|
325
|
+
|
|
326
|
+
const element = rowElement(id)
|
|
327
|
+
if (!element) return false
|
|
328
|
+
|
|
329
|
+
// Scrolled by hand rather than by focus, which centres the row in Chromium: `nearest` moves the
|
|
330
|
+
// panel no more than it has to. An unfolding row starts stacked on its parent, so it is only
|
|
331
|
+
// scrolled to once it has landed; a later call takes that over, so only the last one scrolls.
|
|
332
|
+
element.focus({ preventScroll: true })
|
|
333
|
+
scrollOnLanding = unfolding.has(id) ? id : null
|
|
334
|
+
if (!scrollOnLanding) element.scrollIntoView({ block: 'nearest' })
|
|
335
|
+
|
|
336
|
+
return document.activeElement === element
|
|
337
|
+
}
|
|
338
|
+
|
|
224
339
|
const onKeydown = (event: KeyboardEvent) => {
|
|
225
340
|
const index = rows.value.findIndex((row) => row.node.id === tabStopId.value)
|
|
226
341
|
const row = rows.value[index]
|
|
@@ -261,6 +376,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
|
|
261
376
|
|
|
262
377
|
event.preventDefault()
|
|
263
378
|
}
|
|
379
|
+
|
|
380
|
+
defineExpose({ focusNode })
|
|
264
381
|
</script>
|
|
265
382
|
|
|
266
383
|
<template>
|
|
@@ -301,7 +418,8 @@ const onKeydown = (event: KeyboardEvent) => {
|
|
|
301
418
|
:aria-setsize="row.setSize"
|
|
302
419
|
:aria-selected="selected === row.node.id"
|
|
303
420
|
:aria-expanded="row.expandable ? row.expanded : undefined"
|
|
304
|
-
:tabindex="row.node.id
|
|
421
|
+
:tabindex="tabindexOf(row.node.id)"
|
|
422
|
+
:aria-labelledby="labelId(row.node.id)"
|
|
305
423
|
:data-tree-id="row.node.id"
|
|
306
424
|
class="group mb-0.5 flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-2 small-normal transition-colors duration-150 select-none focus-visible:outline-2 focus-visible:outline-cornflower-blue-500 motion-reduce:transition-none"
|
|
307
425
|
:class="
|
|
@@ -311,15 +429,18 @@ const onKeydown = (event: KeyboardEvent) => {
|
|
|
311
429
|
@click="selected = row.node.id"
|
|
312
430
|
@focus="focusedId = row.node.id"
|
|
313
431
|
>
|
|
314
|
-
<!-- Out of the tab order: the arrow keys expand and collapse for keyboard users.
|
|
315
|
-
|
|
432
|
+
<!-- Out of the tab order: the arrow keys expand and collapse for keyboard users. A click
|
|
433
|
+
does not focus it either, as focus inside `aria-hidden` is blocked; see
|
|
434
|
+
`onChevronClick` for where the focus goes instead. Stays grey on the selected row:
|
|
435
|
+
the chevron is a control, not part of the label. -->
|
|
316
436
|
<button
|
|
317
437
|
v-if="row.expandable"
|
|
318
438
|
type="button"
|
|
319
439
|
tabindex="-1"
|
|
320
440
|
aria-hidden="true"
|
|
321
441
|
class="flex size-5 shrink-0 cursor-pointer items-center justify-center text-gray-400 transition-colors duration-150 group-hover:text-gray-600 hover:text-gray-600 motion-reduce:transition-none"
|
|
322
|
-
@
|
|
442
|
+
@mousedown.prevent
|
|
443
|
+
@click.stop="onChevronClick(row.node.id)"
|
|
323
444
|
>
|
|
324
445
|
<NexxtIcon
|
|
325
446
|
name="chevron-down"
|
|
@@ -335,7 +456,19 @@ const onKeydown = (event: KeyboardEvent) => {
|
|
|
335
456
|
class="shrink-0 transition-colors duration-150 motion-reduce:transition-none"
|
|
336
457
|
:class="selected === row.node.id ? 'text-cornflower-blue-600' : 'text-gray-600'"
|
|
337
458
|
/>
|
|
338
|
-
<span class="truncate">{{ row.node.label }}</span>
|
|
459
|
+
<span :id="labelId(row.node.id)" class="truncate">{{ row.node.label }}</span>
|
|
460
|
+
|
|
461
|
+
<!-- Revealed by opacity rather than removed, so the row keeps its layout while it
|
|
462
|
+
appears. Pulled into the row's padding so a 24px button leaves the row at 28px.
|
|
463
|
+
Stopping here keeps the row's click and the tree's keys away from the actions. -->
|
|
464
|
+
<div
|
|
465
|
+
v-if="slots['node-actions']"
|
|
466
|
+
class="-my-0.5 ml-auto flex shrink-0 items-center opacity-0 transition-opacity duration-150 ease-out group-focus-within:opacity-100 group-hover:opacity-100 motion-reduce:transition-none pointer-coarse:opacity-100"
|
|
467
|
+
@click.stop
|
|
468
|
+
@keydown.stop
|
|
469
|
+
>
|
|
470
|
+
<NodeActions :node="row.node" :tabindex="tabindexOf(row.node.id)" />
|
|
471
|
+
</div>
|
|
339
472
|
</div>
|
|
340
473
|
</TransitionGroup>
|
|
341
474
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,11 @@ export type {
|
|
|
18
18
|
NexxtPageCardSlots,
|
|
19
19
|
} from './components/molecules/PageCard/PageCard.vue'
|
|
20
20
|
export type { NexxtPageMockupType } from './components/molecules/PageMockup/PageMockup.vue'
|
|
21
|
-
export type {
|
|
21
|
+
export type {
|
|
22
|
+
NexxtTreeNode,
|
|
23
|
+
NexxtTreeProps,
|
|
24
|
+
NexxtTreeSlots,
|
|
25
|
+
} from './components/molecules/Tree/Tree.vue'
|
|
22
26
|
export type {
|
|
23
27
|
NexxtPlaceholderGroup,
|
|
24
28
|
NexxtPlaceholderInputMessages,
|