@nexxtmove/ui 1.47.0 → 1.48.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,282 @@
1
+ <script lang="ts">
2
+ import type { NexxtIconPickerIcon } from './IconPicker.vue'
3
+ import type { GridPosition, GridRow } from './useIconPickerCatalogue'
4
+
5
+ interface IconPickerGridProps {
6
+ /** Every row of the list on show; only those in view (and the overscan) are rendered. */
7
+ rows: GridRow[]
8
+ /** Skeleton cells instead of the rows: the catalogue is on its way. */
9
+ skeleton?: boolean
10
+ /** Id of the grid element, for the search field's `aria-controls`. */
11
+ gridId: string
12
+ /** Id of the active tab that names the grid. */
13
+ labelledby: string
14
+ /** A cell's id is this prefix, its row and its column. */
15
+ cellIdPrefix: string
16
+ /** Id of the hidden "Huidige keuze", the description of the selected cell. */
17
+ currentDescriptionId: string
18
+ /** The highlighted cell; drawn while the grid has focus. */
19
+ highlight: GridPosition | null
20
+ /** Whether the grid has focus. */
21
+ focused: boolean
22
+ /** The one selected cell: the first place of the current icon. */
23
+ selected: GridPosition | null
24
+ /** The classes that draw an icon's glyph in the style a choice would write. */
25
+ glyphClass: (icon: NexxtIconPickerIcon) => string[]
26
+ }
27
+
28
+ /** The cell size until the grid has been measured: what the 576px panel gives. */
29
+ const FALLBACK_CELL = 51
30
+ const CELL_GAP = 12
31
+ const HEADING_HEIGHT = 32
32
+ /** The grid's `p-2`. */
33
+ const GRID_PADDING = 8
34
+ const OVERSCAN = 3
35
+
36
+ /**
37
+ * The selected glyph is cornflower-blue-600 on -100: 3.6:1, past the 3:1 a meaningful graphic
38
+ * needs. A highlight on a selected cell sits on its ring, so its outline moves in by the ring's
39
+ * width.
40
+ */
41
+ const CELL_CLASSES = {
42
+ base: 'flex aspect-square w-full cursor-pointer items-center justify-center rounded-lg text-2xl transition-colors duration-100 ease-out motion-reduce:transition-none',
43
+ idle: 'text-gray-800 hover:bg-gray-100',
44
+ selected:
45
+ 'bg-cornflower-blue-100 text-cornflower-blue-600 ring-2 ring-cornflower-blue-500 ring-inset',
46
+ highlighted: 'outline-2 -outline-offset-2 outline-cornflower-blue-500',
47
+ highlightedSelected: 'outline-2 -outline-offset-4 outline-cornflower-blue-500',
48
+ }
49
+ </script>
50
+
51
+ <script lang="ts" setup>
52
+ import { computed, ref } from 'vue'
53
+ import { useResizeObserver, useVirtualList } from '@vueuse/core'
54
+ import NexxtSkeleton from '../../atoms/Skeleton/Skeleton.vue'
55
+ import { COLUMNS } from './useIconPickerCatalogue'
56
+ import type { IconPickerGridHandle } from './useIconPickerNavigation'
57
+
58
+ defineOptions({ name: 'NexxtIconPickerGrid' })
59
+
60
+ const {
61
+ rows,
62
+ skeleton = false,
63
+ gridId,
64
+ labelledby,
65
+ cellIdPrefix,
66
+ currentDescriptionId,
67
+ highlight,
68
+ focused,
69
+ selected,
70
+ glyphClass,
71
+ } = defineProps<IconPickerGridProps>()
72
+
73
+ const emit = defineEmits<{
74
+ /** The grid took focus. */
75
+ focus: []
76
+ /** The grid lost focus. */
77
+ blur: []
78
+ /** A key on the grid; the navigation decides. */
79
+ keydown: [event: KeyboardEvent]
80
+ /** The pointer is on a cell: it becomes the highlight. */
81
+ hover: [position: GridPosition]
82
+ /** A click on a cell. */
83
+ choose: [icon: NexxtIconPickerIcon]
84
+ }>()
85
+
86
+ const SKELETON_CELLS = 6 * COLUMNS
87
+
88
+ /** The grid's content width, without padding and scrollbar; 0 until measured. */
89
+ const gridWidth = ref(0)
90
+
91
+ /**
92
+ * The cells share the grid's width and stay square, so a row is one cell plus the gap: about
93
+ * 51 + 12px in the 576px panel. The width is measured, as a scrollbar or a narrow viewport takes
94
+ * from it.
95
+ */
96
+ const cellSize = computed(() =>
97
+ gridWidth.value > 0 ? (gridWidth.value - (COLUMNS - 1) * CELL_GAP) / COLUMNS : FALLBACK_CELL,
98
+ )
99
+
100
+ /** A row of cells: one cell plus the gap under it. */
101
+ const iconRowHeight = computed(() => cellSize.value + CELL_GAP)
102
+
103
+ // Exactly the height the virtual list counts with, so its offsets never drift from the layout
104
+ const iconRowStyle = computed(() => ({ height: `${iconRowHeight.value}px` }))
105
+
106
+ const rowHeight = (index: number) =>
107
+ rows[index]?.kind === 'heading' ? HEADING_HEIGHT : iconRowHeight.value
108
+
109
+ /** Where each row starts in the grid's content, without the padding. */
110
+ const rowTops = computed(() => {
111
+ let top = 0
112
+ return rows.map((_, index) => {
113
+ const start = top
114
+ top += rowHeight(index)
115
+ return start
116
+ })
117
+ })
118
+
119
+ // Per row, not per cell: six cells a row, so the DOM holds about fourteen rows whatever the catalogue
120
+ const {
121
+ list: renderedRows,
122
+ containerProps,
123
+ wrapperProps,
124
+ } = useVirtualList(
125
+ computed(() => rows),
126
+ { itemHeight: rowHeight, overscan: OVERSCAN },
127
+ )
128
+
129
+ // The cells follow the measured width, and with them the row height the list is laid out with
130
+ useResizeObserver(containerProps.ref, ([entry]) => {
131
+ gridWidth.value = entry?.contentRect.width ?? 0
132
+ })
133
+
134
+ const cellId = (row: number, col: number) => `${cellIdPrefix}-${row}-${col}`
135
+
136
+ const isHighlightedIcon = (position: GridPosition | null) => {
137
+ const row = position ? rows[position.row] : undefined
138
+ return !!position && row?.kind === 'icons' && !!row.icons[position.col]
139
+ }
140
+
141
+ /** Only a cell that is rendered: `reveal` puts the highlighted one there first. */
142
+ const activeDescendant = computed(() => {
143
+ if (!focused || !highlight || !isHighlightedIcon(highlight)) return undefined
144
+ const { row, col } = highlight
145
+ return renderedRows.value.some(({ index }) => index === row) ? cellId(row, col) : undefined
146
+ })
147
+
148
+ /**
149
+ * One selected cell, the first place the current icon appears: in the pinned section when there
150
+ * is one. Its second place in "all icons" is a plain cell, or a screen reader hears two selections.
151
+ */
152
+ const isSelected = (row: number, col: number) => selected?.row === row && selected.col === col
153
+
154
+ const isHighlighted = (row: number, col: number) =>
155
+ focused && highlight?.row === row && highlight.col === col
156
+
157
+ const cellClass = (row: number, col: number) => {
158
+ const isSelectedCell = isSelected(row, col)
159
+ const highlighted = isHighlighted(row, col)
160
+ return [
161
+ CELL_CLASSES.base,
162
+ isSelectedCell ? CELL_CLASSES.selected : CELL_CLASSES.idle,
163
+ highlighted && (isSelectedCell ? CELL_CLASSES.highlightedSelected : CELL_CLASSES.highlighted),
164
+ ]
165
+ }
166
+
167
+ const inView = (row: number) => {
168
+ const grid = containerProps.ref.value
169
+ const top = rowTops.value[row]
170
+ if (!grid || top === undefined) return false
171
+ return (
172
+ GRID_PADDING + top >= grid.scrollTop &&
173
+ GRID_PADDING + top + iconRowHeight.value <= grid.scrollTop + grid.clientHeight
174
+ )
175
+ }
176
+
177
+ /**
178
+ * Scrolls the least needed to show a row, its section heading along when it is the first row under
179
+ * one, and renders the rows there at once: `aria-activedescendant` may only point at a cell that
180
+ * exists.
181
+ */
182
+ const reveal = (row: number) => {
183
+ const grid = containerProps.ref.value
184
+ const top = rowTops.value[rows[row - 1]?.kind === 'heading' ? row - 1 : row]
185
+ const bottom = (rowTops.value[row] ?? 0) + iconRowHeight.value + GRID_PADDING
186
+ if (!grid || top === undefined) return
187
+ if (top < grid.scrollTop) grid.scrollTop = top
188
+ else if (bottom > grid.scrollTop + grid.clientHeight) {
189
+ grid.scrollTop = bottom - grid.clientHeight + GRID_PADDING
190
+ }
191
+ containerProps.onScroll()
192
+ }
193
+
194
+ // The rows that fit in view, less one, so a page step keeps one row of context
195
+ const pageRows = () =>
196
+ Math.max(1, Math.floor((containerProps.ref.value?.clientHeight ?? 0) / iconRowHeight.value) - 1)
197
+
198
+ const scrollToTop = () => {
199
+ const grid = containerProps.ref.value
200
+ if (grid) grid.scrollTop = 0
201
+ }
202
+
203
+ const focus = () => containerProps.ref.value?.focus()
204
+
205
+ defineExpose<IconPickerGridHandle>({ focus, reveal, inView, pageRows, scrollToTop })
206
+ </script>
207
+
208
+ <template>
209
+ <div
210
+ v-if="skeleton"
211
+ :id="gridId"
212
+ role="grid"
213
+ aria-busy="true"
214
+ :aria-labelledby="labelledby"
215
+ class="min-h-0 flex-1 overflow-hidden p-2"
216
+ >
217
+ <div class="grid grid-cols-6 gap-3" aria-hidden="true">
218
+ <NexxtSkeleton
219
+ v-for="index in SKELETON_CELLS"
220
+ :key="index"
221
+ class="aspect-square w-full rounded-lg"
222
+ />
223
+ </div>
224
+ </div>
225
+
226
+ <!--
227
+ The grid is the scroll container and the focus: the highlight is an active descendant, so
228
+ focus never moves onto a cell that scrolling could unmount.
229
+ -->
230
+ <div
231
+ v-else
232
+ :id="gridId"
233
+ v-bind="containerProps"
234
+ role="grid"
235
+ tabindex="0"
236
+ :aria-labelledby="labelledby"
237
+ :aria-rowcount="rows.length"
238
+ :aria-colcount="COLUMNS"
239
+ :aria-activedescendant="activeDescendant"
240
+ class="min-h-0 flex-1 p-2 focus:outline-none"
241
+ @focus="emit('focus')"
242
+ @blur="emit('blur')"
243
+ @keydown="emit('keydown', $event)"
244
+ >
245
+ <div v-bind="wrapperProps">
246
+ <template v-for="{ data: row, index: rowIndex } in renderedRows" :key="row.key">
247
+ <div
248
+ v-if="row.kind === 'heading'"
249
+ role="row"
250
+ :aria-rowindex="rowIndex + 1"
251
+ class="flex h-8 items-end px-1 pt-2 pb-1 extra-small-semibold text-gray-700"
252
+ >
253
+ <span role="rowheader" :aria-colspan="COLUMNS">{{ row.label }}</span>
254
+ </div>
255
+
256
+ <div
257
+ v-else
258
+ role="row"
259
+ :aria-rowindex="rowIndex + 1"
260
+ class="grid grid-cols-6 content-start gap-x-3"
261
+ :style="iconRowStyle"
262
+ >
263
+ <div
264
+ v-for="(icon, col) in row.icons"
265
+ :id="cellId(rowIndex, col)"
266
+ :key="icon.name"
267
+ role="gridcell"
268
+ :aria-colindex="col + 1"
269
+ :aria-label="icon.label"
270
+ :aria-selected="isSelected(rowIndex, col) ? 'true' : undefined"
271
+ :aria-describedby="isSelected(rowIndex, col) ? currentDescriptionId : undefined"
272
+ :class="cellClass(rowIndex, col)"
273
+ @mouseenter="emit('hover', { row: rowIndex, col })"
274
+ @click="emit('choose', icon)"
275
+ >
276
+ <i :class="glyphClass(icon)" aria-hidden="true" />
277
+ </div>
278
+ </div>
279
+ </template>
280
+ </div>
281
+ </div>
282
+ </template>
@@ -0,0 +1,112 @@
1
+ <script lang="ts">
2
+ import type { RailTab } from './useIconPickerCatalogue'
3
+
4
+ interface IconPickerRailProps {
5
+ /** "Alle iconen" first, then the categories that hold an icon, sorted. */
6
+ tabs: RailTab[]
7
+ /** Index of the selected tab in `tabs`: the one tab stop. */
8
+ activeIndex: number
9
+ /** Accessible name of the tablist. */
10
+ label: string
11
+ /** Id of the tab panel every tab controls. */
12
+ panelId: string
13
+ /** The id of the tab at an index; the tab panel and the grid are labelled by the active one. */
14
+ tabId: (index: number) => string
15
+ /** Skeleton bars after the tabs: the categories are on their way. */
16
+ loading?: boolean
17
+ }
18
+
19
+ const RAIL_SKELETONS = [72, 56, 80, 64, 48, 76, 60, 68].map((width) => ({ width: `${width}%` }))
20
+
21
+ const RAIL_KEYS: Record<string, (index: number, count: number) => number> = {
22
+ ArrowDown: (index, count) => Math.min(index + 1, count - 1),
23
+ ArrowUp: (index) => Math.max(index - 1, 0),
24
+ Home: () => 0,
25
+ End: (_, count) => count - 1,
26
+ }
27
+
28
+ // No `focus:outline-none` next to the focus-visible outline: in Tailwind v4 it sets
29
+ // `--tw-outline-style: none`, which `focus-visible:outline-2` picks up (see EditorSettingsGroup)
30
+ const TAB_CLASSES = {
31
+ base: 'flex h-8 w-full min-w-0 shrink-0 cursor-pointer items-center rounded-md px-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cornflower-blue-500',
32
+ idle: 'small-normal text-gray-800 hover:bg-gray-50',
33
+ active: 'small-semibold bg-gray-100 text-gray-950',
34
+ }
35
+ </script>
36
+
37
+ <script lang="ts" setup>
38
+ import { useTemplateRef } from 'vue'
39
+ import NexxtSkeleton from '../../atoms/Skeleton/Skeleton.vue'
40
+
41
+ defineOptions({ name: 'NexxtIconPickerRail' })
42
+
43
+ const {
44
+ tabs,
45
+ activeIndex,
46
+ label,
47
+ panelId,
48
+ tabId,
49
+ loading = false,
50
+ } = defineProps<IconPickerRailProps>()
51
+
52
+ const emit = defineEmits<{
53
+ /** A tab was chosen, by a click or the arrow keys; `null` is "all icons". */
54
+ select: [id: string | null]
55
+ }>()
56
+
57
+ const rail = useTemplateRef<HTMLElement>('rail')
58
+
59
+ const tabClass = (index: number) => [
60
+ TAB_CLASSES.base,
61
+ index === activeIndex ? TAB_CLASSES.active : TAB_CLASSES.idle,
62
+ ]
63
+
64
+ // Tabs with automatic activation: moving focus is choosing
65
+ const onKeydown = (event: KeyboardEvent) => {
66
+ const move = RAIL_KEYS[event.key]
67
+ if (!move) return
68
+ event.preventDefault()
69
+ const index = move(activeIndex, tabs.length)
70
+ emit('select', tabs[index]?.id ?? null)
71
+ rail.value?.querySelector<HTMLElement>(`[id="${tabId(index)}"]`)?.focus()
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <!-- One tab stop; the arrow keys move and choose at once -->
77
+ <div
78
+ ref="rail"
79
+ role="tablist"
80
+ aria-orientation="vertical"
81
+ :aria-label="label"
82
+ class="flex w-48 shrink-0 flex-col gap-0.5 overflow-y-auto border-r border-gray-200 p-2"
83
+ @keydown="onKeydown"
84
+ >
85
+ <button
86
+ v-for="(tab, index) in tabs"
87
+ :id="tabId(index)"
88
+ :key="tab.id ?? ''"
89
+ type="button"
90
+ role="tab"
91
+ :aria-selected="index === activeIndex"
92
+ :aria-controls="panelId"
93
+ :tabindex="index === activeIndex ? 0 : -1"
94
+ :class="tabClass(index)"
95
+ :title="tab.label"
96
+ @click="emit('select', tab.id)"
97
+ >
98
+ <span class="truncate">{{ tab.label }}</span>
99
+ </button>
100
+
101
+ <template v-if="loading">
102
+ <div
103
+ v-for="(style, index) in RAIL_SKELETONS"
104
+ :key="index"
105
+ class="flex h-8 shrink-0 items-center px-3"
106
+ aria-hidden="true"
107
+ >
108
+ <NexxtSkeleton class="h-3" :style="style" />
109
+ </div>
110
+ </template>
111
+ </div>
112
+ </template>
@@ -0,0 +1,111 @@
1
+ import type { NexxtIconPickerIcon } from './IconPicker.vue'
2
+
3
+ /**
4
+ * The pure parts of `NexxtIconPicker`: the label of an icon outside the catalogue, the sort, the
5
+ * search and the style an icon is drawn in. Generic like the picker itself: no icon set, no class
6
+ * names.
7
+ */
8
+
9
+ /** The locale rail and grid sort in unless the consumer gives another. */
10
+ export const DEFAULT_SORT_LOCALE = 'nl'
11
+
12
+ /** An icon in the search index: its words prepared once, not per keystroke. */
13
+ export interface NexxtIconPickerSearchEntry {
14
+ icon: NexxtIconPickerIcon
15
+ /** The label, lower case. */
16
+ label: string
17
+ /** Name, label and terms in one lower-case string, for the substring match. */
18
+ haystack: string
19
+ /** The words of the name and the label, for "a word starts with". */
20
+ words: string[]
21
+ }
22
+
23
+ /**
24
+ * `house-chimney` → `House chimney`. What an icon is called while the catalogue that holds its
25
+ * label is not in yet, and the label a catalogue can derive for every icon whose label is just its
26
+ * name.
27
+ */
28
+ export const iconLabelFromName = (name: string) => {
29
+ const words = name.replaceAll('-', ' ')
30
+
31
+ return words.charAt(0).toUpperCase() + words.slice(1)
32
+ }
33
+
34
+ // One collator per sort: `localeCompare` with a locale builds one per comparison, which a sort of
35
+ // a few thousand icons notices
36
+ const byLabel = (locale: string) => {
37
+ const { compare } = new Intl.Collator(locale)
38
+
39
+ return (a: NexxtIconPickerIcon, b: NexxtIconPickerIcon) => compare(a.label, b.label)
40
+ }
41
+
42
+ /** A sorted copy, alphabetical on label in `locale`. */
43
+ export const sortIconsByLabel = (
44
+ icons: readonly NexxtIconPickerIcon[],
45
+ locale: string = DEFAULT_SORT_LOCALE,
46
+ ) => [...icons].sort(byLabel(locale))
47
+
48
+ /** Sorted by label, so the stable sort of a search keeps every rank in alphabetical order. */
49
+ export const iconSearchIndex = (
50
+ icons: readonly NexxtIconPickerIcon[],
51
+ locale: string = DEFAULT_SORT_LOCALE,
52
+ ): NexxtIconPickerSearchEntry[] =>
53
+ sortIconsByLabel(icons, locale).map((icon) => {
54
+ const label = icon.label.toLowerCase()
55
+
56
+ return {
57
+ icon,
58
+ label,
59
+ haystack: `${icon.name} ${label} ${icon.terms.join(' ')}`.toLowerCase(),
60
+ words: [...icon.name.split('-'), ...label.split(/\s+/)],
61
+ }
62
+ })
63
+
64
+ /**
65
+ * 0: the name or label is the search; 1: it starts with it; 2: every word of the search starts a
66
+ * word of the name or label; 3: the rest, a match on the search terms or inside a word.
67
+ */
68
+ const searchRank = (entry: NexxtIconPickerSearchEntry, words: string[]) => {
69
+ const asName = words.join('-')
70
+ const asLabel = words.join(' ')
71
+
72
+ if (entry.icon.name === asName || entry.label === asLabel) return 0
73
+ if (entry.icon.name.startsWith(asName) || entry.label.startsWith(asLabel)) return 1
74
+ if (words.every((word) => entry.words.some((own) => own.startsWith(word)))) return 2
75
+
76
+ return 3
77
+ }
78
+
79
+ /**
80
+ * Every word of the search has to occur in the name, the label or the terms (AND, any case); the
81
+ * results come in the order of `searchRank`, alphabetical within a rank. An empty search finds
82
+ * nothing.
83
+ */
84
+ export const searchIcons = (
85
+ index: readonly NexxtIconPickerSearchEntry[],
86
+ search: string,
87
+ ): NexxtIconPickerIcon[] => {
88
+ const words = search.toLowerCase().split(/\s+/).filter(Boolean)
89
+
90
+ if (words.length === 0) return []
91
+
92
+ return index
93
+ .filter((entry) => words.every((word) => entry.haystack.includes(word)))
94
+ .map((entry) => ({ icon: entry.icon, rank: searchRank(entry, words) }))
95
+ .sort((a, b) => a.rank - b.rank)
96
+ .map(({ icon }) => icon)
97
+ }
98
+
99
+ /**
100
+ * The style an icon is drawn and chosen in: the given one when the icon has it, otherwise the
101
+ * first of `order` it has. `null` only for an icon that has none of the styles in `order`.
102
+ */
103
+ export const iconStyleFor = (
104
+ styles: readonly string[],
105
+ preferred: string | undefined,
106
+ order: readonly string[],
107
+ ) => {
108
+ if (preferred && styles.includes(preferred)) return preferred
109
+
110
+ return order.find((style) => styles.includes(style)) ?? null
111
+ }