@asd20/ui-next 2.34.1 → 2.34.3

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.34.3](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.34.2...ui-next-v2.34.3) (2026-11-02)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * add relevant related links to page search results using keyword searches ([c1f9025](https://github.com/academydistrict20/asd20-ui-next/commit/c1f9025943fb45f35948c19259a3fa0642532d2d))
9
+
10
+ ## [2.34.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.34.1...ui-next-v2.34.2) (2026-11-02)
11
+
3
12
  ## [2.34.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.34.0...ui-next-v2.34.1) (2026-10-28)
4
13
 
5
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.34.1",
3
+ "version": "2.34.3",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -0,0 +1,246 @@
1
+ <template>
2
+ <section
3
+ v-if="links.length"
4
+ class="asd20-matching-search-links"
5
+ aria-label="Matching links on this page"
6
+ @click.stop
7
+ >
8
+ <p class="asd20-matching-search-links__heading">
9
+ {{ links.length === 1 ? 'Matching link' : 'Matching links' }}
10
+ </p>
11
+ <ul>
12
+ <li
13
+ v-for="link in visibleLinks"
14
+ :key="link.url"
15
+ >
16
+ <a
17
+ :href="link.url"
18
+ :target="link.newWindow ? '_blank' : undefined"
19
+ :rel="link.newWindow ? 'noopener noreferrer' : undefined"
20
+ @click="$emit('navigate', $event)"
21
+ >
22
+ <component
23
+ :is="part.matched ? 'mark' : 'span'"
24
+ v-for="(part, index) in highlight(link.title)"
25
+ :key="index"
26
+ >{{ part.text }}</component>
27
+ <span
28
+ v-if="link.external"
29
+ class="asd20-matching-search-links__external"
30
+ aria-label="External link"
31
+ >↗</span>
32
+ </a>
33
+ <p
34
+ v-if="link.description"
35
+ class="asd20-matching-search-links__description"
36
+ >
37
+ <component
38
+ :is="part.matched ? 'mark' : 'span'"
39
+ v-for="(part, index) in highlight(link.description)"
40
+ :key="index"
41
+ >
42
+ {{ part.text }}
43
+ </component>
44
+ </p>
45
+ <p class="asd20-matching-search-links__destination">
46
+ {{ link.destination }}
47
+ </p>
48
+ </li>
49
+ </ul>
50
+ <button
51
+ v-if="links.length > 2"
52
+ type="button"
53
+ :aria-expanded="expanded"
54
+ @click="expanded = !expanded"
55
+ >
56
+ {{
57
+ expanded
58
+ ? 'Show fewer matching links'
59
+ : `Show ${links.length - 2} more matching links`
60
+ }}
61
+ </button>
62
+ </section>
63
+ </template>
64
+
65
+ <script>
66
+ import {
67
+ shouldOpenInNewWindow,
68
+ shouldShowExternalIcon,
69
+ } from '../../../helpers/linkPolicy'
70
+
71
+ const STOP_WORDS = new Set([
72
+ 'a',
73
+ 'an',
74
+ 'and',
75
+ 'are',
76
+ 'as',
77
+ 'at',
78
+ 'be',
79
+ 'by',
80
+ 'can',
81
+ 'do',
82
+ 'for',
83
+ 'how',
84
+ 'i',
85
+ 'in',
86
+ 'is',
87
+ 'it',
88
+ 'of',
89
+ 'on',
90
+ 'or',
91
+ 'the',
92
+ 'to',
93
+ 'with',
94
+ ])
95
+
96
+ export default {
97
+ name: 'Asd20MatchingSearchLinks',
98
+ props: {
99
+ matchingLinks: { type: Array, default: () => [] },
100
+ keywords: { type: String, default: '' },
101
+ pageUrl: { type: String, default: '' },
102
+ },
103
+ emits: ['navigate'],
104
+ data: () => ({ expanded: false }),
105
+ computed: {
106
+ links() {
107
+ const seen = new Set()
108
+ return this.matchingLinks
109
+ .flatMap(link => {
110
+ if (
111
+ !link ||
112
+ typeof link.title !== 'string' ||
113
+ !link.title.trim() ||
114
+ typeof link.url !== 'string' ||
115
+ !/^https?:\/\//i.test(link.url)
116
+ )
117
+ return []
118
+ try {
119
+ const url = new URL(link.url)
120
+ if (url.username || url.password || seen.has(url.href)) return []
121
+ seen.add(url.href)
122
+ const form = ['forms.cloud.microsoft', 'forms.office.com'].includes(
123
+ url.hostname
124
+ )
125
+ const pageHost = this.pageUrl
126
+ ? new URL(this.pageUrl, 'https://www.asd20.org').hostname
127
+ : ''
128
+ return [
129
+ {
130
+ title: link.title.trim(),
131
+ url: url.href,
132
+ description:
133
+ typeof link.description === 'string' ? link.description : '',
134
+ destination: form
135
+ ? 'Form · Microsoft Forms'
136
+ : url.hostname.replace(/^www\./, ''),
137
+ external: shouldShowExternalIcon(url.href, pageHost),
138
+ newWindow: shouldOpenInNewWindow(url.href),
139
+ },
140
+ ]
141
+ } catch (_error) {
142
+ return []
143
+ }
144
+ })
145
+ .slice(0, 8)
146
+ },
147
+ visibleLinks() {
148
+ return this.expanded ? this.links : this.links.slice(0, 2)
149
+ },
150
+ },
151
+ watch: {
152
+ keywords() {
153
+ this.expanded = false
154
+ },
155
+ matchingLinks() {
156
+ this.expanded = false
157
+ },
158
+ },
159
+ methods: {
160
+ highlight(text) {
161
+ const tokens = [
162
+ ...new Set(
163
+ this.keywords
164
+ .toLowerCase()
165
+ .split(/[^a-z0-9]+/)
166
+ .filter(token => token && !STOP_WORDS.has(token))
167
+ ),
168
+ ].slice(0, 8)
169
+ if (!tokens.length) return [{ text, matched: false }]
170
+ const pattern = tokens
171
+ .map(token => `\\b${token}${token.length >= 4 ? '[a-z0-9]*' : ''}\\b`)
172
+ .join('|')
173
+ return text
174
+ .split(new RegExp(`(${pattern})`, 'gi'))
175
+ .map((part, index) => ({ text: part, matched: index % 2 === 1 }))
176
+ },
177
+ },
178
+ }
179
+ </script>
180
+
181
+ <style scoped>
182
+ .asd20-matching-search-links {
183
+ padding: 0 1rem 1rem 3.25rem;
184
+ font-family: var(--website-typography__font-family-body);
185
+ min-width: 0;
186
+ overflow-wrap: anywhere;
187
+ }
188
+ .asd20-matching-search-links__heading {
189
+ margin: 0 0 0.5rem;
190
+ font-size: 0.875rem;
191
+ font-weight: 600;
192
+ }
193
+ ul {
194
+ list-style: none;
195
+ padding: 0;
196
+ margin: 0;
197
+ }
198
+ li {
199
+ border-inline-start: 2px solid var(--color__tertiary);
200
+ padding-inline-start: 0.75rem;
201
+ }
202
+ li + li {
203
+ margin-top: 0.75rem;
204
+ }
205
+ a {
206
+ color: var(--color__primary);
207
+ font-weight: 600;
208
+ text-underline-offset: 0.15em;
209
+ }
210
+ a:focus-visible,
211
+ button:focus-visible {
212
+ outline: 2px solid currentColor;
213
+ outline-offset: 3px;
214
+ }
215
+ .asd20-matching-search-links__description {
216
+ margin: 0.25rem 0;
217
+ font-size: 0.9375rem;
218
+ }
219
+ .asd20-matching-search-links__destination {
220
+ margin: 0.25rem 0 0;
221
+ font-size: 0.8125rem;
222
+ }
223
+ .asd20-matching-search-links__external {
224
+ margin-inline-start: 0.25rem;
225
+ }
226
+ mark {
227
+ background: transparent;
228
+ color: inherit;
229
+ font-weight: 700;
230
+ }
231
+ button {
232
+ margin-top: 0.75rem;
233
+ padding: 0.25rem 0;
234
+ border: 0;
235
+ background: transparent;
236
+ color: var(--color__primary);
237
+ font: inherit;
238
+ text-decoration: underline;
239
+ cursor: pointer;
240
+ }
241
+ @media (max-width: 600px) {
242
+ .asd20-matching-search-links {
243
+ padding-inline: 1rem;
244
+ }
245
+ }
246
+ </style>
@@ -285,11 +285,28 @@
285
285
  />
286
286
 
287
287
  <asd20-list @click="dismiss">
288
- <asd20-list-item
288
+ <div
289
289
  v-for="item in pageListItems"
290
- v-bind="item"
291
290
  :key="item.page.id"
292
- />
291
+ class="asd20-site-search__page-result"
292
+ :class="{
293
+ 'asd20-site-search__page-result--matching':
294
+ item.page.matchingLinks && item.page.matchingLinks.length,
295
+ }"
296
+ >
297
+ <asd20-list-item
298
+ v-bind="item"
299
+ :bordered="
300
+ !(item.page.matchingLinks && item.page.matchingLinks.length)
301
+ "
302
+ />
303
+ <asd20-matching-search-links
304
+ :matching-links="item.page.matchingLinks || []"
305
+ :keywords="keywords"
306
+ :page-url="item.page.url"
307
+ @navigate="dismiss"
308
+ />
309
+ </div>
293
310
  </asd20-list>
294
311
 
295
312
  <asd20-loader
@@ -555,6 +572,7 @@ import Asd20Spinner from '../../atoms/Asd20Spinner'
555
572
 
556
573
  import debounce from 'lodash/debounce'
557
574
  import mapPagesToListItems from '../../../helpers/mapPagesToListItems'
575
+ import Asd20MatchingSearchLinks from '../../molecules/Asd20MatchingSearchLinks'
558
576
  import mapFilesToListItems from '../../../helpers/mapFilesToListItems'
559
577
  import mapPeopleToListItems from '../../../helpers/mapPeopleToListItems'
560
578
  import logSearchAnalytics from '../../../helpers/logSearchAnalytics'
@@ -610,6 +628,7 @@ export default {
610
628
  name: 'Asd20SiteSearch',
611
629
 
612
630
  components: {
631
+ Asd20MatchingSearchLinks,
613
632
  Asd20SearchField,
614
633
  Asd20List,
615
634
  Asd20ListItem,
@@ -3622,6 +3641,14 @@ export default {
3622
3641
  <style lang="scss" scoped>
3623
3642
  @use '../../../design/component-stack' as *;
3624
3643
 
3644
+ .asd20-site-search__page-result {
3645
+ min-width: 0;
3646
+ width: 100%;
3647
+ }
3648
+ .asd20-site-search__page-result--matching {
3649
+ border-bottom: 1px solid var(--color__tertiary);
3650
+ }
3651
+
3625
3652
  .asd20-site-search {
3626
3653
  position: fixed;
3627
3654
  z-index: 311;
@@ -98,6 +98,11 @@
98
98
  margin: space(0.25) 0 space(1) 0;
99
99
  display: flex;
100
100
  flex-wrap: wrap;
101
+
102
+ // The list's bottom margin controls the gap before an adjacent paragraph.
103
+ &:has(+ p) {
104
+ margin-bottom: 0.5rem;
105
+ }
101
106
  }
102
107
 
103
108
  ul > ul,