@nexxtmove/ui 1.47.0 → 1.49.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,238 @@
1
+ import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import type {
3
+ NexxtIconPickerCategory,
4
+ NexxtIconPickerIcon,
5
+ NexxtIconPickerPinned,
6
+ NexxtIconPickerStyle,
7
+ NexxtIconPickerValue,
8
+ } from './IconPicker.vue'
9
+ import {
10
+ iconLabelFromName,
11
+ iconSearchIndex,
12
+ iconStyleFor,
13
+ searchIcons,
14
+ sortIconsByLabel,
15
+ } from './iconPickerHelpers'
16
+
17
+ /**
18
+ * Six columns with a 12px gap (`gap-3`) scan more easily than eight close together. `grid-cols-6`
19
+ * in `_IconPickerGrid.vue` is the same number: change both together.
20
+ */
21
+ export const COLUMNS = 6
22
+ /** Two rows. */
23
+ const PINNED_MAX = 2 * COLUMNS
24
+
25
+ /**
26
+ * On every glyph `<i>`. Font Awesome centres its `::before` with `text-align` in a fixed 1.25em
27
+ * box, which stops working once the box is a flex container (Font Awesome's display variable set
28
+ * to `inline-flex`, as in Storybook); centring it as a flex box as well holds in both, as
29
+ * NexxtIcon does. `leading-none` keeps the line box at the glyph's own height.
30
+ */
31
+ const GLYPH_CLASSES = 'items-center justify-center leading-none'
32
+
33
+ /** A line of the grid: a section heading, or up to `COLUMNS` icons. */
34
+ export type GridRow =
35
+ | { kind: 'heading'; key: string; label: string }
36
+ | { kind: 'icons'; key: string; icons: NexxtIconPickerIcon[] }
37
+
38
+ /** A cell, by its index in the rows and its column. */
39
+ export interface GridPosition {
40
+ row: number
41
+ col: number
42
+ }
43
+
44
+ /** A rail item; `null` is "all icons". */
45
+ export interface RailTab {
46
+ id: string | null
47
+ label: string
48
+ }
49
+
50
+ const toRows = (list: NexxtIconPickerIcon[], section: string): GridRow[] =>
51
+ Array.from({ length: Math.ceil(list.length / COLUMNS) }, (_, index) => ({
52
+ kind: 'icons' as const,
53
+ key: `${section}-${index}`,
54
+ icons: list.slice(index * COLUMNS, (index + 1) * COLUMNS),
55
+ }))
56
+
57
+ const isKnown = (icon: NexxtIconPickerIcon | undefined): icon is NexxtIconPickerIcon => !!icon
58
+
59
+ interface IconPickerCatalogueOptions {
60
+ icons: MaybeRefOrGetter<NexxtIconPickerIcon[]>
61
+ categories: MaybeRefOrGetter<NexxtIconPickerCategory[]>
62
+ pinned: MaybeRefOrGetter<NexxtIconPickerPinned | undefined>
63
+ styles: MaybeRefOrGetter<NexxtIconPickerStyle[]>
64
+ /** A getter, never the function itself: `toValue` would call that without a name. */
65
+ iconClass: () => (name: string) => string
66
+ sortLocale: MaybeRefOrGetter<string>
67
+ /** Heading of the full list under a pinned section. */
68
+ allIconsLabel: MaybeRefOrGetter<string>
69
+ model: Ref<NexxtIconPickerValue | null>
70
+ /** The trimmed search. */
71
+ term: Ref<string>
72
+ /** The chosen category; `null` is "all icons". */
73
+ category: Ref<string | null>
74
+ }
75
+
76
+ /**
77
+ * What the picker shows, derived from the catalogue, the model, the search and the category: the
78
+ * rail's categories, the grid's rows and where the current icon is in them, and the classes that
79
+ * draw a glyph. No DOM, no focus: that is `useIconPickerNavigation` and the parts.
80
+ */
81
+ export const useIconPickerCatalogue = (options: IconPickerCatalogueOptions) => {
82
+ const { model, term, category } = options
83
+
84
+ const iconsByName = computed(
85
+ () => new Map(toValue(options.icons).map((icon) => [icon.name, icon])),
86
+ )
87
+ const searchIndex = computed(() =>
88
+ iconSearchIndex(toValue(options.icons), toValue(options.sortLocale)),
89
+ )
90
+ const sortedIcons = computed(() => searchIndex.value.map((entry) => entry.icon))
91
+
92
+ /**
93
+ * The categories the rail shows, sorted, each with the icons of it the catalogue knows. One
94
+ * without any is left out: while the catalogue loads that is all of them, and the skeletons
95
+ * stand in.
96
+ */
97
+ const shownCategories = computed(() => {
98
+ const { compare } = new Intl.Collator(toValue(options.sortLocale))
99
+ return toValue(options.categories)
100
+ .map(({ id, label, icons: names }) => ({
101
+ id,
102
+ label,
103
+ icons: names.map((name) => iconsByName.value.get(name)).filter(isKnown),
104
+ }))
105
+ .filter((entry) => entry.icons.length > 0)
106
+ .sort((a, b) => compare(a.label, b.label))
107
+ })
108
+
109
+ const styleOrder = computed(() => toValue(options.styles).map((style) => style.id))
110
+ const stylesById = computed(
111
+ () => new Map(toValue(options.styles).map((style) => [style.id, style])),
112
+ )
113
+
114
+ const catalogueEmpty = computed(() => toValue(options.icons).length === 0)
115
+
116
+ /** The current icon, also while the catalogue that would describe it is not in. */
117
+ const currentIcon = computed<NexxtIconPickerIcon | null>(() => {
118
+ if (!model.value) return null
119
+ const { icon, style } = model.value
120
+ return (
121
+ iconsByName.value.get(icon) ?? {
122
+ name: icon,
123
+ label: iconLabelFromName(icon),
124
+ terms: [],
125
+ styles: [style],
126
+ }
127
+ )
128
+ })
129
+
130
+ /** The style of the trigger: the stored one. */
131
+ const currentStyle = computed(() =>
132
+ model.value ? (stylesById.value.get(model.value.style) ?? null) : null,
133
+ )
134
+
135
+ /** The category on show: the chosen one while the rail still has it, "all icons" otherwise. */
136
+ const activeCategory = computed(
137
+ () => shownCategories.value.find(({ id }) => id === category.value) ?? null,
138
+ )
139
+
140
+ /** The current icon first, then the pinned ones the catalogue knows, two rows at most. */
141
+ const pinnedIcons = computed(() => {
142
+ const pinned = toValue(options.pinned)
143
+ if (!pinned) return []
144
+ const current = currentIcon.value
145
+ const others = pinned.icons
146
+ .filter((name) => name !== current?.name)
147
+ .map((name) => iconsByName.value.get(name))
148
+ .filter(isKnown)
149
+ return [...(current ? [current] : []), ...others].slice(0, PINNED_MAX)
150
+ })
151
+
152
+ const rows = computed<GridRow[]>(() => {
153
+ // No catalogue (it failed, or is on its way behind the skeletons): the current icon stays
154
+ // choosable
155
+ if (catalogueEmpty.value) return toRows(currentIcon.value ? [currentIcon.value] : [], 'current')
156
+
157
+ if (term.value) return toRows(searchIcons(searchIndex.value, term.value), 'results')
158
+
159
+ if (activeCategory.value) {
160
+ const { id, icons: list } = activeCategory.value
161
+ return toRows(sortIconsByLabel(list, toValue(options.sortLocale)), `category-${id}`)
162
+ }
163
+
164
+ const pinned = toValue(options.pinned)
165
+ if (pinned && pinnedIcons.value.length > 0) {
166
+ return [
167
+ { kind: 'heading', key: 'pinned-heading', label: pinned.label },
168
+ ...toRows(pinnedIcons.value, 'pinned'),
169
+ { kind: 'heading', key: 'all-heading', label: toValue(options.allIconsLabel) },
170
+ ...toRows(sortedIcons.value, 'all'),
171
+ ]
172
+ }
173
+
174
+ return toRows(sortedIcons.value, 'all')
175
+ })
176
+
177
+ /** The indexes of the rows that hold icons, in order. */
178
+ const iconRows = computed(() =>
179
+ rows.value.flatMap((row, index) => (row.kind === 'icons' ? [index] : [])),
180
+ )
181
+
182
+ /** The icons in the list on show; the pinned section repeats icons, so it does not count. */
183
+ const iconCount = computed(
184
+ () =>
185
+ rows.value.reduce((count, row) => count + (row.kind === 'icons' ? row.icons.length : 0), 0) -
186
+ (rows.value[0]?.kind === 'heading' ? pinnedIcons.value.length : 0),
187
+ )
188
+
189
+ const rowLength = (row: number | undefined) => {
190
+ const entry = row === undefined ? undefined : rows.value[row]
191
+ return entry?.kind === 'icons' ? entry.icons.length : 0
192
+ }
193
+
194
+ const iconAt = (position: GridPosition | null) => {
195
+ if (!position) return null
196
+ const row = rows.value[position.row]
197
+ return row?.kind === 'icons' ? (row.icons[position.col] ?? null) : null
198
+ }
199
+
200
+ /** Where the current icon first appears in the grid. */
201
+ const currentPosition = computed<GridPosition | null>(() => {
202
+ const name = model.value?.icon
203
+ if (!name) return null
204
+ for (const row of iconRows.value) {
205
+ const entry = rows.value[row]
206
+ const col = entry?.kind === 'icons' ? entry.icons.findIndex((icon) => icon.name === name) : -1
207
+ if (col !== -1) return { row, col }
208
+ }
209
+ return null
210
+ })
211
+
212
+ /** What a choice of this icon writes, and so what its cell shows: the current style, or its first. */
213
+ const styleOf = (icon: NexxtIconPickerIcon) =>
214
+ iconStyleFor(icon.styles, model.value?.style, styleOrder.value)
215
+
216
+ /** The classes of a glyph: those of its style, the one from `iconClass`, and the centring. */
217
+ const glyphClass = (icon: NexxtIconPickerIcon, style: string | null = styleOf(icon)) => [
218
+ ...(style ? (stylesById.value.get(style)?.classes ?? []) : []),
219
+ options.iconClass()(icon.name),
220
+ GLYPH_CLASSES,
221
+ ]
222
+
223
+ return {
224
+ shownCategories,
225
+ activeCategory,
226
+ catalogueEmpty,
227
+ currentIcon,
228
+ currentStyle,
229
+ rows,
230
+ iconRows,
231
+ iconCount,
232
+ currentPosition,
233
+ rowLength,
234
+ iconAt,
235
+ styleOf,
236
+ glyphClass,
237
+ }
238
+ }
@@ -0,0 +1,206 @@
1
+ import { computed, ref, watch, type ComputedRef, type Ref } from 'vue'
2
+ import { useDebounceFn } from '@vueuse/core'
3
+ import type { NexxtIconPickerIcon } from './IconPicker.vue'
4
+ import type { GridPosition } from './useIconPickerCatalogue'
5
+
6
+ const RESULTS_DELAY = 500
7
+
8
+ /** What the grid part exposes: the parts of navigating that need its scroll box. */
9
+ export interface IconPickerGridHandle {
10
+ /** Focus the grid itself; the highlight is its active descendant. */
11
+ focus: () => void
12
+ /** Scroll the least needed to show the row, and render the rows there at once. */
13
+ reveal: (row: number) => void
14
+ /** Whether the row is fully in view. */
15
+ inView: (row: number) => boolean
16
+ /** The rows that fit in view, less one, at least one. */
17
+ pageRows: () => number
18
+ scrollToTop: () => void
19
+ }
20
+
21
+ interface IconPickerNavigationOptions {
22
+ /** The indexes of the rows that hold icons, in order. */
23
+ iconRows: ComputedRef<number[]>
24
+ currentPosition: ComputedRef<GridPosition | null>
25
+ iconCount: ComputedRef<number>
26
+ catalogueEmpty: ComputedRef<boolean>
27
+ iconAt: (position: GridPosition | null) => NexxtIconPickerIcon | null
28
+ rowLength: (row: number | undefined) => number
29
+ /** The raw search: a letter typed in the grid is appended to it. */
30
+ search: Ref<string>
31
+ /** The trimmed search. */
32
+ term: ComputedRef<string>
33
+ category: Ref<string | null>
34
+ isOpen: Ref<boolean>
35
+ grid: () => IconPickerGridHandle | null | undefined
36
+ focusSearch: () => void
37
+ choose: (icon: NexxtIconPickerIcon) => void
38
+ /** The copy of the announcement. */
39
+ results: () => (count: number) => string
40
+ }
41
+
42
+ const isPrintable = (event: KeyboardEvent) =>
43
+ event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey
44
+
45
+ /**
46
+ * The keyboard, highlight and focus state of the picker: the highlighted cell and whether the grid
47
+ * has focus, the keys of the search field and the grid, the move from one to the other, and the
48
+ * results count for the live region. Only a choice writes the model; moving the highlight never does.
49
+ */
50
+ export const useIconPickerNavigation = (options: IconPickerNavigationOptions) => {
51
+ const { iconRows, currentPosition, iconAt, rowLength, search, term, category, isOpen } = options
52
+
53
+ const highlight = ref<GridPosition | null>(null)
54
+ const gridFocused = ref(false)
55
+ const announcement = ref('')
56
+ /** Opening resets search and category; that reset is no change to announce. */
57
+ let skipAnnouncement = false
58
+
59
+ const highlightedIcon = computed(() => iconAt(highlight.value))
60
+
61
+ const moveTo = (row: number | undefined, col: number) => {
62
+ if (row === undefined || rowLength(row) === 0) return
63
+ highlight.value = { row, col: Math.max(0, Math.min(col, rowLength(row) - 1)) }
64
+ options.grid()?.reveal(row)
65
+ }
66
+
67
+ /**
68
+ * On opening: the reset (search, category) runs in between, so the highlight lands on the
69
+ * current icon of the full list.
70
+ */
71
+ const open = (reset: () => void) => {
72
+ // On the state the watcher sees: a search of only spaces is no term, and resetting it fires nothing
73
+ skipAnnouncement = term.value !== '' || category.value !== null
74
+ reset()
75
+ highlight.value = currentPosition.value
76
+ announcement.value = ''
77
+ }
78
+
79
+ const close = () => {
80
+ gridFocused.value = false
81
+ }
82
+
83
+ /** Into the grid from the search field: the current icon when it is in view, the first cell otherwise. */
84
+ const enterGrid = () => {
85
+ const current = currentPosition.value
86
+ if (current && options.grid()?.inView(current.row)) highlight.value = current
87
+ else moveTo(iconRows.value[0], 0)
88
+ options.grid()?.focus()
89
+ }
90
+
91
+ const onGridFocus = () => {
92
+ gridFocused.value = true
93
+ if (!highlightedIcon.value) moveTo(iconRows.value[0], 0)
94
+ }
95
+
96
+ const onGridBlur = () => {
97
+ gridFocused.value = false
98
+ }
99
+
100
+ const onCellHover = (position: GridPosition) => {
101
+ highlight.value = position
102
+ }
103
+
104
+ const onGridKeydown = (event: KeyboardEvent) => {
105
+ // A letter is a search: the field takes it, and the focus
106
+ if (isPrintable(event)) {
107
+ event.preventDefault()
108
+ search.value += event.key
109
+ options.focusSearch()
110
+ return
111
+ }
112
+
113
+ const position = highlight.value
114
+ const icon = iconAt(position)
115
+ if (!position || !icon) return
116
+
117
+ const order = iconRows.value
118
+ const at = order.indexOf(position.row)
119
+ const page = options.grid()?.pageRows() ?? 1
120
+ const toEdge = event.ctrlKey || event.metaKey
121
+ const last = order.at(-1)
122
+
123
+ switch (event.key) {
124
+ case 'ArrowRight':
125
+ if (position.col + 1 < rowLength(position.row)) moveTo(position.row, position.col + 1)
126
+ else moveTo(order[at + 1], 0)
127
+ break
128
+ case 'ArrowLeft':
129
+ if (position.col > 0) moveTo(position.row, position.col - 1)
130
+ else moveTo(order[at - 1], rowLength(order[at - 1]) - 1)
131
+ break
132
+ case 'ArrowDown':
133
+ moveTo(order[at + 1], position.col)
134
+ break
135
+ case 'ArrowUp':
136
+ if (at === 0) options.focusSearch()
137
+ else moveTo(order[at - 1], position.col)
138
+ break
139
+ case 'Home':
140
+ moveTo(toEdge ? order[0] : position.row, 0)
141
+ break
142
+ case 'End':
143
+ moveTo(toEdge ? last : position.row, rowLength(toEdge ? last : position.row) - 1)
144
+ break
145
+ case 'PageDown':
146
+ moveTo(order[Math.min(at + page, order.length - 1)], position.col)
147
+ break
148
+ case 'PageUp':
149
+ moveTo(order[Math.max(at - page, 0)], position.col)
150
+ break
151
+ case 'Enter':
152
+ case ' ':
153
+ options.choose(icon)
154
+ break
155
+ default:
156
+ return
157
+ }
158
+
159
+ event.preventDefault()
160
+ }
161
+
162
+ const onSearchKeydown = (event: KeyboardEvent) => {
163
+ if (event.key === 'ArrowDown' && iconRows.value.length > 0) {
164
+ event.preventDefault()
165
+ enterGrid()
166
+ } else if (event.key === 'Enter' && term.value) {
167
+ event.preventDefault()
168
+ const first = iconAt({ row: iconRows.value[0] ?? -1, col: 0 })
169
+ if (first) options.choose(first)
170
+ }
171
+ }
172
+
173
+ const announceResults = useDebounceFn(() => {
174
+ announcement.value = options.results()(options.iconCount.value)
175
+ }, RESULTS_DELAY)
176
+
177
+ // A new list starts at the top with nothing highlighted; back on the full list (also on opening,
178
+ // which resets both) the current icon is highlighted again
179
+ watch([term, category], () => {
180
+ highlight.value = !term.value && category.value === null ? currentPosition.value : null
181
+ options.grid()?.scrollToTop()
182
+ if (skipAnnouncement) skipAnnouncement = false
183
+ else if (isOpen.value) announceResults()
184
+ })
185
+
186
+ // The catalogue came in while the panel was open: highlight the current icon, as on opening
187
+ watch(options.catalogueEmpty, (empty) => {
188
+ if (!empty && isOpen.value && !term.value && category.value === null) {
189
+ highlight.value = currentPosition.value
190
+ }
191
+ })
192
+
193
+ return {
194
+ highlight,
195
+ highlightedIcon,
196
+ gridFocused,
197
+ announcement,
198
+ open,
199
+ close,
200
+ onGridFocus,
201
+ onGridBlur,
202
+ onCellHover,
203
+ onGridKeydown,
204
+ onSearchKeydown,
205
+ }
206
+ }
@@ -61,6 +61,7 @@
61
61
  "NexxtDropdownButton": "components/organisms/DropdownButton/DropdownButton.vue",
62
62
  "NexxtFontPicker": "components/organisms/FontPicker/FontPicker.vue",
63
63
  "NexxtHeader": "components/organisms/Header/Header.vue",
64
+ "NexxtIconPicker": "components/organisms/IconPicker/IconPicker.vue",
64
65
  "NexxtModal": "components/organisms/Modal/Modal.vue",
65
66
  "NexxtPickerPanel": "components/organisms/PickerPanel/PickerPanel.vue",
66
67
  "NexxtProgressBar": "components/organisms/ProgressBar/ProgressBar.vue",
package/src/index.ts CHANGED
@@ -126,6 +126,23 @@ export type {
126
126
  NexxtFontPickerMessages,
127
127
  NexxtFontPickerProps,
128
128
  } from './components/organisms/FontPicker/FontPicker.vue'
129
+ export type {
130
+ NexxtIconPickerCategory,
131
+ NexxtIconPickerIcon,
132
+ NexxtIconPickerMessages,
133
+ NexxtIconPickerPinned,
134
+ NexxtIconPickerProps,
135
+ NexxtIconPickerStyle,
136
+ NexxtIconPickerValue,
137
+ } from './components/organisms/IconPicker/IconPicker.vue'
138
+ export {
139
+ iconLabelFromName,
140
+ iconSearchIndex,
141
+ iconStyleFor,
142
+ searchIcons,
143
+ sortIconsByLabel,
144
+ } from './components/organisms/IconPicker/iconPickerHelpers'
145
+ export type { NexxtIconPickerSearchEntry } from './components/organisms/IconPicker/iconPickerHelpers'
129
146
  export { EDITOR_PADDING_STEPS } from './components/editor/molecules/EditorPadding/EditorPadding.vue'
130
147
  export type {
131
148
  EditorPaddingMessages,
@@ -214,6 +231,7 @@ export { default as NexxtFloatingPanel } from './components/atoms/FloatingPanel/
214
231
  export { default as NexxtFontPicker } from './components/organisms/FontPicker/FontPicker.vue'
215
232
  export { default as NexxtHeader } from './components/organisms/Header/Header.vue'
216
233
  export { default as NexxtIcon } from './components/atoms/Icon/Icon.vue'
234
+ export { default as NexxtIconPicker } from './components/organisms/IconPicker/IconPicker.vue'
217
235
  export { default as NexxtInfoBlock } from './components/molecules/InfoBlock/InfoBlock.vue'
218
236
  export { default as NexxtInputField } from './components/molecules/InputField/InputField.vue'
219
237
  export { default as NexxtInputSelect } from './components/molecules/InputSelect/InputSelect.vue'