@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.
- package/dist/components/organisms/IconPicker/IconPicker.vue.d.ts +142 -0
- package/dist/components/organisms/IconPicker/_IconPickerGrid.vue.d.ts +40 -0
- package/dist/components/organisms/IconPicker/_IconPickerRail.vue.d.ts +22 -0
- package/dist/components/organisms/IconPicker/iconPickerHelpers.d.ts +39 -0
- package/dist/components/organisms/IconPicker/useIconPickerCatalogue.d.ts +72 -0
- package/dist/components/organisms/IconPicker/useIconPickerNavigation.d.ts +60 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2097 -1365
- package/dist/nuxt.js +1 -0
- package/package.json +1 -1
- package/src/components/organisms/IconPicker/IconPicker.vue +621 -0
- package/src/components/organisms/IconPicker/_IconPickerGrid.vue +282 -0
- package/src/components/organisms/IconPicker/_IconPickerRail.vue +112 -0
- package/src/components/organisms/IconPicker/iconPickerHelpers.ts +111 -0
- package/src/components/organisms/IconPicker/useIconPickerCatalogue.ts +238 -0
- package/src/components/organisms/IconPicker/useIconPickerNavigation.ts +206 -0
- package/src/components.json +1 -0
- package/src/index.ts +18 -0
|
@@ -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
|
+
}
|