@asd20/ui-next 2.0.16 → 2.0.17

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,7 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.0.17](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.0.16...ui-next-v2.0.17) (2026-04-02)
4
+
3
5
  ## [2.0.16](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.0.15...ui-next-v2.0.16) (2026-04-01)
4
6
 
5
7
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.0.16",
3
+ "version": "2.0.17",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -2,7 +2,7 @@
2
2
  <component
3
3
  :is="tag"
4
4
  v-bind="rootAttrs"
5
- @click="forwardEvent('click', $event)"
5
+ @click="handleClick"
6
6
  @mouseup="forwardEvent('mouseup', $event)"
7
7
  @keyup="forwardEvent('keyup', $event)"
8
8
  @focusin="forwardEvent('focusin', $event)"
@@ -25,7 +25,10 @@
25
25
  <script>
26
26
  import Asd20Badge from '../Asd20Badge'
27
27
  import Asd20Icon from '../Asd20Icon'
28
- import { shouldOpenInNewWindow } from '../../../helpers/linkPolicy'
28
+ import {
29
+ navigateInternalHref,
30
+ shouldOpenInNewWindow,
31
+ } from '../../../helpers/linkPolicy'
29
32
 
30
33
  export default {
31
34
  name: 'Asd20Button',
@@ -111,6 +114,13 @@ export default {
111
114
  },
112
115
  },
113
116
  methods: {
117
+ handleClick(event) {
118
+ navigateInternalHref(this, event, this.link, {
119
+ target: event.currentTarget?.getAttribute?.('target'),
120
+ download: event.currentTarget?.hasAttribute?.('download'),
121
+ })
122
+ this.forwardEvent('click', event)
123
+ },
114
124
  forwardEvent(name, event) {
115
125
  this.$emit(name, event)
116
126
  },
@@ -4,6 +4,7 @@
4
4
  class="asd20-district-logo"
5
5
  aria-label="Academy District 20 Home - We educate and inspire students to thrive."
6
6
  alt="The Academy District 20 logo."
7
+ @click="onClick"
7
8
  >
8
9
  <svg viewBox="0 0 460.94 62.52">
9
10
  <path
@@ -30,12 +31,22 @@
30
31
  </template>
31
32
 
32
33
  <script>
34
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
35
+
33
36
  export default {
34
37
  name: 'Asd20DistrictLogo',
35
38
  props: {
36
39
  link: { type: String, default: '/' },
37
40
  tagline: { type: Boolean, default: false },
38
41
  },
42
+ methods: {
43
+ onClick(event) {
44
+ navigateInternalHref(this, event, this.link, {
45
+ target: event.currentTarget?.getAttribute?.('target'),
46
+ download: event.currentTarget?.hasAttribute?.('download'),
47
+ })
48
+ },
49
+ },
39
50
  }
40
51
  </script>
41
52
 
@@ -8,6 +8,7 @@
8
8
  ? `${abbreviatedTitle} ${subtitle}, An Academy District 20 School`
9
9
  : `${abbreviatedTitle} ${subtitle}`
10
10
  "
11
+ @click="onClick"
11
12
  >
12
13
  <figure class="asd20-logo__image">
13
14
  <img
@@ -40,6 +41,8 @@
40
41
  </template>
41
42
 
42
43
  <script>
44
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
45
+
43
46
  export default {
44
47
  name: 'Asd20Logo',
45
48
  props: {
@@ -90,6 +93,14 @@ export default {
90
93
  return this.title.trim()
91
94
  },
92
95
  },
96
+ methods: {
97
+ onClick(event) {
98
+ navigateInternalHref(this, event, this.link, {
99
+ target: event.currentTarget?.getAttribute?.('target'),
100
+ download: event.currentTarget?.hasAttribute?.('download'),
101
+ })
102
+ },
103
+ },
93
104
  }
94
105
  </script>
95
106
 
@@ -35,6 +35,7 @@
35
35
  :href="detailLink"
36
36
  :target="detailLinkTarget"
37
37
  :rel="detailLinkRel"
38
+ @click="onDetailLinkClick"
38
39
  >
39
40
  {{ detailLinkLabel }}
40
41
  </a>
@@ -98,6 +99,7 @@ import Asd20Button from '../Asd20Button'
98
99
  import Asd20Icon from '../Asd20Icon'
99
100
  import Asd20RichBodyContent from '../Asd20RichBodyContent'
100
101
  import {
102
+ navigateInternalHref,
101
103
  resolveCurrentHostname,
102
104
  shouldOpenInNewWindow,
103
105
  shouldShowExternalIcon,
@@ -176,7 +178,14 @@ export default {
176
178
  },
177
179
  },
178
180
 
179
- methods: {},
181
+ methods: {
182
+ onDetailLinkClick(event) {
183
+ navigateInternalHref(this, event, this.detailLink, {
184
+ target: event.currentTarget?.getAttribute?.('target'),
185
+ download: event.currentTarget?.hasAttribute?.('download'),
186
+ })
187
+ },
188
+ },
180
189
  }
181
190
  </script>
182
191
 
@@ -2,6 +2,7 @@
2
2
  <div
3
3
  v-if="renderNodes.length"
4
4
  class="asd20-rich-body-content"
5
+ @click="onContentClick"
5
6
  >
6
7
  <template v-for="(node, index) in renderNodes">
7
8
  <div
@@ -37,6 +38,7 @@
37
38
  <script>
38
39
  import Asd20BodyAccordion from '../../molecules/Asd20BodyAccordion'
39
40
  import Asd20TableauEmbed from '../../molecules/Asd20TableauEmbed'
41
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
40
42
 
41
43
  const BODY_BLOCK_PLACEHOLDER_PATTERN =
42
44
  /<div\b(?=[^>]*\bdata-cc-block=(['"])([^'"]+)\1)(?=[^>]*\bdata-block-id=(['"])([^'"]+)\3)[^>]*><\/div>/gi
@@ -145,6 +147,19 @@ export default {
145
147
  mounted() {
146
148
  this.hasMounted = true
147
149
  },
150
+
151
+ methods: {
152
+ onContentClick(event) {
153
+ const anchor = event.target?.closest?.('a')
154
+
155
+ if (!anchor || !this.$el?.contains(anchor)) return
156
+
157
+ navigateInternalHref(this, event, anchor.getAttribute('href'), {
158
+ target: anchor.getAttribute('target'),
159
+ download: anchor.hasAttribute('download'),
160
+ })
161
+ },
162
+ },
148
163
  }
149
164
  </script>
150
165
 
@@ -54,6 +54,7 @@
54
54
  :href="link"
55
55
  :title="title"
56
56
  :tabindex="disableFocus ? -1 : 0"
57
+ @click="onLinkClick"
57
58
  ></a>
58
59
  </div>
59
60
  <div
@@ -178,6 +179,7 @@
178
179
  :href="link ? link : ''"
179
180
  :title="title"
180
181
  :tabindex="disableFocus ? -1 : 0"
182
+ @click="onLinkClick"
181
183
  v-html="linkLabel"
182
184
  ></a>
183
185
  </p>
@@ -256,6 +258,7 @@
256
258
  import Asd20Icon from '../../atoms/Asd20Icon'
257
259
  import Asd20Tag from '../../atoms/Asd20Tag'
258
260
  import lazyImage from '../../../directives/lazy-image'
261
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
259
262
  import truncate from 'lodash/truncate'
260
263
 
261
264
  export default {
@@ -379,6 +382,14 @@ export default {
379
382
  return plain.slice(0, 65) + '...'
380
383
  },
381
384
  },
385
+ methods: {
386
+ onLinkClick(event) {
387
+ navigateInternalHref(this, event, this.link, {
388
+ target: event.currentTarget?.getAttribute?.('target'),
389
+ download: event.currentTarget?.hasAttribute?.('download'),
390
+ })
391
+ },
392
+ },
382
393
  }
383
394
  </script>
384
395
 
@@ -28,6 +28,7 @@
28
28
  v-if="detailLinkUrl"
29
29
  :tabindex="focusDisabled ? '-1' : undefined"
30
30
  :href="detailLinkUrl"
31
+ @click="onDetailLinkClick"
31
32
  >
32
33
  {{ detailLinkLabel || detailLinkUrl }}
33
34
  </a>
@@ -63,6 +64,7 @@
63
64
  <script>
64
65
  import Asd20Icon from '../../atoms/Asd20Icon'
65
66
  import Asd20Button from '../../atoms/Asd20Button'
67
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
66
68
 
67
69
  export default {
68
70
  name: 'Asd20Notification',
@@ -126,6 +128,14 @@ export default {
126
128
  }
127
129
  },
128
130
  },
131
+ methods: {
132
+ onDetailLinkClick(event) {
133
+ navigateInternalHref(this, event, this.detailLinkUrl, {
134
+ target: event.currentTarget?.getAttribute?.('target'),
135
+ download: event.currentTarget?.hasAttribute?.('download'),
136
+ })
137
+ },
138
+ },
129
139
  }
130
140
  </script>
131
141
 
@@ -33,6 +33,7 @@
33
33
  <a
34
34
  v-if="websiteLogoProps"
35
35
  :href="websiteLogoProps.logoLink ? websiteLogoProps.logoLink : ''"
36
+ @click="onLogoClick($event, websiteLogoProps.logoLink)"
36
37
  >
37
38
  <img
38
39
  :src="
@@ -44,6 +45,7 @@
44
45
  <a
45
46
  v-if="websiteLogoProps2"
46
47
  :href="websiteLogoProps2.logoLink ? websiteLogoProps2.logoLink : ''"
48
+ @click="onLogoClick($event, websiteLogoProps2.logoLink)"
47
49
  >
48
50
  <img
49
51
  :src="
@@ -173,6 +175,7 @@ import Asd20SocialMenu from '../../../components/molecules/Asd20SocialMenu'
173
175
  import Asd20Logo from '../../../components/atoms/Asd20Logo'
174
176
  import Asd20DistrictLogo from '../../../components/atoms/Asd20DistrictLogo'
175
177
  import organizationAddress from '../../../data/organization-address.json'
178
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
176
179
  import {
177
180
  organizationLevel,
178
181
  organizationName,
@@ -265,6 +268,14 @@ export default {
265
268
  return null
266
269
  },
267
270
  },
271
+ methods: {
272
+ onLogoClick(event, link) {
273
+ navigateInternalHref(this, event, link, {
274
+ target: event.currentTarget?.getAttribute?.('target'),
275
+ download: event.currentTarget?.hasAttribute?.('download'),
276
+ })
277
+ },
278
+ },
268
279
  }
269
280
  </script>
270
281
 
@@ -67,6 +67,7 @@
67
67
  <a
68
68
  v-if="detailLink && !isDetailPage"
69
69
  :href="detailLink"
70
+ @click="onDetailLinkClick"
70
71
  >
71
72
  {{ detailLinkLabel }}
72
73
  </a>
@@ -104,6 +105,7 @@
104
105
  <script>
105
106
  import scrollTrack from '../../../directives/scroll-track'
106
107
  import responsiveBreakpointMixin from '../../../mixins/responsiveBreakpointMixin'
108
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
107
109
  import Asd20Button from '../../atoms/Asd20Button'
108
110
 
109
111
  export default {
@@ -181,6 +183,12 @@ export default {
181
183
  }
182
184
  }
183
185
  },
186
+ onDetailLinkClick(event) {
187
+ navigateInternalHref(this, event, this.detailLink, {
188
+ target: event.currentTarget?.getAttribute?.('target'),
189
+ download: event.currentTarget?.hasAttribute?.('download'),
190
+ })
191
+ },
184
192
  },
185
193
  }
186
194
  </script>
@@ -23,6 +23,7 @@
23
23
  <a
24
24
  v-if="detailLink && !isDetailPage"
25
25
  :href="detailLink"
26
+ @click="onDetailLinkClick"
26
27
  >
27
28
  {{ detailLinkLabel }}
28
29
  </a>
@@ -73,6 +74,7 @@
73
74
  <script>
74
75
  import scrollTrack from '../../../directives/scroll-track'
75
76
  import responsiveBreakpointMixin from '../../../mixins/responsiveBreakpointMixin'
77
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
76
78
  import Asd20Button from '../../atoms/Asd20Button'
77
79
 
78
80
  export default {
@@ -105,6 +107,14 @@ export default {
105
107
  )
106
108
  },
107
109
  },
110
+ methods: {
111
+ onDetailLinkClick(event) {
112
+ navigateInternalHref(this, event, this.detailLink, {
113
+ target: event.currentTarget?.getAttribute?.('target'),
114
+ download: event.currentTarget?.hasAttribute?.('download'),
115
+ })
116
+ },
117
+ },
108
118
  }
109
119
  </script>
110
120
 
@@ -120,6 +120,7 @@
120
120
  :href="item.url"
121
121
  :target="getTarget(item.url)"
122
122
  :rel="getRel(item.url)"
123
+ @click="onItemClick($event, item.url)"
123
124
  >
124
125
  <span class="asd20-site-menu__item-label">{{ item.title }}</span>
125
126
  <!-- Keep this icon mounted so current-host checks do not introduce hydration mismatches. -->
@@ -147,6 +148,7 @@
147
148
  import Asd20Icon from '../../atoms/Asd20Icon'
148
149
  import FocusTrap from '../../utils/FocusTrap'
149
150
  import {
151
+ navigateInternalHref,
150
152
  resolveCurrentHostname,
151
153
  shouldOpenInNewWindow,
152
154
  shouldShowExternalIcon,
@@ -326,6 +328,17 @@ export default {
326
328
  ? 'noopener noreferrer'
327
329
  : undefined
328
330
  },
331
+ onItemClick(event, url) {
332
+ const handled = navigateInternalHref(this, event, url, {
333
+ target: event.currentTarget?.getAttribute?.('target'),
334
+ download: event.currentTarget?.hasAttribute?.('download'),
335
+ })
336
+
337
+ if (handled) {
338
+ this.activeSectionIndex = -1
339
+ this.dismiss()
340
+ }
341
+ },
329
342
  shouldShowItemExternalIcon(url) {
330
343
  return shouldShowExternalIcon(url, this.currentHostname)
331
344
  },
@@ -28,6 +28,7 @@
28
28
  <a
29
29
  v-if="websiteLogoProps"
30
30
  :href="websiteLogoProps.logoLink ? websiteLogoProps.logoLink : ''"
31
+ @click="onLogoClick($event, websiteLogoProps.logoLink)"
31
32
  >
32
33
  <img
33
34
  class="optionalLogo"
@@ -42,6 +43,7 @@
42
43
  <a
43
44
  v-if="websiteLogoProps2"
44
45
  :href="websiteLogoProps2.logoLink ? websiteLogoProps2.logoLink : ''"
46
+ @click="onLogoClick($event, websiteLogoProps2.logoLink)"
45
47
  >
46
48
  <img
47
49
  class="optionalLogo"
@@ -167,6 +169,7 @@ import Asd20FeedsSection from '../../../components/organisms/Asd20FeedsSection'
167
169
  import Asd20PageFooter from '../../../components/organisms/Asd20PageFooter'
168
170
  // import Asd20QuicklinksMenu from '../../../components/organisms/Asd20QuicklinksMenu'
169
171
  import Asd20NotificationGroup from '../../../components/organisms/Asd20NotificationGroup'
172
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
170
173
 
171
174
 
172
175
  // Mixins
@@ -200,6 +203,14 @@ export default {
200
203
  )
201
204
  },
202
205
  },
206
+ methods: {
207
+ onLogoClick(event, link) {
208
+ navigateInternalHref(this, event, link, {
209
+ target: event.currentTarget?.getAttribute?.('target'),
210
+ download: event.currentTarget?.hasAttribute?.('download'),
211
+ })
212
+ },
213
+ },
203
214
  }
204
215
  </script>
205
216
 
@@ -233,6 +233,7 @@ export default {
233
233
  )
234
234
 
235
235
  return sortedCategories.map(c => ({
236
+ id: `tab-${kebabCase(c)}`,
236
237
  label: c,
237
238
  hash: `#tab-${kebabCase(c)}`,
238
239
  active: this.tab === `#tab-${kebabCase(c)}`,
@@ -308,31 +309,38 @@ export default {
308
309
  // },
309
310
 
310
311
  created() {
311
- if (
312
- typeof window !== 'undefined' &&
313
- window.location.hash.indexOf('#tab-') > -1
314
- ) {
315
- this.tab = window.location.hash
316
- console.log('#tab-check=', window.location.hash)
317
- } else {
318
- if (typeof window !== 'undefined' && this.tabsList.length) {
319
- window.location.hash = this.tabsList[0].hash
320
- this.tab = window.location.hash
321
- console.log('forced tab =', this.tabsList[0].hash)
322
- }
323
- }
312
+ if (typeof window === 'undefined' || !this.tabsList.length) return
313
+
314
+ const activeHash = this.tabsList.some(t => t.hash === window.location.hash)
315
+ ? window.location.hash
316
+ : this.tabsList[0].hash
317
+
318
+ this.tab = activeHash
324
319
  },
325
320
  mounted() {
326
- window.focus(this.tab)
321
+ this.syncBrowserHash(this.tab)
327
322
  },
328
323
  methods: {
329
324
  onTabClick(tab) {
330
- window.location.hash = tab.hash
331
325
  this.tab = tab.hash
326
+ this.syncBrowserHash(tab.hash)
332
327
  },
333
328
  isResetTab(label) {
334
329
  return /\breset\b/i.test(label)
335
330
  },
331
+ syncBrowserHash(hash) {
332
+ if (typeof window === 'undefined' || !hash) return
333
+
334
+ const currentUrl = new URL(window.location.href)
335
+ if (currentUrl.hash === hash) return
336
+
337
+ currentUrl.hash = hash
338
+ window.history.replaceState(
339
+ window.history.state,
340
+ '',
341
+ `${currentUrl.pathname}${currentUrl.search}${currentUrl.hash}`
342
+ )
343
+ },
336
344
  },
337
345
  }
338
346
  </script>
@@ -41,6 +41,7 @@
41
41
  <a
42
42
  v-if="websiteLogoProps && showLogo"
43
43
  :href="websiteLogoProps.logoLink ? websiteLogoProps.logoLink : ''"
44
+ @click="onLogoClick($event, websiteLogoProps.logoLink)"
44
45
  >
45
46
  <img
46
47
  class="optionalLogo"
@@ -60,6 +61,7 @@
60
61
  :href="
61
62
  websiteLogoProps2.logoLink ? websiteLogoProps2.logoLink : ''
62
63
  "
64
+ @click="onLogoClick($event, websiteLogoProps2.logoLink)"
63
65
  >
64
66
  <img
65
67
  class="optionalLogo"
@@ -192,6 +194,7 @@ import Asd20OrganizationPicker from '../../../components/organisms/Asd20Organiza
192
194
  import Asd20LanguageTranslation from '../../molecules/Asd20LanguageTranslation'
193
195
  import Asd20BlockSchedule from '../../molecules/Asd20BlockSchedule'
194
196
  import Asd20AiSearch from '../../organisms/Asd20AiSearch'
197
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
195
198
 
196
199
  // Helpers
197
200
  import _get from 'lodash/get'
@@ -304,6 +307,12 @@ export default {
304
307
  handleResize() {
305
308
  this.showLogo = window.innerWidth >= 1350
306
309
  },
310
+ onLogoClick(event, link) {
311
+ navigateInternalHref(this, event, link, {
312
+ target: event.currentTarget?.getAttribute?.('target'),
313
+ download: event.currentTarget?.hasAttribute?.('download'),
314
+ })
315
+ },
307
316
  },
308
317
  }
309
318
  </script>
@@ -41,6 +41,7 @@
41
41
  <a
42
42
  v-if="websiteLogoProps"
43
43
  :href="websiteLogoProps.logoLink ? websiteLogoProps.logoLink : ''"
44
+ @click="onLogoClick($event, websiteLogoProps.logoLink)"
44
45
  >
45
46
  <img
46
47
  class="optionalLogo"
@@ -57,6 +58,7 @@
57
58
  :href="
58
59
  websiteLogoProps2.logoLink ? websiteLogoProps2.logoLink : ''
59
60
  "
61
+ @click="onLogoClick($event, websiteLogoProps2.logoLink)"
60
62
  >
61
63
  <img
62
64
  class="optionalLogo"
@@ -194,6 +196,7 @@ import Asd20OrganizationPicker from '../../../components/organisms/Asd20Organiza
194
196
  import Asd20LanguageTranslation from '../../molecules/Asd20LanguageTranslation'
195
197
  import Asd20BlockSchedule from '../../molecules/Asd20BlockSchedule'
196
198
  import Asd20AiSearch from '../../organisms/Asd20AiSearch'
199
+ import { navigateInternalHref } from '../../../helpers/linkPolicy'
197
200
 
198
201
  // Helpers
199
202
 
@@ -263,6 +266,14 @@ export default {
263
266
  this.$emit('events-in-view')
264
267
  }
265
268
  },
269
+ methods: {
270
+ onLogoClick(event, link) {
271
+ navigateInternalHref(this, event, link, {
272
+ target: event.currentTarget?.getAttribute?.('target'),
273
+ download: event.currentTarget?.hasAttribute?.('download'),
274
+ })
275
+ },
276
+ },
266
277
  }
267
278
  </script>
268
279
 
@@ -1,5 +1,7 @@
1
1
  const ASD20_ROOT_DOMAIN = 'asd20.org'
2
2
  const DEFAULT_BASE_URL = `https://${ASD20_ROOT_DOMAIN}`
3
+ const NON_ROUTER_PROTOCOL_PATTERN = /^(mailto:|tel:|javascript:|data:|blob:)/i
4
+ const FILE_LIKE_PATH_PATTERN = /\/[^/?#]+\.[a-z0-9]{1,8}$/i
3
5
 
4
6
  export function normalizeHostname(hostname, { treatWwwAsLocal = true } = {}) {
5
7
  if (!hostname) return ''
@@ -44,6 +46,59 @@ export function parseAbsoluteHttpUrl(href) {
44
46
  }
45
47
  }
46
48
 
49
+ export function resolveCurrentUrl(_vm, options = {}) {
50
+ if (typeof window !== 'undefined' && window.location) {
51
+ return window.location.href
52
+ }
53
+
54
+ if (options.currentUrl) return options.currentUrl
55
+ if (options.currentOrigin) return options.currentOrigin
56
+
57
+ return DEFAULT_BASE_URL
58
+ }
59
+
60
+ export function resolveCurrentOrigin(_vm, options = {}) {
61
+ if (typeof window !== 'undefined' && window.location) {
62
+ return window.location.origin
63
+ }
64
+
65
+ try {
66
+ return new URL(resolveCurrentUrl(_vm, options), DEFAULT_BASE_URL).origin
67
+ } catch (error) {
68
+ return DEFAULT_BASE_URL
69
+ }
70
+ }
71
+
72
+ export function isHashOnlyHref(href) {
73
+ if (!href || typeof href !== 'string') return false
74
+ return href.trim().startsWith('#')
75
+ }
76
+
77
+ export function parseNavigableUrl(href, currentUrl = DEFAULT_BASE_URL) {
78
+ if (!href || typeof href !== 'string') return null
79
+
80
+ const trimmed = href.trim()
81
+ if (
82
+ !trimmed ||
83
+ isHashOnlyHref(trimmed) ||
84
+ NON_ROUTER_PROTOCOL_PATTERN.test(trimmed)
85
+ ) {
86
+ return null
87
+ }
88
+
89
+ try {
90
+ return new URL(trimmed, currentUrl)
91
+ } catch (error) {
92
+ return null
93
+ }
94
+ }
95
+
96
+ export function isFileLikeHref(href, currentUrl = DEFAULT_BASE_URL) {
97
+ const url = parseNavigableUrl(href, currentUrl)
98
+ if (!url) return false
99
+ return FILE_LIKE_PATH_PATTERN.test(url.pathname)
100
+ }
101
+
47
102
  export function shouldOpenInNewWindow(href, options) {
48
103
  const url = parseAbsoluteHttpUrl(href)
49
104
  if (!url) return false
@@ -73,3 +128,73 @@ export function shouldShowExternalIcon(href, currentHostname, options) {
73
128
 
74
129
  return targetHostname !== normalizedCurrentHostname
75
130
  }
131
+
132
+ export function shouldHandleClientRoutingEvent(event) {
133
+ if (!event) return true
134
+ if (event.defaultPrevented) return false
135
+ if (typeof event.button === 'number' && event.button !== 0) return false
136
+
137
+ return !event.metaKey && !event.altKey && !event.ctrlKey && !event.shiftKey
138
+ }
139
+
140
+ export function shouldUseClientRouter(href, options = {}) {
141
+ const currentUrl = options.currentUrl || resolveCurrentUrl(null, options)
142
+ const {
143
+ router,
144
+ target,
145
+ download = false,
146
+ currentOrigin = options.currentOrigin || new URL(currentUrl).origin,
147
+ } = options
148
+
149
+ if (!router || download) return false
150
+
151
+ if (href && typeof href === 'object') {
152
+ return !target || target.toLowerCase() === '_self'
153
+ }
154
+
155
+ if (target && target.toLowerCase() !== '_self') return false
156
+
157
+ const url = parseNavigableUrl(href, currentUrl)
158
+ if (!url) return false
159
+ if (url.origin !== currentOrigin) return false
160
+ if (isFileLikeHref(href, currentUrl)) return false
161
+
162
+ return true
163
+ }
164
+
165
+ export function buildRouterLocationFromHref(href, currentUrl = DEFAULT_BASE_URL) {
166
+ if (href && typeof href === 'object') return href
167
+
168
+ const url = parseNavigableUrl(href, currentUrl)
169
+ if (!url) return null
170
+
171
+ return `${url.pathname}${url.search}${url.hash}` || '/'
172
+ }
173
+
174
+ export function navigateInternalHref(vm, event, href, options = {}) {
175
+ const router = options.router || vm?.$router
176
+ const currentUrl = options.currentUrl || resolveCurrentUrl(vm, options)
177
+ const target = options.target
178
+ const download = options.download || false
179
+
180
+ if (
181
+ !shouldUseClientRouter(href, {
182
+ ...options,
183
+ router,
184
+ currentUrl,
185
+ target,
186
+ download,
187
+ })
188
+ ) {
189
+ return false
190
+ }
191
+
192
+ if (!shouldHandleClientRoutingEvent(event)) return false
193
+
194
+ const location = buildRouterLocationFromHref(href, currentUrl)
195
+ if (!location) return false
196
+
197
+ event.preventDefault()
198
+ router.push(location)
199
+ return true
200
+ }