@nexxtmove/ui 1.25.0 → 1.27.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.
@@ -0,0 +1,342 @@
1
+ <script lang="ts" setup>
2
+ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
3
+ import { useResizeObserver } from '@vueuse/core'
4
+ import NexxtIcon, { type NexxtIconName } from '../../atoms/Icon/Icon.vue'
5
+
6
+ export interface NexxtTreeNode {
7
+ /** Unique within the tree; this is what `selected` and `collapsed` hold. */
8
+ id: string
9
+ label: string
10
+ icon?: NexxtIconName
11
+ /** A node with children gets a chevron and can be folded. */
12
+ children?: NexxtTreeNode[]
13
+ }
14
+
15
+ export interface NexxtTreeProps {
16
+ /** The top-level nodes; nest `children` for deeper levels. */
17
+ nodes: NexxtTreeNode[]
18
+ /** Accessible name for the tree, e.g. the heading above it. */
19
+ label: string
20
+ }
21
+
22
+ interface Row {
23
+ node: NexxtTreeNode
24
+ depth: number
25
+ parentId: string | null
26
+ expandable: boolean
27
+ expanded: boolean
28
+ position: number
29
+ setSize: number
30
+ }
31
+
32
+ defineOptions({ name: 'NexxtTree' })
33
+
34
+ const props = defineProps<NexxtTreeProps>()
35
+
36
+ const selected = defineModel<string | null>('selected', { default: null })
37
+ /** Ids of the nodes whose children are hidden. Tracking the collapsed ones makes "everything open" the default. */
38
+ const collapsed = defineModel<string[]>('collapsed', { default: () => [] })
39
+
40
+ const INDENT = 22
41
+
42
+ const root = useTemplateRef<HTMLElement>('root')
43
+ const focusedId = ref<string | null>(null)
44
+
45
+ // The visible rows in document order; children of a collapsed node are left out
46
+ const rows = computed(() => {
47
+ const result: Row[] = []
48
+
49
+ const walk = (nodes: NexxtTreeNode[], depth: number, parentId: string | null) => {
50
+ nodes.forEach((node, index) => {
51
+ const expandable = !!node.children?.length
52
+ const expanded = expandable && !collapsed.value.includes(node.id)
53
+
54
+ result.push({
55
+ node,
56
+ depth,
57
+ parentId,
58
+ expandable,
59
+ expanded,
60
+ position: index + 1,
61
+ setSize: nodes.length,
62
+ })
63
+
64
+ if (expanded) walk(node.children!, depth + 1, node.id)
65
+ })
66
+ }
67
+
68
+ walk(props.nodes, 0, null)
69
+
70
+ return result
71
+ })
72
+
73
+ // A row is only highlighted while it is actually on screen; collapsing its parent fades the
74
+ // highlight out rather than dropping it, so reopening slides it back from the same spot
75
+ const selectionVisible = computed(
76
+ () => !!selected.value && rows.value.some((row) => row.node.id === selected.value),
77
+ )
78
+
79
+ /** Geometry of the moving highlight, relative to the tree container. */
80
+ const highlight = ref({ top: 0, height: 0 })
81
+ const highlightElement = useTemplateRef<HTMLElement>('highlightElement')
82
+
83
+ /**
84
+ * How the highlight travels to its next spot. A selection arrives on the row it was clicked on,
85
+ * so it decelerates; a row change drags the highlight along with a row that is sliding under
86
+ * the `move` transition, so it has to use that transition's duration and curve or the two come
87
+ * apart on screen. The first render and layout corrections do not travel at all.
88
+ */
89
+ const MOTION = {
90
+ none: '',
91
+ select: 'transition-[transform,opacity] duration-200 ease-out-strong',
92
+ move: 'transition-[transform,opacity] duration-200 ease-in-out-strong',
93
+ }
94
+
95
+ const motion = ref<keyof typeof MOTION>('none')
96
+
97
+ /**
98
+ * Matched on the dataset rather than in a selector, so an id never needs escaping. A row that is
99
+ * leaving keeps its id for another beat, but it is out of the flow — `absolute` marks it — and
100
+ * folding the same node straight back open would otherwise find that stale one.
101
+ */
102
+ const rowElement = (id: string | null | undefined) =>
103
+ Array.from(root.value?.querySelectorAll<HTMLElement>('[data-tree-id]') ?? []).find(
104
+ (element) => element.dataset.treeId === id && !element.classList.contains('absolute'),
105
+ )
106
+
107
+ const updateHighlight = async () => {
108
+ await nextTick()
109
+ if (!root.value || !selectionVisible.value) return
110
+
111
+ const element = rowElement(selected.value)
112
+
113
+ if (element) highlight.value = { top: element.offsetTop, height: element.offsetHeight }
114
+ }
115
+
116
+ /**
117
+ * Reposition without animating, for the first render and for layout corrections. Reading the
118
+ * geometry makes the browser commit the new position while the transition is still off; the
119
+ * class coming back after that has nothing left to animate. Deliberately not `requestAnimationFrame`:
120
+ * that never fires while the tree sits in a hidden tab, which would leave it unable to animate.
121
+ */
122
+ const jumpTo = async (reposition: () => Promise<void>) => {
123
+ const previous = motion.value
124
+ motion.value = 'none'
125
+
126
+ await reposition()
127
+ await nextTick()
128
+ root.value?.getBoundingClientRect()
129
+
130
+ motion.value = previous
131
+ }
132
+
133
+ // Rows above the selection can appear or disappear, so every row change is a reposition too
134
+ watch([selected, rows], ([id], [previousId]) => {
135
+ motion.value = id === previousId ? 'move' : 'select'
136
+ updateHighlight()
137
+ })
138
+
139
+ // A resize corrects the geometry — late webfonts, a narrower panel — so it never animates.
140
+ // Expanding and collapsing resizes the tree too, and there the highlight is mid-flight: forcing
141
+ // the geometry then cancels its transition and teleports it to the row's end position.
142
+ useResizeObserver(root, () => {
143
+ if (highlightElement.value?.getAnimations().length) return
144
+
145
+ jumpTo(updateHighlight)
146
+ })
147
+
148
+ onMounted(() =>
149
+ jumpTo(async () => {
150
+ await updateHighlight()
151
+ // Fallback metrics put the rows at another height, which would animate once the real font lands
152
+ await document.fonts?.ready
153
+ await updateHighlight()
154
+ }),
155
+ )
156
+
157
+ /**
158
+ * Children unfold from under their parent. They are inserted at their final spot, where the rows
159
+ * below them are still sliding down to make room, so each one starts stacked on the parent's
160
+ * bottom edge and travels to its slot on the same duration and curve as the `move` transition.
161
+ * Fading in is what keeps the stack readable: on the way out from under the parent the children
162
+ * cover each other and the rows below, so the label only comes up over the last stretch, once
163
+ * everything has all but landed. Inline rather than in `enter-active-class`, because the two
164
+ * properties need their own timing.
165
+ */
166
+ const ENTER_TRANSITION = 'transform 200ms var(--ease-in-out-strong), opacity 80ms linear 120ms'
167
+
168
+ const onEnter = (element: Element) => {
169
+ const row = element as HTMLElement
170
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
171
+
172
+ const parent = rowElement(
173
+ rows.value.find((item) => item.node.id === row.dataset.treeId)?.parentId,
174
+ )
175
+ const travel = parent ? row.offsetTop - (parent.offsetTop + parent.offsetHeight) : 0
176
+
177
+ row.style.transition = 'none'
178
+ row.style.transform = `translateY(${-travel}px)`
179
+ row.style.opacity = '0'
180
+ // Nothing to click on a row that is still on its way and cannot be read yet
181
+ row.style.pointerEvents = 'none'
182
+
183
+ // Commits the start of the travel while the transition is off, so only the way back animates
184
+ row.getBoundingClientRect()
185
+
186
+ row.style.transition = ENTER_TRANSITION
187
+ row.style.transform = ''
188
+ row.style.opacity = ''
189
+ }
190
+
191
+ const onEnterDone = (element: Element) => {
192
+ const row = element as HTMLElement
193
+
194
+ row.style.transition = ''
195
+ row.style.pointerEvents = ''
196
+ }
197
+
198
+ // One tab stop for the whole tree: the focused row, else the selected one, else the first
199
+ const tabStopId = computed(() => {
200
+ const ids = rows.value.map((row) => row.node.id)
201
+
202
+ if (focusedId.value && ids.includes(focusedId.value)) return focusedId.value
203
+ if (selected.value && ids.includes(selected.value)) return selected.value
204
+
205
+ return ids[0] ?? null
206
+ })
207
+
208
+ const toggle = (id: string) => {
209
+ collapsed.value = collapsed.value.includes(id)
210
+ ? collapsed.value.filter((item) => item !== id)
211
+ : [...collapsed.value, id]
212
+ }
213
+
214
+ const focusRow = async (id: string) => {
215
+ focusedId.value = id
216
+ await nextTick()
217
+
218
+ // Matched on the dataset rather than in a selector, so an id never needs escaping
219
+ Array.from(root.value?.querySelectorAll<HTMLElement>('[data-tree-id]') ?? [])
220
+ .find((element) => element.dataset.treeId === id)
221
+ ?.focus()
222
+ }
223
+
224
+ const onKeydown = (event: KeyboardEvent) => {
225
+ const index = rows.value.findIndex((row) => row.node.id === tabStopId.value)
226
+ const row = rows.value[index]
227
+ if (!row) return
228
+
229
+ const move = (target: Row | undefined) => {
230
+ if (target) focusRow(target.node.id)
231
+ }
232
+
233
+ switch (event.key) {
234
+ case 'ArrowDown':
235
+ move(rows.value[index + 1])
236
+ break
237
+ case 'ArrowUp':
238
+ move(rows.value[index - 1])
239
+ break
240
+ case 'Home':
241
+ move(rows.value[0])
242
+ break
243
+ case 'End':
244
+ move(rows.value[rows.value.length - 1])
245
+ break
246
+ case 'ArrowRight':
247
+ if (row.expandable && !row.expanded) toggle(row.node.id)
248
+ else if (row.expanded) move(rows.value[index + 1])
249
+ break
250
+ case 'ArrowLeft':
251
+ if (row.expanded) toggle(row.node.id)
252
+ else if (row.parentId) focusRow(row.parentId)
253
+ break
254
+ case 'Enter':
255
+ case ' ':
256
+ selected.value = row.node.id
257
+ break
258
+ default:
259
+ return
260
+ }
261
+
262
+ event.preventDefault()
263
+ }
264
+ </script>
265
+
266
+ <template>
267
+ <div ref="root" role="tree" :aria-label="label" class="relative isolate" @keydown="onKeydown">
268
+ <!-- One highlight for the whole tree: selecting another row slides it instead of
269
+ repainting two backgrounds. Behind the rows, so their hover still reads. -->
270
+ <div
271
+ ref="highlightElement"
272
+ aria-hidden="true"
273
+ class="pointer-events-none absolute top-0 left-0 -z-10 w-full rounded-md bg-cornflower-blue-50 motion-reduce:transition-none"
274
+ :class="[MOTION[motion], selectionVisible ? 'opacity-100' : 'opacity-0']"
275
+ :style="{ transform: `translateY(${highlight.top}px)`, height: `${highlight.height}px` }"
276
+ data-tree-highlight
277
+ />
278
+
279
+ <!-- A `group` keeps the tree/treeitem relation intact now that the rows sit one level
280
+ deeper. Leaving rows go absolute so the remaining ones slide up right away, and go
281
+ invisible with them: out of the flow they stack on the first one's spot while the rows
282
+ below slide over them, which read as a flash of doubled labels. Folding away is not a
283
+ departure worth animating — the rows sliding up under the parent tell the story. -->
284
+ <TransitionGroup
285
+ tag="div"
286
+ role="group"
287
+ leave-active-class="invisible absolute w-full transition-none!"
288
+ move-class="transition-transform duration-200 ease-in-out-strong motion-reduce:transition-none"
289
+ @enter="onEnter"
290
+ @after-enter="onEnterDone"
291
+ @enter-cancelled="onEnterDone"
292
+ >
293
+ <!-- Every row keeps `small-normal`: switching the selected one to semibold widened its
294
+ label, which made the text jump. Colour carries the selection instead. -->
295
+ <div
296
+ v-for="row in rows"
297
+ :key="row.node.id"
298
+ role="treeitem"
299
+ :aria-level="row.depth + 1"
300
+ :aria-posinset="row.position"
301
+ :aria-setsize="row.setSize"
302
+ :aria-selected="selected === row.node.id"
303
+ :aria-expanded="row.expandable ? row.expanded : undefined"
304
+ :tabindex="row.node.id === tabStopId ? 0 : -1"
305
+ :data-tree-id="row.node.id"
306
+ 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
+ :class="
308
+ selected === row.node.id ? 'text-cornflower-blue-600' : 'text-gray-800 hover:bg-gray-100'
309
+ "
310
+ :style="{ paddingLeft: `${row.depth * INDENT + 4}px` }"
311
+ @click="selected = row.node.id"
312
+ @focus="focusedId = row.node.id"
313
+ >
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. -->
316
+ <button
317
+ v-if="row.expandable"
318
+ type="button"
319
+ tabindex="-1"
320
+ aria-hidden="true"
321
+ 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)"
323
+ >
324
+ <NexxtIcon
325
+ name="chevron-down"
326
+ class="extra-small-normal transition-transform duration-150 ease-out motion-reduce:transition-none"
327
+ :class="!row.expanded && '-rotate-90'"
328
+ />
329
+ </button>
330
+ <span v-else class="size-5 shrink-0" />
331
+
332
+ <NexxtIcon
333
+ v-if="row.node.icon"
334
+ :name="row.node.icon"
335
+ class="shrink-0 transition-colors duration-150 motion-reduce:transition-none"
336
+ :class="selected === row.node.id ? 'text-cornflower-blue-600' : 'text-gray-600'"
337
+ />
338
+ <span class="truncate">{{ row.node.label }}</span>
339
+ </div>
340
+ </TransitionGroup>
341
+ </div>
342
+ </template>
@@ -25,7 +25,10 @@
25
25
  "NexxtInputField": "components/molecules/InputField/InputField.vue",
26
26
  "NexxtInputSelect": "components/molecules/InputSelect/InputSelect.vue",
27
27
  "NexxtModalFooter": "components/molecules/ModalFooter/ModalFooter.vue",
28
+ "NexxtPageCard": "components/molecules/PageCard/PageCard.vue",
28
29
  "NexxtPageContainer": "components/molecules/PageContainer/PageContainer.vue",
30
+ "NexxtPageMockup": "components/molecules/PageMockup/PageMockup.vue",
31
+ "NexxtPageTypeTile": "components/molecules/PageTypeTile/PageTypeTile.vue",
29
32
  "NexxtPagination": "components/molecules/Pagination/Pagination.vue",
30
33
  "NexxtPropertyCard": "components/molecules/PropertyCard/PropertyCard.vue",
31
34
  "NexxtSelect": "components/molecules/Select/Select.vue",
@@ -43,6 +46,7 @@
43
46
  "NexxtTimelineLanes": "components/molecules/TimelineLanes/TimelineLanes.vue",
44
47
  "NexxtToast": "components/molecules/Toast/Toast.vue",
45
48
  "NexxtToggleGroup": "components/molecules/ToggleGroup/ToggleGroup.vue",
49
+ "NexxtTree": "components/molecules/Tree/Tree.vue",
46
50
  "NexxtCalendar": "components/organisms/Calendar/Calendar.vue",
47
51
  "NexxtColorPicker": "components/organisms/ColorPicker/ColorPicker.vue",
48
52
  "NexxtCombobox": "components/organisms/Combobox/Combobox.vue",
package/src/index.ts CHANGED
@@ -13,6 +13,12 @@ export type { EnergyLabelLetter } from './components/atoms/EnergyLabel/EnergyLab
13
13
  export type { IconType, NexxtIconName } from './components/atoms/Icon/Icon.vue'
14
14
  export type { CustomIconName } from './components/atoms/Icon/customIcons'
15
15
  export type { NexxtTabItem } from './components/molecules/Tabs/Tabs.vue'
16
+ export type {
17
+ NexxtPageCardProps,
18
+ NexxtPageCardSlots,
19
+ } from './components/molecules/PageCard/PageCard.vue'
20
+ export type { NexxtPageMockupType } from './components/molecules/PageMockup/PageMockup.vue'
21
+ export type { NexxtTreeNode, NexxtTreeProps } from './components/molecules/Tree/Tree.vue'
16
22
  export type {
17
23
  EditorAlignItemsMessages,
18
24
  EditorAlignItemsValue,
@@ -95,7 +101,10 @@ export { default as NexxtInputSelect } from './components/molecules/InputSelect/
95
101
  export { default as NexxtLoader } from './components/atoms/Loader/Loader.vue'
96
102
  export { default as NexxtModal } from './components/organisms/Modal/Modal.vue'
97
103
  export { default as NexxtModalFooter } from './components/molecules/ModalFooter/ModalFooter.vue'
104
+ export { default as NexxtPageCard } from './components/molecules/PageCard/PageCard.vue'
98
105
  export { default as NexxtPageContainer } from './components/molecules/PageContainer/PageContainer.vue'
106
+ export { default as NexxtPageMockup } from './components/molecules/PageMockup/PageMockup.vue'
107
+ export { default as NexxtPageTypeTile } from './components/molecules/PageTypeTile/PageTypeTile.vue'
99
108
  export { default as NexxtPagination } from './components/molecules/Pagination/Pagination.vue'
100
109
  export { default as NexxtPresenceIndicator } from './components/atoms/PresenceIndicator/PresenceIndicator.vue'
101
110
  export { default as NexxtProgressBar } from './components/organisms/ProgressBar/ProgressBar.vue'
@@ -129,4 +138,5 @@ export { default as NexxtToast } from './components/molecules/Toast/Toast.vue'
129
138
  export { default as NexxtToaster } from './components/organisms/Toaster/Toaster.vue'
130
139
  export { default as NexxtToggleGroup } from './components/molecules/ToggleGroup/ToggleGroup.vue'
131
140
  export { default as NexxtTooltip } from './components/atoms/Tooltip/Tooltip.vue'
141
+ export { default as NexxtTree } from './components/molecules/Tree/Tree.vue'
132
142
  export { default as NexxtVideoPlayer } from './components/organisms/VideoPlayer/VideoPlayer.vue'
@@ -49,4 +49,10 @@
49
49
 
50
50
  --spacing-8xl: 100rem;
51
51
  --spacing-full-width: 116rem; /* 1920px (full hd) minus the sidebar: max app width */
52
+
53
+ /* Motion easings. `ease-out-strong` decelerates hard: things that arrive or
54
+ settle into place. `ease-in-out-strong` for elements that move from one
55
+ spot to another. */
56
+ --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
57
+ --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);
52
58
  }