@nexxtmove/ui 1.32.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/components/atoms/FloatingPanel/FloatingPanel.vue.d.ts +48 -7
- package/dist/components/atoms/PickerItem/PickerItem.vue.d.ts +40 -0
- package/dist/components/atoms/PickerPreview/PickerPreview.vue.d.ts +29 -0
- package/dist/components/editor/molecules/EditorRadius/EditorRadius.vue.d.ts +4 -4
- package/dist/components/molecules/PickerGroup/PickerGroup.vue.d.ts +32 -0
- package/dist/components/molecules/PickerList/PickerList.vue.d.ts +107 -0
- package/dist/components/organisms/ColorPicker/ColorPicker.vue.d.ts +1 -1
- package/dist/components/organisms/PickerPanel/PickerPanel.vue.d.ts +93 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +2368 -1643
- package/dist/nuxt.js +5 -0
- package/package.json +1 -1
- package/src/components/atoms/FloatingPanel/FloatingPanel.vue +280 -20
- package/src/components/atoms/PickerItem/PickerItem.vue +113 -0
- package/src/components/atoms/PickerPreview/PickerPreview.vue +99 -0
- package/src/components/editor/molecules/EditorRadius/EditorRadius.vue +6 -6
- package/src/components/molecules/PickerGroup/PickerGroup.vue +107 -0
- package/src/components/molecules/PickerList/PickerList.vue +649 -0
- package/src/components/organisms/PickerPanel/PickerPanel.vue +267 -0
- package/src/components.json +5 -0
- package/src/index.ts +10 -0
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,5 +1,18 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
14
|
-
|
|
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({
|
|
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
|
-
<
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
</
|
|
202
|
-
</
|
|
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>
|