@nexxtmove/ui 1.31.0 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.31.0",
4
+ "version": "1.32.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -7,7 +7,7 @@ export interface NexxtPlaceholderGroup {
7
7
  }
8
8
 
9
9
  export interface NexxtPlaceholderInputMessages {
10
- /** Accessible name and tooltip of the pen button. */
10
+ /** Accessible name and tooltip of the wand button. */
11
11
  insert?: string
12
12
  }
13
13
 
@@ -83,7 +83,7 @@ const insert = (key: string, close: () => void) => {
83
83
  :aria-label="insertLabel"
84
84
  @click="open"
85
85
  >
86
- <NexxtIcon name="pen" aria-hidden="true" />
86
+ <NexxtIcon name="wand-magic-sparkles" class="text-yellow-400" aria-hidden="true" />
87
87
  </button>
88
88
  <template #tooltip>{{ insertLabel }}</template>
89
89
  </NexxtTooltip>
@@ -1,5 +1,15 @@
1
1
  <script lang="ts" setup>
2
- import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
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 === tabStopId ? 0 : -1"
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
- Stays grey on the selected row: the chevron is a control, not part of the label. -->
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
- @click.stop="toggle(row.node.id)"
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 { NexxtTreeNode, NexxtTreeProps } from './components/molecules/Tree/Tree.vue'
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,