@asd20/ui-next 2.0.23 → 2.0.25

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.25](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.0.24...ui-next-v2.0.25) (2026-04-03)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * allow article list and digest pages to process more than one category selection ([5ebf88a](https://github.com/academydistrict20/asd20-ui-next/commit/5ebf88a9d51d44de3976df3951ccf925cec1f269))
9
+
10
+ ## [2.0.24](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.0.23...ui-next-v2.0.24) (2026-04-03)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * prevent double activation of multi select on mouse click ([0e8dfbd](https://github.com/academydistrict20/asd20-ui-next/commit/0e8dfbdd6ac6c20181065c47324f7e624d9f49e6))
16
+
3
17
  ## [2.0.23](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.0.22...ui-next-v2.0.23) (2026-04-03)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.0.23",
3
+ "version": "2.0.25",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -75,6 +75,22 @@ function normalizeItem(item, itemLabel, itemValue) {
75
75
  return normalized
76
76
  }
77
77
 
78
+ function getItemKey(item, itemLabel, itemValue) {
79
+ const normalized = normalizeItem(item, itemLabel, itemValue)
80
+ return normalized[itemValue] ?? normalized[itemLabel]
81
+ }
82
+
83
+ function sameItemKey(left, right) {
84
+ return (
85
+ left === right ||
86
+ (left !== undefined &&
87
+ left !== null &&
88
+ right !== undefined &&
89
+ right !== null &&
90
+ String(left) === String(right))
91
+ )
92
+ }
93
+
78
94
  const MULTISELECT_OMITTED_INPUT_ATTRS = [
79
95
  'autocomplete',
80
96
  'disabled',
@@ -101,9 +117,7 @@ export default {
101
117
  computed: {
102
118
  multiselectValue() {
103
119
  if (Array.isArray(this.resolvedValue)) {
104
- return this.resolvedValue.map(item =>
105
- normalizeItem(item, this.itemLabel, this.itemValue)
106
- )
120
+ return this.resolvedValue.map(item => this.resolveSelectedItem(item))
107
121
  }
108
122
 
109
123
  if (
@@ -114,7 +128,7 @@ export default {
114
128
  return []
115
129
  }
116
130
 
117
- return [normalizeItem(this.resolvedValue, this.itemLabel, this.itemValue)]
131
+ return [this.resolveSelectedItem(this.resolvedValue)]
118
132
  },
119
133
  multiselectItems() {
120
134
  const seen = new Set()
@@ -156,9 +170,21 @@ export default {
156
170
  emitValue(value) {
157
171
  this.$emit('update:modelValue', value)
158
172
  },
159
- getItemKey(item) {
173
+ resolveSelectedItem(item) {
160
174
  const normalized = normalizeItem(item, this.itemLabel, this.itemValue)
161
- return normalized[this.itemValue] ?? normalized[this.itemLabel]
175
+ const itemKey = getItemKey(normalized, this.itemLabel, this.itemValue)
176
+
177
+ return (
178
+ this.computedItems.find(candidate =>
179
+ sameItemKey(
180
+ getItemKey(candidate, this.itemLabel, this.itemValue),
181
+ itemKey
182
+ )
183
+ ) || normalized
184
+ )
185
+ },
186
+ getItemKey(item) {
187
+ return getItemKey(item, this.itemLabel, this.itemValue)
162
188
  },
163
189
  addTag(newTag) {
164
190
  const normalizedTag = normalizeItem(newTag, this.itemLabel, this.itemValue)
@@ -90,7 +90,7 @@
90
90
  :items="categoryOptions"
91
91
  :hide-label="true"
92
92
  placeholder="Filter by Category"
93
- @update:modelValue="$emit('update:selected-categories', $event)"
93
+ @update:modelValue="onCategorySelect"
94
94
  />
95
95
  </div>
96
96
 
@@ -282,6 +282,7 @@ import Intersect from '../../../components/utils/Intersect'
282
282
 
283
283
  // Mixins
284
284
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
285
+ import categoryFilterQuerySyncMixin from '../../../mixins/categoryFilterQuerySyncMixin'
285
286
 
286
287
  // Helpers
287
288
  import mapMessageToCard from '../../../helpers/mapMessageToCard'
@@ -304,7 +305,7 @@ export default {
304
305
  Intersect,
305
306
  Asd20MediaSection,
306
307
  },
307
- mixins: [pageTemplateMixin],
308
+ mixins: [pageTemplateMixin, categoryFilterQuerySyncMixin],
308
309
 
309
310
  props: {
310
311
  keywords: { type: String, default: '' },
@@ -299,6 +299,7 @@ import Intersect from '../../../components/utils/Intersect'
299
299
 
300
300
  // Mixins
301
301
  import pageTemplateMixin from '../../../mixins/pageTemplateMixin'
302
+ import categoryFilterQuerySyncMixin from '../../../mixins/categoryFilterQuerySyncMixin'
302
303
 
303
304
  // Helpers
304
305
  import mapMessageToCard from '../../../helpers/mapMessageToCard'
@@ -322,7 +323,7 @@ export default {
322
323
  Intersect,
323
324
  Asd20MediaSection,
324
325
  },
325
- mixins: [pageTemplateMixin],
326
+ mixins: [pageTemplateMixin, categoryFilterQuerySyncMixin],
326
327
  props: {
327
328
  keywords: { type: String, default: '' },
328
329
  selectedCategories: { type: Array, default: () => [] },
@@ -332,7 +333,6 @@ export default {
332
333
  numberToShow: 25,
333
334
  counter: 1,
334
335
  counter2: 25,
335
- currentCategory: null,
336
336
  }
337
337
  },
338
338
  computed: {
@@ -367,57 +367,8 @@ export default {
367
367
  this.reset() // Reset page data when categories change
368
368
  }
369
369
  },
370
- categoryOptions(newOptions) {
371
- // Ensure $route and $route.query are defined before accessing query.category
372
- if (this.$route && this.$route.query) {
373
- const categoryFromQuery = this.$route.query.category
374
- if (categoryFromQuery && newOptions.length) {
375
- this.setCategoryFromUrl(categoryFromQuery) // Set category from URL when options are ready
376
- }
377
- }
378
- },
379
370
  },
380
371
  methods: {
381
- setCategoryFromUrl(category) {
382
- // Avoid re-processing if the category is already set
383
- if (this.currentCategory !== category) {
384
- const matchingCategory = this.categoryOptions.find(
385
- opt => opt === category
386
- )
387
- if (matchingCategory) {
388
- this.currentCategory = category // Update the current category
389
- this.$emit('update:selected-categories', [
390
- { name: matchingCategory, id: matchingCategory },
391
- ])
392
- }
393
- }
394
- },
395
- onCategorySelect(selected) {
396
- // Ensure $router and $route are defined before using them
397
- if (this.$router && this.$route) {
398
- if (selected.length > 0) {
399
- const selectedCategory = selected[0].id
400
-
401
- // Only update if the selection has changed
402
- if (this.currentCategory !== selectedCategory) {
403
- this.currentCategory = selectedCategory // Track the selected category
404
- this.$emit('update:selected-categories', selected) // Update selected categories
405
- this.$router.replace({
406
- query: { ...this.$route.query, category: selectedCategory },
407
- })
408
- }
409
- } else {
410
- // Handle clearing the selection
411
- this.currentCategory = null // Reset current category
412
- this.$emit('update:selected-categories', []) // Clear selected categories
413
-
414
- // Remove the 'category' parameter entirely from the URL
415
- const { ...remainingQuery } = this.$route.query || {} // Ensure $route.query is defined
416
- delete remainingQuery.category
417
- this.$router.replace({ query: remainingQuery }) // Update the URL without the category
418
- }
419
- }
420
- },
421
372
  nextSet() {
422
373
  if (
423
374
  Math.ceil(this.counter2 / this.numberToShow) <
@@ -27,7 +27,7 @@
27
27
  class="multiselect__tag-icon"
28
28
  :aria-label="`Remove ${getOptionLabel(option)}`"
29
29
  @mousedown.prevent.stop="removeOption(option)"
30
- @click.prevent.stop="removeOption(option)"
30
+ @click.prevent.stop="handleRemoveClick($event, option)"
31
31
  />
32
32
  </span>
33
33
  </div>
@@ -82,7 +82,6 @@
82
82
  role="option"
83
83
  :aria-selected="isSelected(option) ? 'true' : 'false'"
84
84
  @mousedown.prevent="toggleOption(option)"
85
- @click.prevent="toggleOption(option)"
86
85
  >
87
86
  <div class="option__desc">
88
87
  <span
@@ -102,7 +101,6 @@
102
101
  role="option"
103
102
  :aria-selected="'false'"
104
103
  @mousedown.prevent="createTag"
105
- @click.prevent="createTag"
106
104
  >
107
105
  <div class="option__desc">
108
106
  <span class="option__title">
@@ -384,6 +382,11 @@ export default {
384
382
  )
385
383
  this.open()
386
384
  },
385
+ handleRemoveClick(event, option) {
386
+ // Buttons need click handling for keyboard and assistive-tech activation.
387
+ if (event.detail !== 0) return
388
+ this.removeOption(option)
389
+ },
387
390
  moveActive(step) {
388
391
  if (!this.isOpen) {
389
392
  this.open()
@@ -0,0 +1,170 @@
1
+ function asArray(value) {
2
+ if (Array.isArray(value)) return value
3
+ if (value === null || value === undefined || value === '') return []
4
+ return [value]
5
+ }
6
+
7
+ function uniqueValues(values) {
8
+ return [...new Set(values)]
9
+ }
10
+
11
+ export default {
12
+ mounted() {
13
+ this.syncCategoriesFromRoute()
14
+ },
15
+ watch: {
16
+ categoryOptions(newOptions) {
17
+ if (Array.isArray(newOptions) && newOptions.length) {
18
+ this.syncCategoriesFromRoute()
19
+ }
20
+ },
21
+ },
22
+ methods: {
23
+ normalizeCategoryQueryValues(value) {
24
+ return uniqueValues(
25
+ asArray(value)
26
+ .flatMap(entry => String(entry).split(','))
27
+ .map(entry => entry.trim())
28
+ .filter(Boolean)
29
+ )
30
+ },
31
+ normalizeCategorySelection(selection) {
32
+ return asArray(selection)
33
+ .map(item => {
34
+ const id =
35
+ item && typeof item === 'object'
36
+ ? item.id ?? item.name ?? item.title
37
+ : item
38
+
39
+ if (id === null || id === undefined || id === '') {
40
+ return null
41
+ }
42
+
43
+ const label =
44
+ item && typeof item === 'object'
45
+ ? item.name ?? item.title ?? id
46
+ : id
47
+
48
+ return {
49
+ id,
50
+ name: label,
51
+ }
52
+ })
53
+ .filter(Boolean)
54
+ },
55
+ getCategoryQueryValues(query = this.$route && this.$route.query) {
56
+ if (!query) return []
57
+
58
+ if (Object.prototype.hasOwnProperty.call(query, 'categories')) {
59
+ return this.normalizeCategoryQueryValues(query.categories)
60
+ }
61
+
62
+ if (Object.prototype.hasOwnProperty.call(query, 'category')) {
63
+ return this.normalizeCategoryQueryValues(query.category)
64
+ }
65
+
66
+ return []
67
+ },
68
+ buildSelectionFromCategoryQuery() {
69
+ const queryValues = this.getCategoryQueryValues()
70
+
71
+ if (!queryValues.length || !Array.isArray(this.categoryOptions)) {
72
+ return []
73
+ }
74
+
75
+ const optionLookup = new Map(
76
+ this.categoryOptions.map(option => [String(option), String(option)])
77
+ )
78
+
79
+ return queryValues
80
+ .filter(value => optionLookup.has(String(value)))
81
+ .map(value => {
82
+ const option = optionLookup.get(String(value))
83
+ return {
84
+ id: option,
85
+ name: option,
86
+ }
87
+ })
88
+ },
89
+ hasSameCategorySelection(left, right) {
90
+ const leftIds = this.normalizeCategorySelection(left).map(item => String(item.id))
91
+ const rightIds = this.normalizeCategorySelection(right).map(item => String(item.id))
92
+
93
+ return (
94
+ leftIds.length === rightIds.length &&
95
+ leftIds.every((id, index) => id === rightIds[index])
96
+ )
97
+ },
98
+ syncCategoriesFromRoute() {
99
+ const routeSelection = this.buildSelectionFromCategoryQuery()
100
+
101
+ if (!routeSelection.length) return
102
+ if (this.hasSameCategorySelection(routeSelection, this.selectedCategories)) {
103
+ return
104
+ }
105
+
106
+ this.$emit('update:selected-categories', routeSelection)
107
+ },
108
+ categoryQueryMatchesSelection(selection) {
109
+ const normalizedSelection = this.normalizeCategorySelection(selection)
110
+ const selectionIds = normalizedSelection.map(item => String(item.id))
111
+ const query = (this.$route && this.$route.query) || {}
112
+ const queryIds = this.getCategoryQueryValues(query)
113
+
114
+ if (
115
+ selectionIds.length !== queryIds.length ||
116
+ selectionIds.some((id, index) => id !== queryIds[index])
117
+ ) {
118
+ return false
119
+ }
120
+
121
+ const hasCategory = Object.prototype.hasOwnProperty.call(query, 'category')
122
+ const hasCategories = Object.prototype.hasOwnProperty.call(query, 'categories')
123
+
124
+ if (!selectionIds.length) {
125
+ return !hasCategory && !hasCategories
126
+ }
127
+
128
+ if (String(query.category) !== selectionIds[0]) {
129
+ return false
130
+ }
131
+
132
+ if (selectionIds.length === 1) {
133
+ return !hasCategories
134
+ }
135
+
136
+ const categoryQueryValues = this.normalizeCategoryQueryValues(query.categories)
137
+
138
+ return (
139
+ categoryQueryValues.length === selectionIds.length &&
140
+ categoryQueryValues.every((id, index) => id === selectionIds[index])
141
+ )
142
+ },
143
+ updateCategoryQuery(selection) {
144
+ if (!this.$router || !this.$route) return
145
+ if (this.categoryQueryMatchesSelection(selection)) return
146
+
147
+ const normalizedSelection = this.normalizeCategorySelection(selection)
148
+ const nextQuery = { ...(this.$route.query || {}) }
149
+ const selectionIds = normalizedSelection.map(item => item.id)
150
+
151
+ delete nextQuery.category
152
+ delete nextQuery.categories
153
+
154
+ if (selectionIds.length === 1) {
155
+ nextQuery.category = selectionIds[0]
156
+ } else if (selectionIds.length > 1) {
157
+ nextQuery.category = selectionIds[0]
158
+ nextQuery.categories = selectionIds
159
+ }
160
+
161
+ this.$router.replace({ query: nextQuery })
162
+ },
163
+ onCategorySelect(selection) {
164
+ const normalizedSelection = this.normalizeCategorySelection(selection)
165
+
166
+ this.$emit('update:selected-categories', normalizedSelection)
167
+ this.updateCategoryQuery(normalizedSelection)
168
+ },
169
+ },
170
+ }