@nexxtmove/ui 1.39.0 → 1.40.1

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nexxtmove/ui",
3
3
  "type": "module",
4
- "version": "1.39.0",
4
+ "version": "1.40.1",
5
5
  "exports": {
6
6
  ".": {
7
7
  "types": "./dist/index.d.ts",
@@ -12,6 +12,11 @@ interface NexxtEditorSettingsBlockProps {
12
12
  switchable?: boolean
13
13
  }
14
14
 
15
+ /**
16
+ * One labelled setting in the editor sidebar, optionally collapsible via a
17
+ * switch. The block draws no separator of its own: the consumer groups and
18
+ * divides blocks on the wrapper, e.g. `divide-y divide-gray-200`.
19
+ */
15
20
  defineOptions({ name: 'NexxtEditorSettingsBlock' })
16
21
 
17
22
  const { label, switchable = false } = defineProps<NexxtEditorSettingsBlockProps>()
@@ -32,7 +37,7 @@ const inert = computed(() => (open.value ? undefined : true))
32
37
  </script>
33
38
 
34
39
  <template>
35
- <div class="border-b border-gray-200 py-4 last:border-b-0">
40
+ <div class="py-4">
36
41
  <!--
37
42
  Switch.vue sets `inheritAttrs: false` and binds `$attrs` on its sr-only
38
43
  input, so a class on <Switch> never reaches its root <label>. The child
@@ -1,9 +1,10 @@
1
1
  <script lang="ts" setup>
2
- import { ref, useTemplateRef, computed, onMounted, onUnmounted } from 'vue'
2
+ import { ref, useTemplateRef, computed, onMounted, onUnmounted, useId } from 'vue'
3
3
  import NexxtChip from '../../atoms/Chip/Chip.vue'
4
4
  import NexxtIcon from '../../atoms/Icon/Icon.vue'
5
5
  import NexxtInputField from '../../molecules/InputField/InputField.vue'
6
- import type { ComboboxOptions } from './combobox-types'
6
+ import ComboboxPanelOptions, { type OptionSection } from './_ComboboxPanelOptions.vue'
7
+ import type { ComboboxOption, ComboboxOptions, NexxtComboboxGroup } from './combobox-types'
7
8
 
8
9
  type ComboboxPanelProps = {
9
10
  icon?: InstanceType<typeof NexxtIcon>['name']
@@ -57,20 +58,53 @@ const getLabel = (option: string | { label: string; value: string | number }) =>
57
58
  const getValue = (option: string | { label: string; value: string | number }) =>
58
59
  typeof option === 'string' ? option : option.value
59
60
 
61
+ const isGroup = (item: ComboboxOption | NexxtComboboxGroup): item is NexxtComboboxGroup =>
62
+ typeof item === 'object' && 'options' in item
63
+
64
+ const groupIdBase = useId()
65
+
66
+ // Flat options become one untitled section, so everything below runs on one shape. Ids come from
67
+ // the group's position in `options`, not in the filtered list, so a heading keeps its id while
68
+ // searching
69
+ const sections = computed((): OptionSection[] => {
70
+ const items: (ComboboxOption | NexxtComboboxGroup)[] = props.options
71
+ const groups = items.filter(isGroup)
72
+ if (groups.length === 0) {
73
+ return [
74
+ { id: groupIdBase, options: items.filter((item): item is ComboboxOption => !isGroup(item)) },
75
+ ]
76
+ }
77
+ return groups.map((group, index) => ({
78
+ id: `${groupIdBase}-group-${index}`,
79
+ label: group.label,
80
+ options: group.options,
81
+ }))
82
+ })
83
+
84
+ const allOptions = computed(() => sections.value.flatMap((section) => section.options))
85
+
60
86
  const selectedLabels = computed(() =>
61
87
  selected.value.map((val) => {
62
- const match = props.options.find((o) => getValue(o) === val)
88
+ const match = allOptions.value.find((o) => getValue(o) === val)
63
89
  return { value: val, label: match ? getLabel(match) : String(val), isCustom: !match }
64
90
  }),
65
91
  )
66
92
 
67
- const filteredOptions = computed(() => {
93
+ // Matches on the option label only; a group without matches drops out, heading included
94
+ const filteredSections = computed(() => {
68
95
  const q = search.value.toLowerCase()
69
- return props.options
70
- .filter((o) => props.singleSelect || !selected.value.includes(getValue(o)))
71
- .filter((o) => getLabel(o).toLowerCase().includes(q))
96
+ return sections.value
97
+ .map((section) => ({
98
+ ...section,
99
+ options: section.options
100
+ .filter((o) => props.singleSelect || !selected.value.includes(getValue(o)))
101
+ .filter((o) => getLabel(o).toLowerCase().includes(q)),
102
+ }))
103
+ .filter((section) => section.options.length > 0)
72
104
  })
73
105
 
106
+ const filteredOptions = computed(() => filteredSections.value.flatMap((section) => section.options))
107
+
74
108
  const showAddHint = computed(() => {
75
109
  if (!props.allowNew || !search.value) return false
76
110
  if (filteredOptions.value.length > 0) return false
@@ -92,6 +126,9 @@ const toggleSingle = (option: string | { label: string; value: string | number }
92
126
  emit('select')
93
127
  }
94
128
 
129
+ const pickOption = (option: ComboboxOption) =>
130
+ props.singleSelect ? toggleSingle(option) : selectOption(option)
131
+
95
132
  const handleEnter = () => {
96
133
  if (props.singleSelect) {
97
134
  if (filteredOptions.value.length > 0) toggleSingle(filteredOptions.value[0])
@@ -110,7 +147,7 @@ const addNewItem = () => {
110
147
  if (!trimmed) return
111
148
  const lower = trimmed.toLowerCase()
112
149
  const alreadySelected = selectedLabels.value.some((item) => item.label.toLowerCase() === lower)
113
- const existsAsOption = props.options.some((o) => getLabel(o).toLowerCase() === lower)
150
+ const existsAsOption = allOptions.value.some((o) => getLabel(o).toLowerCase() === lower)
114
151
  if (alreadySelected || existsAsOption) return
115
152
  if (props.singleSelect) {
116
153
  selected.value = [trimmed]
@@ -210,25 +247,12 @@ defineExpose({ focus })
210
247
  tabindex="0"
211
248
  class="scrollbar-thin max-h-56 overflow-y-auto"
212
249
  >
213
- <li
214
- v-for="option in filteredOptions"
215
- :key="getValue(option)"
216
- role="option"
217
- :aria-selected="selected.includes(getValue(option))"
218
- class="flex w-full cursor-pointer items-center gap-2 px-6 py-3 text-left text-sm whitespace-nowrap text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-purple-600 focus:bg-gray-100 focus:outline-none"
219
- :class="{
220
- 'font-medium text-cornflower-blue-600':
221
- singleSelect && selected.includes(getValue(option)),
222
- }"
223
- @click="singleSelect ? toggleSingle(option) : selectOption(option)"
224
- >
225
- {{ getLabel(option) }}
226
- <NexxtIcon
227
- v-if="singleSelect && selected.includes(getValue(option))"
228
- name="check"
229
- class="ml-auto shrink-0 text-xs"
230
- />
231
- </li>
250
+ <ComboboxPanelOptions
251
+ :sections="filteredSections"
252
+ :selected
253
+ :single-select
254
+ @pick="pickOption"
255
+ />
232
256
  </ul>
233
257
  <div
234
258
  v-if="showAddHint"
@@ -299,25 +323,13 @@ defineExpose({ focus })
299
323
  </TransitionGroup>
300
324
  </Transition>
301
325
  <ul role="listbox" aria-multiselectable="true" class="scrollbar-thin max-h-56 overflow-y-auto">
302
- <li
303
- v-for="option in filteredOptions"
304
- :key="getValue(option)"
305
- role="option"
306
- :aria-selected="selected.includes(getValue(option))"
307
- class="flex w-full min-w-42 cursor-pointer items-center gap-2 px-6 py-3 text-left text-sm whitespace-nowrap text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-purple-600 focus:bg-gray-100 focus:outline-none"
308
- :class="{
309
- 'font-medium text-cornflower-blue-600':
310
- singleSelect && selected.includes(getValue(option)),
311
- }"
312
- @click="singleSelect ? toggleSingle(option) : selectOption(option)"
313
- >
314
- {{ getLabel(option) }}
315
- <NexxtIcon
316
- v-if="singleSelect && selected.includes(getValue(option))"
317
- name="check"
318
- class="ml-auto shrink-0 text-xs"
319
- />
320
- </li>
326
+ <ComboboxPanelOptions
327
+ :sections="filteredSections"
328
+ :selected
329
+ :single-select
330
+ inline
331
+ @pick="pickOption"
332
+ />
321
333
  </ul>
322
334
  <div
323
335
  v-if="showAddHint"
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ import type { ComboboxOption } from './combobox-types'
3
+
4
+ /** A run of options in the listbox; `label` is set for a titled group only. */
5
+ export type OptionSection = { id: string; label?: string; options: ComboboxOption[] }
6
+
7
+ export interface ComboboxPanelOptionsProps {
8
+ /** Visible options per section. An untitled section renders its options straight into the list. */
9
+ sections: OptionSection[]
10
+ /** Selected values. */
11
+ selected: (string | number)[]
12
+ /** Marks the selected option with a tint and a check. */
13
+ singleSelect?: boolean
14
+ /** Inline layout (inside `NexxtCombobox`): rows get a minimum width. */
15
+ inline?: boolean
16
+ }
17
+ </script>
18
+
19
+ <script lang="ts" setup>
20
+ import { createReusableTemplate } from '@vueuse/core'
21
+ import NexxtIcon from '../../atoms/Icon/Icon.vue'
22
+
23
+ defineOptions({ name: 'NexxtComboboxPanelOptions' })
24
+
25
+ const {
26
+ sections,
27
+ selected,
28
+ singleSelect = false,
29
+ inline = false,
30
+ } = defineProps<ComboboxPanelOptionsProps>()
31
+
32
+ const emit = defineEmits<{ pick: [option: ComboboxOption] }>()
33
+
34
+ const getLabel = (option: ComboboxOption) => (typeof option === 'string' ? option : option.label)
35
+
36
+ const getValue = (option: ComboboxOption) => (typeof option === 'string' ? option : option.value)
37
+
38
+ const isSelected = (option: ComboboxOption) => selected.includes(getValue(option))
39
+
40
+ const isActive = (option: ComboboxOption) => singleSelect && isSelected(option)
41
+
42
+ // One option row, shared by grouped and flat sections. Both bindings are declared as props:
43
+ // undeclared, they would be attrs and fall through onto the `<li>`
44
+ const [DefineOption, ReuseOption] = createReusableTemplate<{
45
+ option: ComboboxOption
46
+ indented?: boolean
47
+ }>({
48
+ props: { option: { type: [String, Object], required: true }, indented: Boolean },
49
+ })
50
+
51
+ const OPTION_CLASS =
52
+ 'flex w-full cursor-pointer items-center gap-2 py-3 text-left text-sm whitespace-nowrap text-gray-700 transition-colors duration-200 hover:bg-gray-100 hover:text-purple-600 focus:bg-gray-100 focus:outline-none'
53
+
54
+ // Options in a group step in 12px past their heading, so the hierarchy reads at a glance
55
+ const optionClass = (option: ComboboxOption, indented = false) => [
56
+ OPTION_CLASS,
57
+ indented ? 'pr-6 pl-9' : 'px-6',
58
+ { 'min-w-42': inline, 'font-medium text-cornflower-blue-600': isActive(option) },
59
+ ]
60
+ </script>
61
+
62
+ <template>
63
+ <DefineOption v-slot="{ option, indented }">
64
+ <li
65
+ role="option"
66
+ :aria-selected="isSelected(option)"
67
+ :class="optionClass(option, indented)"
68
+ @click="emit('pick', option)"
69
+ >
70
+ {{ getLabel(option) }}
71
+ <NexxtIcon v-if="isActive(option)" name="check" class="ml-auto shrink-0 text-xs" />
72
+ </li>
73
+ </DefineOption>
74
+
75
+ <template v-for="section in sections" :key="section.id">
76
+ <!-- `<li>` may not carry role="group", so the group sits in a presentational item. A listbox
77
+ may only hold options and groups: the heading is presentational too, and names the group
78
+ through `aria-labelledby` -->
79
+ <li v-if="section.label !== undefined" role="none">
80
+ <div role="group" :aria-labelledby="section.id">
81
+ <div
82
+ role="presentation"
83
+ class="px-6 pt-3 pb-1 extra-small-semibold tracking-wide text-gray-700 uppercase select-none"
84
+ >
85
+ <span :id="section.id">{{ section.label }}</span>
86
+ </div>
87
+ <ul role="none">
88
+ <ReuseOption v-for="option in section.options" :key="getValue(option)" :option indented />
89
+ </ul>
90
+ </div>
91
+ </li>
92
+ <template v-else>
93
+ <ReuseOption v-for="option in section.options" :key="getValue(option)" :option />
94
+ </template>
95
+ </template>
96
+ </template>
@@ -1,5 +1,24 @@
1
1
  export type ComboboxOption = string | { label: string; value: string | number }
2
2
 
3
+ /**
4
+ * A titled group of options. Option values must be unique across **all** groups: selection is
5
+ * stored by value only, so a value that appears twice cannot tell its groups apart.
6
+ */
7
+ export type NexxtComboboxGroup = {
8
+ /** Heading shown above the group, and the accessible name of its `role="group"`. */
9
+ label: string
10
+ /** The options of this group. */
11
+ options: ComboboxOption[]
12
+ }
13
+
3
14
  export type ComboboxOptions =
4
15
  | { options: string[]; allowNew?: boolean }
5
16
  | { options: { label: string; value: string | number }[]; allowNew?: never }
17
+ | {
18
+ /**
19
+ * Options in titled groups. Values must be unique across all groups. Groups cannot be
20
+ * combined with `allowNew`.
21
+ */
22
+ options: NexxtComboboxGroup[]
23
+ allowNew?: never
24
+ }
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ export type { EnergyLabelLetter } from './components/atoms/EnergyLabel/EnergyLab
13
13
  export type { IconType, NexxtIconName } from './components/atoms/Icon/Icon.vue'
14
14
  export type { CustomIconName } from './components/atoms/Icon/customIcons'
15
15
  export type { NexxtTabItem } from './components/molecules/Tabs/Tabs.vue'
16
+ export type { NexxtComboboxGroup } from './components/organisms/ComboboxPanel/combobox-types'
16
17
  export type {
17
18
  NexxtPageCardProps,
18
19
  NexxtPageCardSlots,