@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.
@@ -0,0 +1,267 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue'
3
+ import { useResizeObserver } from '@vueuse/core'
4
+ import NexxtPickerPreview from '../../atoms/PickerPreview/PickerPreview.vue'
5
+ import NexxtInputField from '../../molecules/InputField/InputField.vue'
6
+ import NexxtPickerList, {
7
+ type NexxtPickerGroupData,
8
+ type NexxtPickerItemData,
9
+ } from '../../molecules/PickerList/PickerList.vue'
10
+
11
+ interface NexxtPickerPanelProps {
12
+ /** All items; the search field filters them. */
13
+ items: NexxtPickerItemData[]
14
+ /** Groups in display order. Ungrouped items come first. */
15
+ groups?: NexxtPickerGroupData[]
16
+ /** Render the search field. Without it the listbox takes focus on mount. */
17
+ searchable?: boolean
18
+ /** Placeholder of the search field. */
19
+ searchPlaceholder?: string
20
+ /** Accessible name of the search field. Required for an accessible name; the placeholder is only a fallback. */
21
+ searchLabel?: string
22
+ /** Accessible name of the panel (`role="group"`). Required for an accessible name. */
23
+ ariaLabel?: string
24
+ /** Accessible name of the listbox. Required for an accessible name. */
25
+ listLabel?: string
26
+ /** Panel width; a number is in pixels. The columns never shrink with it. */
27
+ width?: number | string
28
+ /** Panel height; a number is in pixels. Clamped to `calc(100dvh - 5rem)`, and only the list gives way. */
29
+ height?: number | string
30
+ /** Below this height of the column row (the panel minus the header), in pixels, the preview column is left out and the list takes the full width. */
31
+ hidePreviewBelow?: number
32
+ /** Whether group headings fold their group. */
33
+ collapsible?: boolean
34
+ /** Heading of the empty state, shown when the search has no results. */
35
+ emptyTitle?: string
36
+ /** Line under the empty heading. A function receives the trimmed search term. */
37
+ emptyDescription?: string | ((search: string) => string)
38
+ /** Label of the button in the empty state that clears the search. Without it there is no button. */
39
+ clearLabel?: string
40
+ /** Text in the preview frame while nothing is highlighted; the `preview-empty` slot replaces it. */
41
+ previewEmptyText?: string
42
+ /** Text announced 500ms after the search changes, e.g. `(n) => \`${n} resultaten\``. */
43
+ resultsAnnouncement?: (count: number) => string
44
+ }
45
+
46
+ defineOptions({ name: 'NexxtPickerPanel' })
47
+
48
+ const props = withDefaults(defineProps<NexxtPickerPanelProps>(), {
49
+ groups: () => [],
50
+ searchable: true,
51
+ width: 640,
52
+ height: 420,
53
+ hidePreviewBelow: 340,
54
+ collapsible: true,
55
+ })
56
+
57
+ /** Id of the highlighted item; the list keeps it on a visible item. Drives the preview. */
58
+ const highlighted = defineModel<string | null>('highlighted', { default: null })
59
+
60
+ /** The search term. Bind it to reset or prefill the search; unbound, the panel keeps its own. */
61
+ const search = defineModel<string>('search', { default: '' })
62
+
63
+ const emit = defineEmits<{
64
+ /** Enter, Space or a click on an enabled item. The panel never closes itself. */
65
+ select: [item: NexxtPickerItemData]
66
+ /** The highlighted item changed; `null` when nothing is visible. */
67
+ highlight: [item: NexxtPickerItemData | null]
68
+ /**
69
+ * Escape in an empty search field, or in the listbox. A surrounding `NexxtFloatingPanel`
70
+ * closes on the same key, so keep the handler idempotent.
71
+ */
72
+ close: []
73
+ }>()
74
+
75
+ defineSlots<{
76
+ /** Above both columns, e.g. `NexxtTabs`. */
77
+ header?(): unknown
78
+ /** Content of the preview frame for the highlighted item. */
79
+ preview?(props: { item: NexxtPickerItemData }): unknown
80
+ /** Replaces `previewEmptyText` in the preview frame. */
81
+ 'preview-empty'?(): unknown
82
+ /** Content of an option. */
83
+ item?(props: { item: NexxtPickerItemData; highlighted: boolean }): unknown
84
+ /** Replaces the content of the empty search state. */
85
+ empty?(props: { search: string; clear: () => void }): unknown
86
+ }>()
87
+
88
+ const inputField = useTemplateRef<InstanceType<typeof NexxtInputField>>('inputField')
89
+ const list = useTemplateRef<InstanceType<typeof NexxtPickerList>>('list')
90
+ const row = useTemplateRef<HTMLElement>('row')
91
+
92
+ const PAGE_SIZE = 8
93
+
94
+ const toLength = (value: number | string) => (typeof value === 'number' ? `${value}px` : value)
95
+
96
+ const panelStyle = computed(() => {
97
+ const height = `min(${toLength(props.height)}, calc(100dvh - 5rem))`
98
+ return { width: toLength(props.width), height, maxHeight: height }
99
+ })
100
+
101
+ // Layout height of the column row. Not `getBoundingClientRect()`: a FloatingPanel scales its
102
+ // content in, which would read 5% short. 0 means unknown (jsdom, a hidden tab): show the preview.
103
+ const rowHeight = ref(0)
104
+
105
+ useResizeObserver(row, ([entry]) => {
106
+ rowHeight.value =
107
+ entry.borderBoxSize?.[0]?.blockSize ?? (entry.target as HTMLElement).offsetHeight
108
+ })
109
+
110
+ const showPreview = computed(
111
+ () => rowHeight.value === 0 || rowHeight.value >= props.hidePreviewBelow,
112
+ )
113
+
114
+ const highlightedItem = computed(
115
+ () => props.items.find((item) => item.id === highlighted.value) ?? null,
116
+ )
117
+
118
+ /** Focuses the search field, or the listbox when the panel is not searchable. */
119
+ const focus = () => {
120
+ if (props.searchable) inputField.value?.inputEl?.focus({ preventScroll: true })
121
+ else list.value?.focus()
122
+ }
123
+
124
+ onMounted(async () => {
125
+ // Measured before the first paint, so the preview never flashes in and out
126
+ if (row.value) rowHeight.value = row.value.offsetHeight
127
+
128
+ // One tick for the list's template ref to land, so the field already carries
129
+ // `aria-controls` and `aria-activedescendant` when it receives focus. `preventScroll`: a
130
+ // FloatingPanel is still in its enter transition here.
131
+ await nextTick()
132
+ focus()
133
+ })
134
+
135
+ const onSearchKeydown = (event: KeyboardEvent) => {
136
+ if (event.isComposing) return
137
+
138
+ switch (event.key) {
139
+ case 'ArrowDown':
140
+ list.value?.move(1)
141
+ break
142
+ case 'ArrowUp':
143
+ list.value?.move(-1)
144
+ break
145
+ case 'PageDown':
146
+ list.value?.move(PAGE_SIZE)
147
+ break
148
+ case 'PageUp':
149
+ list.value?.move(-PAGE_SIZE)
150
+ break
151
+ case 'Enter':
152
+ // Always prevented: the field may sit in a form
153
+ list.value?.selectHighlighted()
154
+ break
155
+ case 'Escape':
156
+ if (search.value === '') {
157
+ // Left to propagate: a surrounding FloatingPanel closes on it too
158
+ emit('close')
159
+ return
160
+ }
161
+ search.value = ''
162
+ // A FloatingPanel listens on `document` and would close on the first press already
163
+ event.stopPropagation()
164
+ break
165
+ // Home, End, ← and → stay cursor keys in a text field
166
+ default:
167
+ return
168
+ }
169
+
170
+ event.preventDefault()
171
+ }
172
+
173
+ // Focus moves in the same handler, before the clear button leaves the DOM with the empty state
174
+ const onClear = () => {
175
+ search.value = ''
176
+ focus()
177
+ }
178
+
179
+ defineExpose({ focus })
180
+
181
+ const COLUMN_CLASSES = {
182
+ withPreview: 'flex w-66 shrink-0 flex-col border-r border-gray-200',
183
+ full: 'flex w-full flex-col',
184
+ }
185
+ </script>
186
+
187
+ <template>
188
+ <div
189
+ role="group"
190
+ :aria-label="ariaLabel"
191
+ class="flex flex-col overflow-hidden"
192
+ :style="panelStyle"
193
+ >
194
+ <div v-if="$slots.header" class="shrink-0 border-b border-gray-200 p-3">
195
+ <slot name="header" />
196
+ </div>
197
+
198
+ <div ref="row" class="flex min-h-0 flex-1">
199
+ <div :class="showPreview ? COLUMN_CLASSES.withPreview : COLUMN_CLASSES.full">
200
+ <!-- `text-gray-700` colours the magnifying glass; the input sets its own text colour.
201
+ `[&_input]:min-w-0` lifts the input's own `min-w-60` inside the panel only; with it
202
+ the field overflows the 240px column and a long term loses its caret. -->
203
+ <div v-if="searchable" class="shrink-0 border-b border-gray-200 p-3 text-gray-700">
204
+ <NexxtInputField
205
+ ref="inputField"
206
+ v-model="search"
207
+ type="text"
208
+ role="combobox"
209
+ aria-expanded="true"
210
+ aria-autocomplete="list"
211
+ autocomplete="off"
212
+ spellcheck="false"
213
+ left-icon="magnifying-glass"
214
+ class="w-full [&_input]:min-w-0"
215
+ :placeholder="searchPlaceholder"
216
+ :aria-label="searchLabel"
217
+ :aria-controls="list?.listboxId"
218
+ :aria-activedescendant="list?.activeDescendantId"
219
+ @keydown="onSearchKeydown"
220
+ />
221
+ </div>
222
+
223
+ <NexxtPickerList
224
+ ref="list"
225
+ v-model:highlighted="highlighted"
226
+ class="min-h-0 flex-1"
227
+ :items="items"
228
+ :groups="groups"
229
+ :search="search"
230
+ :collapsible="collapsible"
231
+ :aria-label="listLabel"
232
+ :empty-title="emptyTitle"
233
+ :empty-description="emptyDescription"
234
+ :clear-label="clearLabel"
235
+ :results-announcement="resultsAnnouncement"
236
+ @select="emit('select', $event)"
237
+ @highlight="emit('highlight', $event)"
238
+ @close="emit('close')"
239
+ @clear="onClear"
240
+ >
241
+ <template v-if="$slots.item" #item="scope">
242
+ <slot name="item" v-bind="scope" />
243
+ </template>
244
+ <template v-if="$slots.empty" #empty="scope">
245
+ <slot name="empty" v-bind="scope" />
246
+ </template>
247
+ </NexxtPickerList>
248
+ </div>
249
+
250
+ <div v-if="showPreview" class="min-w-0 flex-1 overflow-hidden p-4">
251
+ <NexxtPickerPreview
252
+ :content-key="highlighted ?? undefined"
253
+ :title="highlightedItem?.label"
254
+ :description="highlightedItem?.description"
255
+ :empty-text="previewEmptyText"
256
+ >
257
+ <template v-if="highlightedItem && $slots.preview" #default>
258
+ <slot name="preview" :item="highlightedItem" />
259
+ </template>
260
+ <template v-if="$slots['preview-empty']" #empty>
261
+ <slot name="preview-empty" />
262
+ </template>
263
+ </NexxtPickerPreview>
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </template>
@@ -9,6 +9,8 @@
9
9
  "NexxtFloatingPanel": "components/atoms/FloatingPanel/FloatingPanel.vue",
10
10
  "NexxtIcon": "components/atoms/Icon/Icon.vue",
11
11
  "NexxtLoader": "components/atoms/Loader/Loader.vue",
12
+ "NexxtPickerItem": "components/atoms/PickerItem/PickerItem.vue",
13
+ "NexxtPickerPreview": "components/atoms/PickerPreview/PickerPreview.vue",
12
14
  "NexxtPresenceIndicator": "components/atoms/PresenceIndicator/PresenceIndicator.vue",
13
15
  "NexxtPropertyListing": "components/atoms/PropertyListing/PropertyListing.vue",
14
16
  "NexxtSelectableOption": "components/atoms/SelectableOption/SelectableOption.vue",
@@ -30,6 +32,8 @@
30
32
  "NexxtPageMockup": "components/molecules/PageMockup/PageMockup.vue",
31
33
  "NexxtPageTypeTile": "components/molecules/PageTypeTile/PageTypeTile.vue",
32
34
  "NexxtPagination": "components/molecules/Pagination/Pagination.vue",
35
+ "NexxtPickerGroup": "components/molecules/PickerGroup/PickerGroup.vue",
36
+ "NexxtPickerList": "components/molecules/PickerList/PickerList.vue",
33
37
  "NexxtPlaceholderInput": "components/molecules/PlaceholderInput/PlaceholderInput.vue",
34
38
  "NexxtPropertyCard": "components/molecules/PropertyCard/PropertyCard.vue",
35
39
  "NexxtSelect": "components/molecules/Select/Select.vue",
@@ -57,6 +61,7 @@
57
61
  "NexxtDropdownButton": "components/organisms/DropdownButton/DropdownButton.vue",
58
62
  "NexxtHeader": "components/organisms/Header/Header.vue",
59
63
  "NexxtModal": "components/organisms/Modal/Modal.vue",
64
+ "NexxtPickerPanel": "components/organisms/PickerPanel/PickerPanel.vue",
60
65
  "NexxtProgressBar": "components/organisms/ProgressBar/ProgressBar.vue",
61
66
  "NexxtReferenceCard": "components/organisms/ReferenceCard/ReferenceCard.vue",
62
67
  "NexxtSocialMediaTemplate": "components/organisms/SocialMediaTemplate/SocialMediaTemplate.vue",
package/src/index.ts CHANGED
@@ -18,6 +18,10 @@ 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 {
22
+ NexxtPickerGroupData,
23
+ NexxtPickerItemData,
24
+ } from './components/molecules/PickerList/PickerList.vue'
21
25
  export type {
22
26
  NexxtTreeNode,
23
27
  NexxtTreeProps,
@@ -57,6 +61,7 @@ export type {
57
61
  EditorHeadingTypeMessages,
58
62
  EditorHeadingTypeValue,
59
63
  } from './components/editor/molecules/EditorHeadingType/EditorHeadingType.vue'
64
+ export { EDITOR_RADIUS_STEPS } from './components/editor/molecules/EditorRadius/EditorRadius.vue'
60
65
  export type {
61
66
  EditorRadiusMessages,
62
67
  EditorRadiusValue,
@@ -126,6 +131,11 @@ export { default as NexxtPageContainer } from './components/molecules/PageContai
126
131
  export { default as NexxtPageMockup } from './components/molecules/PageMockup/PageMockup.vue'
127
132
  export { default as NexxtPageTypeTile } from './components/molecules/PageTypeTile/PageTypeTile.vue'
128
133
  export { default as NexxtPagination } from './components/molecules/Pagination/Pagination.vue'
134
+ export { default as NexxtPickerGroup } from './components/molecules/PickerGroup/PickerGroup.vue'
135
+ export { default as NexxtPickerItem } from './components/atoms/PickerItem/PickerItem.vue'
136
+ export { default as NexxtPickerList } from './components/molecules/PickerList/PickerList.vue'
137
+ export { default as NexxtPickerPanel } from './components/organisms/PickerPanel/PickerPanel.vue'
138
+ export { default as NexxtPickerPreview } from './components/atoms/PickerPreview/PickerPreview.vue'
129
139
  export { default as NexxtPlaceholderInput } from './components/molecules/PlaceholderInput/PlaceholderInput.vue'
130
140
  export { default as NexxtPresenceIndicator } from './components/atoms/PresenceIndicator/PresenceIndicator.vue'
131
141
  export { default as NexxtProgressBar } from './components/organisms/ProgressBar/ProgressBar.vue'