@nexxtmove/ui 1.33.0 → 1.34.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/nuxt.js CHANGED
@@ -12,6 +12,8 @@ var s = {
12
12
  NexxtFloatingPanel: "components/atoms/FloatingPanel/FloatingPanel.vue",
13
13
  NexxtIcon: "components/atoms/Icon/Icon.vue",
14
14
  NexxtLoader: "components/atoms/Loader/Loader.vue",
15
+ NexxtPickerItem: "components/atoms/PickerItem/PickerItem.vue",
16
+ NexxtPickerPreview: "components/atoms/PickerPreview/PickerPreview.vue",
15
17
  NexxtPresenceIndicator: "components/atoms/PresenceIndicator/PresenceIndicator.vue",
16
18
  NexxtPropertyListing: "components/atoms/PropertyListing/PropertyListing.vue",
17
19
  NexxtSelectableOption: "components/atoms/SelectableOption/SelectableOption.vue",
@@ -33,6 +35,8 @@ var s = {
33
35
  NexxtPageMockup: "components/molecules/PageMockup/PageMockup.vue",
34
36
  NexxtPageTypeTile: "components/molecules/PageTypeTile/PageTypeTile.vue",
35
37
  NexxtPagination: "components/molecules/Pagination/Pagination.vue",
38
+ NexxtPickerGroup: "components/molecules/PickerGroup/PickerGroup.vue",
39
+ NexxtPickerList: "components/molecules/PickerList/PickerList.vue",
36
40
  NexxtPlaceholderInput: "components/molecules/PlaceholderInput/PlaceholderInput.vue",
37
41
  NexxtPropertyCard: "components/molecules/PropertyCard/PropertyCard.vue",
38
42
  NexxtSelect: "components/molecules/Select/Select.vue",
@@ -60,6 +64,7 @@ var s = {
60
64
  NexxtDropdownButton: "components/organisms/DropdownButton/DropdownButton.vue",
61
65
  NexxtHeader: "components/organisms/Header/Header.vue",
62
66
  NexxtModal: "components/organisms/Modal/Modal.vue",
67
+ NexxtPickerPanel: "components/organisms/PickerPanel/PickerPanel.vue",
63
68
  NexxtProgressBar: "components/organisms/ProgressBar/ProgressBar.vue",
64
69
  NexxtReferenceCard: "components/organisms/ReferenceCard/ReferenceCard.vue",
65
70
  NexxtSocialMediaTemplate: "components/organisms/SocialMediaTemplate/SocialMediaTemplate.vue",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.33.0",
4
+ "version": "1.34.0",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -1,5 +1,18 @@
1
1
  <script lang="ts" setup>
2
- import { ref, useTemplateRef, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
2
+ import {
3
+ ref,
4
+ useTemplateRef,
5
+ computed,
6
+ watch,
7
+ nextTick,
8
+ onMounted,
9
+ onUnmounted,
10
+ h,
11
+ readonly,
12
+ Teleport,
13
+ type FunctionalComponent,
14
+ } from 'vue'
15
+ import { usePreferredReducedMotion } from '@vueuse/core'
3
16
  import { useFloating, autoUpdate, flip, shift, offset, size } from '@floating-ui/vue'
4
17
  import type { Placement } from '@floating-ui/vue'
5
18
 
@@ -10,10 +23,93 @@ export interface NexxtFloatingPanelProps {
10
23
  offsetDistance?: number
11
24
  /** Stretch the panel to at least the trigger's width. */
12
25
  matchWidth?: boolean
13
- /** Wrap the content slot in the shared panel surface (rounded, bordered, white, shadowed). */
14
- surface?: boolean
26
+ /**
27
+ * Wrap the content slot in the shared panel surface. `true` is the surface for small
28
+ * dropdowns (gray-200 border, `shadow-sm`). `'raised'` is for large panels over a white page,
29
+ * such as a picker: heavier border and `shadow-lg`, so the panel reads as a layer above the
30
+ * page instead of part of it.
31
+ */
32
+ // `boolean` must come first: Vue then casts the bare attribute `surface` to `true`. With
33
+ // `String` first it becomes `''`, which is falsy, and the surface silently disappears.
34
+ surface?: boolean | 'raised'
35
+ /**
36
+ * Limit the content to the room on the roomiest side of the trigger (top or bottom), minus the
37
+ * offset and an 8px viewport margin. The value reaches the content as the `availableHeight`
38
+ * slot prop and as the CSS variable `--floating-panel-available-height` on the positioning
39
+ * element. The panel sets no height itself: the content decides how to use it. Follows
40
+ * scroll and resize while open.
41
+ */
42
+ fitHeight?: boolean
43
+ /**
44
+ * Render the panel at the end of `<body>` instead of next to the trigger, so it is no longer in
45
+ * the stacking context of the trigger's ancestors (a sibling with the same z-index can paint
46
+ * over a non-teleported panel). Keyboard order stays as if the panel followed the trigger.
47
+ * Costs: the panel no longer inherits font, colour or CSS variables from around the trigger,
48
+ * and DOM events from the panel no longer bubble through the trigger's ancestors.
49
+ * Ignored inside a `role="dialog"` (e.g. `NexxtModal`): Reka makes everything outside the
50
+ * dialog `pointer-events: none` and traps focus inside it. The panel stays in place there;
51
+ * `position: fixed` already escapes the dialog's overflow. Whether the panel was actually
52
+ * teleported is the `teleported` slot prop of `#content`, and `teleported` on the instance.
53
+ *
54
+ * Keyboard order is kept by invisible focus guards: one at the end of the trigger wrapper, and
55
+ * one at the start and end of the panel. A consumer that closes on `focusout` therefore sees
56
+ * focus land on a guard first and on its real target right after; check `relatedTarget` against
57
+ * the panel and the trigger, not against a list of expected elements. Listen on the exposed
58
+ * `floatingRef`, not on the slot content: the guards sit outside the content, so the final step
59
+ * out of the panel is a `focusout` of a guard that the content never sees. Focus inside the panel
60
+ * goes back to the trigger when it closes. Known limitation: with two teleported panels open at
61
+ * once, Tab from one can end up in the other.
62
+ */
63
+ teleport?: boolean
15
64
  }
16
65
 
66
+ type GuardEdge = 'bridge' | 'start' | 'end'
67
+
68
+ // Mirrors shift({ padding: 8 }), so the panel keeps the same gap to every viewport edge.
69
+ const VIEWPORT_MARGIN = 8
70
+ // The surface wrapper's `border`, top plus bottom.
71
+ const SURFACE_BORDER = 2
72
+
73
+ const SURFACE_CLASSES = {
74
+ default: 'overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm',
75
+ raised: 'overflow-hidden rounded-xl border border-gray-300 bg-white shadow-lg',
76
+ }
77
+
78
+ const TABBABLE =
79
+ 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
80
+
81
+ // Reka puts `role="dialog"` on its content but no `aria-modal`, so match on the role.
82
+ const DIALOG = '[role="dialog"], [role="alertdialog"]'
83
+
84
+ /*
85
+ * Renders its single slot child in place, or teleported when `to` is set. Returning the child
86
+ * itself (not an array, not a disabled <Teleport>) keeps the non-teleported DOM byte-identical:
87
+ * a disabled Teleport still leaves `teleport start/end` anchors next to the trigger.
88
+ */
89
+ const MaybeTeleport: FunctionalComponent<{ to?: string }> = (props, { slots }) =>
90
+ props.to ? h(Teleport, { to: props.to }, slots.default?.() ?? []) : slots.default?.()[0]
91
+ MaybeTeleport.props = ['to']
92
+
93
+ /*
94
+ * An invisible tab stop that hands focus on, used only while teleported. Inactive it renders an
95
+ * empty fragment rather than a `v-if` comment, so the default DOM gains no node that shows in
96
+ * the markup. No `aria-hidden`: on a focusable element that is the axe rule `aria-hidden-focus`.
97
+ */
98
+ const FocusGuard: FunctionalComponent<
99
+ { edge: GuardEdge; active: boolean },
100
+ { enter: [event: FocusEvent] }
101
+ > = (props, { emit }) =>
102
+ props.active
103
+ ? h('span', {
104
+ tabindex: 0,
105
+ 'data-focus-guard': props.edge,
106
+ class: 'pointer-events-none fixed opacity-0',
107
+ onFocus: (event: FocusEvent) => emit('enter', event),
108
+ })
109
+ : []
110
+ FocusGuard.props = ['edge', 'active']
111
+ FocusGuard.emits = ['enter']
112
+
17
113
  defineOptions({ name: 'NexxtFloatingPanel' })
18
114
 
19
115
  const props = withDefaults(defineProps<NexxtFloatingPanelProps>(), {
@@ -21,6 +117,8 @@ const props = withDefaults(defineProps<NexxtFloatingPanelProps>(), {
21
117
  offsetDistance: 8,
22
118
  matchWidth: false,
23
119
  surface: false,
120
+ fitHeight: false,
121
+ teleport: false,
24
122
  })
25
123
 
26
124
  const emit = defineEmits<{
@@ -36,6 +134,24 @@ const isLeaving = ref(false)
36
134
  const isReady = ref(false)
37
135
  const triggerRef = useTemplateRef('triggerRef')
38
136
  const floatingRef = useTemplateRef('floatingRef')
137
+ /**
138
+ * Whether the current (or last) open cycle rendered the panel in `<body>`: `teleport` was set and
139
+ * the trigger is not inside a `role="dialog"`. Decided once per open cycle in open(), since
140
+ * flipping it while open would remount the panel.
141
+ */
142
+ const teleported = ref(false)
143
+ const availableHeight = ref<number | null>(null)
144
+
145
+ const preferredMotion = usePreferredReducedMotion()
146
+ // `motion-reduce:transition-none` alone leaves Vue reading the duration off the leaving
147
+ // element, so a closed panel would linger; without CSS mode it goes in the same patch.
148
+ const reduced = computed(() => preferredMotion.value === 'reduce')
149
+
150
+ const surfaceClass = computed(
151
+ () => SURFACE_CLASSES[props.surface === 'raised' ? 'raised' : 'default'],
152
+ )
153
+ const slotAvailableHeight = computed(() => (props.fitHeight ? availableHeight.value : null))
154
+ const bridgeActive = computed(() => teleported.value && isOpen.value)
39
155
 
40
156
  const middleware = computed(() => [
41
157
  offset(props.offsetDistance),
@@ -50,6 +166,29 @@ const middleware = computed(() => [
50
166
  }),
51
167
  ]
52
168
  : []),
169
+ // A separate size() so the matchWidth path stays exactly as it was. It measures the roomiest
170
+ // side, not the side flip() picked: the first pass runs on an empty panel, which always fits
171
+ // the preferred side, and sizing to that side would stop the content from ever flipping.
172
+ ...(props.fitHeight
173
+ ? [
174
+ size({
175
+ apply: ({ elements, placement }) => {
176
+ const rect = elements.reference.getBoundingClientRect()
177
+ const viewport = document.documentElement.clientHeight
178
+ const side = placement.split('-')[0]
179
+ const room =
180
+ side === 'top' || side === 'bottom'
181
+ ? Math.max(rect.top, viewport - rect.bottom) -
182
+ props.offsetDistance -
183
+ VIEWPORT_MARGIN
184
+ : viewport - 2 * VIEWPORT_MARGIN
185
+ const height = Math.max(0, Math.floor(room - (props.surface ? SURFACE_BORDER : 0)))
186
+ elements.floating.style.setProperty('--floating-panel-available-height', `${height}px`)
187
+ availableHeight.value = height
188
+ },
189
+ }),
190
+ ]
191
+ : []),
53
192
  ])
54
193
 
55
194
  const {
@@ -68,6 +207,7 @@ const {
68
207
  })
69
208
 
70
209
  const open = async () => {
210
+ teleported.value = props.teleport && !triggerRef.value?.closest(DIALOG)
71
211
  isReady.value = false
72
212
  isOpen.value = true
73
213
  emit('open')
@@ -81,6 +221,13 @@ const open = async () => {
81
221
 
82
222
  const close = () => {
83
223
  if (!isOpen.value) return
224
+ // Teleported, focus inside the panel would drop to <body> when it goes, and the next Tab would
225
+ // start at the top of the page. Hand it back to the trigger first, before the emit, so a
226
+ // consumer that moves focus in @close still has the last word. A click outside still wins: this
227
+ // runs on mousedown, and the click's own focus change comes after it.
228
+ if (teleported.value && floatingRef.value?.contains(document.activeElement)) {
229
+ lastTabbableInTrigger()?.focus({ preventScroll: true })
230
+ }
84
231
  // Only keep the container alive if content was actually shown (isPositioned),
85
232
  // otherwise there is no leave transition to wait for.
86
233
  if (isPositioned.value) isLeaving.value = true
@@ -100,6 +247,92 @@ const handleKeydown = (event: KeyboardEvent) => {
100
247
  if (event.key === 'Escape' && isOpen.value) close()
101
248
  }
102
249
 
250
+ // Capture phase, unlike Escape: an element that stops Tab from propagating would otherwise switch
251
+ // the skip off and bring the loop back. Escape stays in the bubble phase on purpose, so content
252
+ // (the picker) can stop it to keep the panel open.
253
+ const handleTabCapture = (event: KeyboardEvent) => {
254
+ if (event.key === 'Tab' && !event.shiftKey && bridgeActive.value) skipPanelOnTab()
255
+ }
256
+
257
+ /*
258
+ * Focus guards, only while teleported. They make Tab and Shift+Tab walk the page as if the panel
259
+ * still followed the trigger: the bridge (last in the trigger) leads into the panel, the start
260
+ * guard back to the trigger, and the end guard on to whatever follows the trigger.
261
+ */
262
+ const tabbablesIn = (root: ParentNode | null | undefined) =>
263
+ root
264
+ ? Array.from(root.querySelectorAll<HTMLElement>(TABBABLE)).filter(
265
+ (el) => !el.hasAttribute('data-focus-guard') && (el.checkVisibility?.() ?? true),
266
+ )
267
+ : []
268
+
269
+ const follows = (reference: Node, node: Node) =>
270
+ (reference.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
271
+
272
+ const firstTabbableAfterTrigger = () => {
273
+ const trigger = triggerRef.value
274
+ if (!trigger) return undefined
275
+ return tabbablesIn(document).find(
276
+ (el) => follows(trigger, el) && !trigger.contains(el) && !floatingRef.value?.contains(el),
277
+ )
278
+ }
279
+
280
+ const lastTabbableInTrigger = () => tabbablesIn(triggerRef.value).at(-1)
281
+
282
+ const onBridgeEnter = (event: FocusEvent) => {
283
+ const from = event.relatedTarget
284
+ const trigger = triggerRef.value
285
+ // Shift+Tab from the element after the trigger lands on the bridge as well, since it is the
286
+ // last tab stop of the trigger: that is a step back into the panel, onto its last tab stop.
287
+ const backward =
288
+ from instanceof Node &&
289
+ !!trigger &&
290
+ follows(trigger, from) &&
291
+ !trigger.contains(from) &&
292
+ !floatingRef.value?.contains(from)
293
+ const panel = tabbablesIn(floatingRef.value)
294
+ const target = backward
295
+ ? (panel.at(-1) ?? lastTabbableInTrigger())
296
+ : (panel[0] ?? firstTabbableAfterTrigger())
297
+ target?.focus()
298
+ }
299
+
300
+ const onStartEnter = () => lastTabbableInTrigger()?.focus()
301
+
302
+ const onEndEnter = (event: FocusEvent) => {
303
+ const panel = floatingRef.value
304
+ const from = event.relatedTarget
305
+ // Shift+Tab from outside the panel (the browser UI, or anything after it in <body>) lands here
306
+ // first: step back to the tab stop before the panel, as if it were not there.
307
+ if (panel && !(from instanceof Node && panel.contains(from))) {
308
+ const before = tabbablesIn(document).filter((el) => follows(el, panel) && !panel.contains(el))
309
+ before.at(-1)?.focus()
310
+ return
311
+ }
312
+ ;(firstTabbableAfterTrigger() ?? tabbablesIn(triggerRef.value)[0])?.focus()
313
+ }
314
+
315
+ /*
316
+ * In the page the teleported panel sits at the end of <body>, so Tab from the last tab stop before
317
+ * it would walk into the panel (and from there back via the guards: a loop, with the browser UI
318
+ * out of reach). For that one keypress the panel is made inert, so the browser's own Tab skips it
319
+ * and moves on to whatever follows, or out of the document. Only the bridge leads into the panel.
320
+ */
321
+ const skipPanelOnTab = () => {
322
+ const panel = floatingRef.value
323
+ const active = document.activeElement
324
+ if (!panel || !(active instanceof HTMLElement) || panel.contains(active)) return
325
+ const stops = Array.from(document.querySelectorAll<HTMLElement>(TABBABLE)).filter(
326
+ (el) => el.checkVisibility?.() ?? true,
327
+ )
328
+ const index = stops.indexOf(active)
329
+ const next = index === -1 ? undefined : stops[index + 1]
330
+ if (!next || !panel.contains(next)) return
331
+ panel.inert = true
332
+ // the default action of this keydown has run by the time a task gets its turn
333
+ setTimeout(() => (panel.inert = false))
334
+ }
335
+
103
336
  const transitionClasses = computed(() => {
104
337
  const placement = computedPlacement.value
105
338
  const [side, align] = placement.split('-') as [string, string | undefined]
@@ -127,8 +360,8 @@ const transitionClasses = computed(() => {
127
360
  const origin = originMap[align ? `${side}-${align}` : side] ?? 'origin-top'
128
361
  const translate = translates[side] ?? '-translate-y-2'
129
362
  return {
130
- enterActiveClass: `transition-all duration-200 ease-out ${origin}`,
131
- leaveActiveClass: `transition-all duration-150 ease-in ${origin}`,
363
+ enterActiveClass: `transition-all duration-200 ease-out motion-reduce:transition-none ${origin}`,
364
+ leaveActiveClass: `transition-all duration-150 ease-in motion-reduce:transition-none ${origin}`,
132
365
  enterFromClass: `opacity-0 scale-95 ${translate}`,
133
366
  leaveToClass: `opacity-0 scale-95 ${translate}`,
134
367
  enterToClass: 'opacity-100 scale-100 translate-x-0 translate-y-0',
@@ -140,22 +373,33 @@ watch(isPositioned, (val) => {
140
373
  if (val && isOpen.value) isReady.value = true
141
374
  })
142
375
 
143
- defineExpose({ isOpen, open, close, toggle, triggerRef, floatingRef })
376
+ defineExpose({
377
+ isOpen,
378
+ open,
379
+ close,
380
+ toggle,
381
+ triggerRef,
382
+ floatingRef,
383
+ teleported: readonly(teleported),
384
+ })
144
385
 
145
386
  onMounted(() => {
146
387
  document.addEventListener('mousedown', handleMousedown)
147
388
  document.addEventListener('keydown', handleKeydown)
389
+ document.addEventListener('keydown', handleTabCapture, true)
148
390
  })
149
391
 
150
392
  onUnmounted(() => {
151
393
  document.removeEventListener('mousedown', handleMousedown)
152
394
  document.removeEventListener('keydown', handleKeydown)
395
+ document.removeEventListener('keydown', handleTabCapture, true)
153
396
  })
154
397
  </script>
155
398
 
156
399
  <template>
157
400
  <span ref="triggerRef" v-bind="$attrs" class="inline-flex">
158
401
  <slot name="trigger" :is-open="isOpen" :toggle="toggle" :open="open" :close="close" />
402
+ <FocusGuard edge="bridge" :active="bridgeActive" @enter="onBridgeEnter" />
159
403
  </span>
160
404
  <!--
161
405
  floatingRef is the pure positioning container. It mounts immediately when
@@ -165,15 +409,17 @@ onUnmounted(() => {
165
409
  strategy:'fixed' means position:fixed is used, so this escapes any overflow
166
410
  clipping without needing a body teleport.
167
411
  -->
168
- <div v-if="isOpen || isLeaving" ref="floatingRef" class="z-50" :style="floatingStyles">
169
- <!--
412
+ <MaybeTeleport v-if="isOpen || isLeaving" :to="teleported ? 'body' : undefined">
413
+ <div ref="floatingRef" class="z-50" :style="floatingStyles">
414
+ <FocusGuard edge="start" :active="teleported" @enter="onStartEnter" />
415
+ <!--
170
416
  Content only renders once isPositioned is true, meaning floatingRef is
171
417
  already at the correct position. The enter transition therefore always
172
418
  originates from the trigger — never from the page origin.
173
419
  -->
174
- <Transition v-bind="transitionClasses" @after-leave="isLeaving = false">
175
- <div v-if="isOpen && isReady">
176
- <!--
420
+ <Transition :css="!reduced" v-bind="transitionClasses" @after-leave="isLeaving = false">
421
+ <div v-if="isOpen && isReady">
422
+ <!--
177
423
  FloatingPanel is a positioning primitive: by default it adds no surface
178
424
  of its own and the content slot renders bare, exactly as before. The
179
425
  surface wrapper is an extra element that only exists when opted into,
@@ -190,14 +436,28 @@ onUnmounted(() => {
190
436
  strategy 'fixed', and overflow on an ancestor does not clip a fixed
191
437
  descendant unless that ancestor also establishes a containing block.
192
438
  -->
193
- <div
194
- v-if="surface"
195
- class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm"
196
- >
197
- <slot name="content" :is-open="isOpen" :close="close" :toggle="toggle" />
439
+ <div v-if="surface" :class="surfaceClass">
440
+ <slot
441
+ name="content"
442
+ :is-open="isOpen"
443
+ :close="close"
444
+ :toggle="toggle"
445
+ :available-height="slotAvailableHeight"
446
+ :teleported="teleported"
447
+ />
448
+ </div>
449
+ <slot
450
+ v-else
451
+ name="content"
452
+ :is-open="isOpen"
453
+ :close="close"
454
+ :toggle="toggle"
455
+ :available-height="slotAvailableHeight"
456
+ :teleported="teleported"
457
+ />
198
458
  </div>
199
- <slot v-else name="content" :is-open="isOpen" :close="close" :toggle="toggle" />
200
- </div>
201
- </Transition>
202
- </div>
459
+ </Transition>
460
+ <FocusGuard edge="end" :active="teleported" @enter="onEndEnter" />
461
+ </div>
462
+ </MaybeTeleport>
203
463
  </template>
@@ -0,0 +1,113 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import NexxtIcon, { type NexxtIconName } from '../Icon/Icon.vue'
4
+
5
+ interface NexxtPickerItemProps {
6
+ /** DOM id of the option; the listbox points `aria-activedescendant` at it. `NexxtPickerList` generates it. */
7
+ id: string
8
+ /** Visible label, and the accessible name of the option. */
9
+ label: string
10
+ /** Icon in front of the label. */
11
+ icon?: NexxtIconName
12
+ /**
13
+ * Whether this is the active option. Tints the row; inside a focused listbox named
14
+ * `group/listbox` it also gets a ring. Outside such a listbox the item never shows a ring.
15
+ */
16
+ highlighted?: boolean
17
+ /**
18
+ * The option stays reachable with the keyboard and the pointer, but never emits `select`.
19
+ * Give a `description` that names the reason: a disabled option without one is a dead end.
20
+ */
21
+ disabled?: boolean
22
+ /** One line of extra context. Not shown; screen readers get it through `aria-describedby`. */
23
+ description?: string
24
+ }
25
+
26
+ defineOptions({ name: 'NexxtPickerItem' })
27
+
28
+ const {
29
+ id,
30
+ label,
31
+ icon,
32
+ highlighted = false,
33
+ disabled = false,
34
+ description,
35
+ } = defineProps<NexxtPickerItemProps>()
36
+
37
+ const emit = defineEmits<{
38
+ /** Clicked. Never emitted while `disabled`. */
39
+ select: []
40
+ /** The pointer moved over the option; emitted while `disabled` too. */
41
+ highlight: []
42
+ }>()
43
+
44
+ defineSlots<{
45
+ /** Replaces icon and label. The option element, its id and its aria stay the item's own. */
46
+ default?(): unknown
47
+ }>()
48
+
49
+ // The highlighted branch comes first and carries no `hover:` tint, so hovering a highlighted
50
+ // row never repaints it. `cursor-*` lives per branch to keep each string free of conflicts.
51
+ // `shrink-0`: an ungrouped option sits directly in the scrolling flex column of the listbox,
52
+ // which would otherwise squeeze it below `h-9` as soon as the list overflows.
53
+ const OPTION_CLASSES = {
54
+ base: 'flex h-9 shrink-0 items-center gap-2.5 rounded-lg px-2 transition-colors duration-100 ease-out select-none motion-reduce:transition-none',
55
+ default: 'cursor-pointer hover:bg-gray-50 active:bg-cornflower-blue-100',
56
+ highlighted:
57
+ 'cursor-pointer bg-cornflower-blue-50 active:bg-cornflower-blue-100 group-focus-visible/listbox:inset-ring-2 group-focus-visible/listbox:inset-ring-cornflower-blue-600',
58
+ disabled: 'cursor-not-allowed opacity-60',
59
+ disabledHighlighted:
60
+ 'cursor-not-allowed bg-cornflower-blue-50 opacity-60 group-focus-visible/listbox:inset-ring-2 group-focus-visible/listbox:inset-ring-cornflower-blue-600',
61
+ }
62
+
63
+ const ICON_CLASSES = {
64
+ base: 'flex size-5 shrink-0 items-center justify-center transition-colors duration-100 ease-out motion-reduce:transition-none',
65
+ default: 'text-gray-700',
66
+ highlighted: 'text-cornflower-blue-700',
67
+ }
68
+
69
+ const state = computed(() => {
70
+ if (disabled) return highlighted ? 'disabledHighlighted' : 'disabled'
71
+ return highlighted ? 'highlighted' : 'default'
72
+ })
73
+
74
+ const optionClass = computed(() => [OPTION_CLASSES.base, OPTION_CLASSES[state.value]])
75
+ const iconClass = computed(() => [
76
+ ICON_CLASSES.base,
77
+ highlighted ? ICON_CLASSES.highlighted : ICON_CLASSES.default,
78
+ ])
79
+
80
+ const descriptionId = computed(() => (description ? `${id}-desc` : undefined))
81
+
82
+ // The option also prevents the mousedown default: that keeps DOM focus where it was (the search
83
+ // field), since the listbox around the option is focusable and would take it on every click.
84
+ // Noted here, not in the template: a comment before the root makes the template multi-root.
85
+ const onClick = () => {
86
+ if (!disabled) emit('select')
87
+ }
88
+ </script>
89
+
90
+ <template>
91
+ <div
92
+ :id="id"
93
+ role="option"
94
+ :aria-selected="highlighted"
95
+ :aria-disabled="disabled ? 'true' : undefined"
96
+ :aria-describedby="descriptionId"
97
+ :class="optionClass"
98
+ @click="onClick"
99
+ @pointermove="emit('highlight')"
100
+ @mousedown.prevent
101
+ >
102
+ <slot>
103
+ <span v-if="icon" :class="iconClass">
104
+ <!-- Icon.vue does not hide the `<i>`; its `::before` glyph would leak into the name -->
105
+ <NexxtIcon :name="icon" aria-hidden="true" />
106
+ </span>
107
+ <!-- One weight in every state: the tint and the icon colour carry the highlight, so the
108
+ label never widens or reflows when the highlight moves onto it -->
109
+ <span class="min-w-0 flex-1 truncate small-normal text-gray-900">{{ label }}</span>
110
+ </slot>
111
+ <span v-if="description" :id="descriptionId" hidden>{{ description }}</span>
112
+ </div>
113
+ </template>
@@ -0,0 +1,99 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import { usePreferredReducedMotion } from '@vueuse/core'
4
+
5
+ interface NexxtPickerPreviewProps {
6
+ /** Heading under the frame. Without it and without default-slot content the preview is empty. */
7
+ title?: string
8
+ /** Explanation under the title, clamped to two lines. */
9
+ description?: string
10
+ /** Centred in the frame while the preview is empty; the `empty` slot replaces it. */
11
+ emptyText?: string
12
+ /**
13
+ * Identifies what the preview shows. When it changes, the new content fades in (90ms) and the
14
+ * old content is gone at once: no crossfade. A change within 150ms of the previous one, as with
15
+ * a held arrow key, swaps without a fade. Without a key the content swaps without a fade.
16
+ */
17
+ contentKey?: string | number
18
+ }
19
+
20
+ defineOptions({ name: 'NexxtPickerPreview' })
21
+
22
+ const { title, description, emptyText, contentKey } = defineProps<NexxtPickerPreviewProps>()
23
+
24
+ defineSlots<{
25
+ /** Content of the 16:10 frame, e.g. an illustration or a screenshot. */
26
+ default?(): unknown
27
+ /** Replaces `emptyText` in the frame while the preview is empty. */
28
+ empty?(): unknown
29
+ }>()
30
+
31
+ const preferredMotion = usePreferredReducedMotion()
32
+ // `motion-reduce:transition-none` alone leaves Vue reading a duration off the element; turning
33
+ // the CSS mode off makes it swap synchronously instead
34
+ const reduced = computed(() => preferredMotion.value === 'reduce')
35
+
36
+ /** Swaps closer together than this skip the fade; each fade would restart at 0 and flicker. */
37
+ const RAPID_SWAP_MS = 150
38
+
39
+ let lastSwap = Number.NEGATIVE_INFINITY
40
+ const rapid = ref(false)
41
+
42
+ // `pre`, so the Transition already sees the new CSS mode in the patch that swaps the content
43
+ watch(
44
+ () => contentKey,
45
+ () => {
46
+ const now = Date.now()
47
+ rapid.value = now - lastSwap < RAPID_SWAP_MS
48
+ lastSwap = now
49
+ },
50
+ { flush: 'pre' },
51
+ )
52
+
53
+ const fade = computed(() => !reduced.value && !rapid.value)
54
+
55
+ /**
56
+ * Removes the outgoing content in the same patch. A leave hook that takes `done` counts as an
57
+ * explicit callback, so Vue waits for no transition; without it, the old content would stay next
58
+ * to the new one for a frame.
59
+ */
60
+ const leaveNow = (_el: Element, done: () => void) => done()
61
+
62
+ const FRAME_CLASSES =
63
+ 'grid aspect-16/10 w-full shrink-0 place-items-center overflow-hidden rounded-xl border border-gray-200 bg-gray-50'
64
+ const FADE_CLASSES = 'transition-opacity duration-90 ease-out motion-reduce:transition-none'
65
+ </script>
66
+
67
+ <template>
68
+ <div aria-hidden="true" class="flex flex-col gap-3">
69
+ <div :class="FRAME_CLASSES">
70
+ <Transition
71
+ v-if="$slots.default"
72
+ :css="fade"
73
+ :enter-active-class="FADE_CLASSES"
74
+ enter-from-class="opacity-0"
75
+ @leave="leaveNow"
76
+ >
77
+ <div :key="contentKey" class="size-full">
78
+ <slot />
79
+ </div>
80
+ </Transition>
81
+ <slot v-else-if="!title" name="empty">
82
+ <p v-if="emptyText" class="px-4 text-center small-normal text-gray-700">{{ emptyText }}</p>
83
+ </slot>
84
+ </div>
85
+
86
+ <Transition
87
+ v-if="title"
88
+ :css="fade"
89
+ :enter-active-class="FADE_CLASSES"
90
+ enter-from-class="opacity-0"
91
+ @leave="leaveNow"
92
+ >
93
+ <div :key="contentKey" class="flex flex-col gap-1">
94
+ <p class="truncate body-semibold text-gray-900">{{ title }}</p>
95
+ <p v-if="description" class="line-clamp-2 small-normal text-gray-700">{{ description }}</p>
96
+ </div>
97
+ </Transition>
98
+ </div>
99
+ </template>