@nexxtmove/ui 1.33.0 → 1.35.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.
@@ -19,6 +19,7 @@ import NexxtFloatingPanel from '../../atoms/FloatingPanel/FloatingPanel.vue'
19
19
  import NexxtIcon from '../../atoms/Icon/Icon.vue'
20
20
 
21
21
  export type ColorFormat = 'hex' | 'hsl' | 'rgb'
22
+ export type ColorSuggestions = Partial<Record<ColorFormat, string[]>>
22
23
 
23
24
  export interface NexxtColorPickerProps {
24
25
  label?: string
@@ -30,6 +31,10 @@ export interface NexxtColorPickerProps {
30
31
  formats?: ColorFormat[]
31
32
  fullWidth?: boolean
32
33
  matchWidth?: boolean
34
+ /** Existing colours grouped by format; only suggestions for the active format are shown. */
35
+ suggestions?: ColorSuggestions
36
+ /** Accessible and visible label for the suggestions. */
37
+ suggestionsLabel?: string
33
38
  }
34
39
 
35
40
  defineOptions({ name: 'NexxtColorPicker' })
@@ -42,22 +47,62 @@ const props = withDefaults(defineProps<NexxtColorPickerProps>(), {
42
47
  formats: () => ['hex'],
43
48
  fullWidth: false,
44
49
  matchWidth: false,
50
+ suggestions: () => ({}),
51
+ suggestionsLabel: 'Gebruikte kleuren',
45
52
  })
46
53
 
47
54
  const model = defineModel<string>({ default: '' })
48
55
 
49
- const colorObj = ref<Color>(normalizeColor(model.value || '#3b82f6'))
50
- const hexInputError = ref('')
56
+ const withoutAlpha = (color: Color) =>
57
+ color.alpha < 1 ? setChannelValue(color, 'alpha', 100) : color
58
+
59
+ const normalizeModelColor = (value: string) => {
60
+ const color = normalizeColor(value)
61
+
62
+ return props.showAlpha ? color : withoutAlpha(color)
63
+ }
64
+
65
+ const normalizeInputColor = (value: string) => {
66
+ const color = normalizeColor(value)
67
+
68
+ if (!props.showAlpha && color.alpha < 1) {
69
+ throw new Error('Transparantie is niet beschikbaar')
70
+ }
71
+
72
+ return color
73
+ }
74
+
51
75
  const activeFormatIndex = ref(0)
76
+ const activeFormat = computed<ColorFormat>(() => props.formats[activeFormatIndex.value] ?? 'hex')
77
+ const fmt = (color: Color, format: ColorFormat) => {
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ return colorToString(color, format as any)
80
+ }
81
+
82
+ const colorObj = ref<Color>(normalizeModelColor(model.value || '#3b82f6'))
83
+ const hexInputError = ref('')
52
84
  const channelInputErrors = ref<Partial<Record<ColorChannel, string>>>({})
53
85
 
54
86
  const hexColor = computed(() => colorToString(colorObj.value, 'hex'))
55
- const activeFormat = computed<ColorFormat>(() => props.formats[activeFormatIndex.value] ?? 'hex')
56
87
  const displayValue = computed(() => (model.value ? fmt(colorObj.value, activeFormat.value) : ''))
88
+ const colorSuggestions = computed(() => {
89
+ const values = new Set<string>()
57
90
 
58
- const fmt = (color: Color, format: ColorFormat) => {
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- return colorToString(color, format as any)
91
+ props.suggestions[activeFormat.value]?.forEach((suggestion) => {
92
+ try {
93
+ const color = normalizeInputColor(suggestion)
94
+ values.add(fmt(color, activeFormat.value))
95
+ } catch {
96
+ // Consumers can derive suggestions from stored classes; ignore malformed or unsupported values safely.
97
+ }
98
+ })
99
+
100
+ return [...values]
101
+ })
102
+
103
+ const selectSuggestion = (suggestion: string) => {
104
+ colorObj.value = normalizeInputColor(suggestion)
105
+ model.value = fmt(colorObj.value, activeFormat.value)
61
106
  }
62
107
 
63
108
  const cycleFormat = (direction: 1 | -1) => {
@@ -67,14 +112,15 @@ const cycleFormat = (direction: 1 | -1) => {
67
112
  }
68
113
 
69
114
  const handleColorUpdate = (newColor: Color) => {
70
- colorObj.value = newColor
71
- model.value = fmt(newColor, activeFormat.value)
115
+ const updated = props.showAlpha ? newColor : withoutAlpha(newColor)
116
+ colorObj.value = updated
117
+ model.value = fmt(updated, activeFormat.value)
72
118
  }
73
119
 
74
120
  const handleHexInput = (event: Event) => {
75
121
  const val = (event.target as HTMLInputElement).value
76
122
  try {
77
- colorObj.value = normalizeColor(val)
123
+ colorObj.value = normalizeInputColor(val)
78
124
  model.value = fmt(colorObj.value, activeFormat.value)
79
125
  hexInputError.value = ''
80
126
  } catch (e) {
@@ -103,12 +149,23 @@ watch(
103
149
  (val) => {
104
150
  if (!val) return
105
151
  try {
106
- colorObj.value = normalizeColor(val)
152
+ colorObj.value = normalizeModelColor(val)
107
153
  } catch {
108
154
  // Ignore invalid color strings
109
155
  }
110
156
  },
111
157
  )
158
+
159
+ watch(
160
+ () => props.showAlpha,
161
+ (showAlpha) => {
162
+ if (!showAlpha && colorObj.value.alpha < 1) {
163
+ const updated = withoutAlpha(colorObj.value)
164
+ colorObj.value = updated
165
+ model.value = fmt(updated, activeFormat.value)
166
+ }
167
+ },
168
+ )
112
169
  </script>
113
170
 
114
171
  <template>
@@ -386,6 +443,25 @@ watch(
386
443
  </ColorFieldRoot>
387
444
  </div>
388
445
  </div>
446
+ <div v-if="colorSuggestions.length" class="flex flex-col gap-1">
447
+ <span class="extra-small-normal text-gray-500">{{ suggestionsLabel }}</span>
448
+ <div class="flex flex-wrap gap-1.5">
449
+ <button
450
+ v-for="suggestion in colorSuggestions"
451
+ :key="suggestion"
452
+ type="button"
453
+ class="rounded-md p-0.5 ring-gray-200 transition-shadow hover:ring-2 hover:ring-cornflower-blue-400 focus-visible:ring-2 focus-visible:ring-cornflower-blue-500 focus-visible:outline-none"
454
+ :aria-label="`Kies kleur ${suggestion}`"
455
+ @click="selectSuggestion(suggestion)"
456
+ >
457
+ <ColorSwatch
458
+ :color="suggestion"
459
+ class="size-6 rounded-sm shadow-[inset_0_0_0_1px_rgba(0,0,0,0.1)]"
460
+ :style="{ backgroundColor: 'var(--reka-color-swatch-color)' }"
461
+ />
462
+ </button>
463
+ </div>
464
+ </div>
389
465
  </div>
390
466
  </template>
391
467
  </NexxtFloatingPanel>
@@ -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,
@@ -127,6 +131,11 @@ export { default as NexxtPageContainer } from './components/molecules/PageContai
127
131
  export { default as NexxtPageMockup } from './components/molecules/PageMockup/PageMockup.vue'
128
132
  export { default as NexxtPageTypeTile } from './components/molecules/PageTypeTile/PageTypeTile.vue'
129
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'
130
139
  export { default as NexxtPlaceholderInput } from './components/molecules/PlaceholderInput/PlaceholderInput.vue'
131
140
  export { default as NexxtPresenceIndicator } from './components/atoms/PresenceIndicator/PresenceIndicator.vue'
132
141
  export { default as NexxtProgressBar } from './components/organisms/ProgressBar/ProgressBar.vue'