@asd20/ui-next 2.22.0 → 2.22.2

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.22.2](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.22.1...ui-next-v2.22.2) (2026-08-31)
4
+
5
+ ## [2.22.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.22.0...ui-next-v2.22.1) (2026-08-28)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * accessibility improvements; native space/enter activation in nav; fix skip-link; logins tab relationships ([e198494](https://github.com/academydistrict20/asd20-ui-next/commit/e198494e9aa59b83857cf8ca8c6d933afa32e284))
11
+
3
12
  # [2.22.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.21.11...ui-next-v2.22.0) (2026-08-28)
4
13
 
5
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.22.0",
3
+ "version": "2.22.2",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  >
15
15
  <focus-trap
16
16
  :open="resolvedOpen"
17
+ :return-focus-on-deactivate="false"
17
18
  >
18
19
  <div
19
20
  class="asd20-modal"
@@ -84,6 +85,7 @@ export default {
84
85
  listeningForEscape: false,
85
86
  transitioning: false,
86
87
  titleId: createComponentInstanceId('asd20-modal-title'),
88
+ previouslyFocusedElement: null,
87
89
  }
88
90
  },
89
91
  computed: {
@@ -103,17 +105,23 @@ export default {
103
105
  },
104
106
  },
105
107
  watch: {
106
- resolvedOpen: function(newVal) {
108
+ resolvedOpen: function(newVal, oldVal) {
107
109
  if (newVal) {
110
+ if (!oldVal) this.rememberFocus()
108
111
  this.startListeningForEscape()
109
112
  this.setFocus()
110
113
  return
111
114
  }
112
115
 
113
116
  this.stopListeningForEscape()
117
+ if (oldVal) this.restoreFocus()
114
118
  },
115
119
  },
116
120
 
121
+ beforeMount() {
122
+ if (this.resolvedOpen) this.rememberFocus()
123
+ },
124
+
117
125
  mounted() {
118
126
  if (this.resolvedOpen === true) {
119
127
  this.startListeningForEscape()
@@ -122,6 +130,7 @@ export default {
122
130
  },
123
131
  beforeUnmount() {
124
132
  this.stopListeningForEscape()
133
+ if (this.resolvedOpen) this.restoreFocus()
125
134
  },
126
135
  methods: {
127
136
  dismiss(value = false) {
@@ -166,6 +175,26 @@ export default {
166
175
  focusTarget.focus()
167
176
  }
168
177
  },
178
+ rememberFocus() {
179
+ const activeElement = document?.activeElement
180
+ this.previouslyFocusedElement =
181
+ activeElement && activeElement !== document.body ? activeElement : null
182
+ },
183
+ restoreFocus() {
184
+ const focusTarget = this.previouslyFocusedElement
185
+ this.previouslyFocusedElement = null
186
+
187
+ if (!focusTarget) return
188
+
189
+ this.$nextTick(() => {
190
+ if (
191
+ focusTarget.isConnected &&
192
+ typeof focusTarget.focus === 'function'
193
+ ) {
194
+ focusTarget.focus()
195
+ }
196
+ })
197
+ },
169
198
  setFocus() {
170
199
  this.$nextTick(() => {
171
200
  this.focusRef('closeButton')
@@ -1,8 +1,11 @@
1
1
  <template>
2
2
  <component
3
3
  :is="component"
4
- tabindex="0"
4
+ :tabindex="resolvedTabindex"
5
5
  :class="classes"
6
+ :role="semantic ? 'tab' : undefined"
7
+ :aria-selected="semantic ? String(active) : undefined"
8
+ :aria-controls="semantic && panelId ? panelId : undefined"
6
9
  exact
7
10
  tag="button"
8
11
  :to="to"
@@ -67,6 +70,8 @@ export default {
67
70
  badge: { type: Number, default: 0 },
68
71
  pushRight: { type: Boolean, default: false },
69
72
  active: { type: Boolean, default: false },
73
+ semantic: { type: Boolean, default: false },
74
+ panelId: { type: String, default: '' },
70
75
  to: { type: [String, Object], default: '' },
71
76
  },
72
77
  emits: ['click'],
@@ -76,6 +81,10 @@ export default {
76
81
  if (resolveRouter(this) && this.to) return 'router-link'
77
82
  return 'button'
78
83
  },
84
+ resolvedTabindex() {
85
+ if (!this.semantic) return 0
86
+ return this.active ? 0 : -1
87
+ },
79
88
  classes() {
80
89
  return {
81
90
  'asd20-tab': true,
@@ -10,8 +10,7 @@
10
10
  :icon="menuActive ? 'close' : 'menu'"
11
11
  :label="menuActive ? 'Close' : 'Menu'"
12
12
  :active="menuActive"
13
- @mouseup="toggleMenu"
14
- @keyup.enter="toggleMenu"
13
+ @click="toggleMenu"
15
14
  />
16
15
 
17
16
  <slot />
@@ -89,12 +88,12 @@ export default {
89
88
  },
90
89
  toggleMenu(event) {
91
90
  this.$emit('update:menuActive', !this.menuActive)
92
- this.$emit('keyboardTriggeredLastAction', event.type === 'keyup')
91
+ this.$emit('keyboardTriggeredLastAction', event?.detail === 0)
93
92
  this.$emit('update:searchActive', false)
94
93
  },
95
94
  toggleSearch(event) {
96
95
  this.$emit('update:searchActive', !this.searchActive)
97
- this.$emit('keyboardTriggeredLastAction', event.type === 'keyup')
96
+ this.$emit('keyboardTriggeredLastAction', event?.detail === 0)
98
97
  this.$emit('update:menuActive', false)
99
98
  },
100
99
  },
@@ -150,6 +149,7 @@ $logo-width: 50vw;
150
149
  }
151
150
  .menu-button,
152
151
  .search-button {
152
+ flex-shrink: 0;
153
153
  border-radius: var(--website-shape__radius-s);
154
154
  color: var(--website-menu__foreground-color);
155
155
  & :deep(.asd20-icon g){
@@ -159,6 +159,12 @@ $logo-width: 50vw;
159
159
  .emptyNav {
160
160
  display: initial;
161
161
  }
162
+
163
+ :deep(.asd20-organization-picker),
164
+ :deep(.asd20-organization-picker .asd20-picker),
165
+ :deep(.asd20-organization-picker .asd20-district-logo) {
166
+ min-width: 0;
167
+ }
162
168
  }
163
169
 
164
170
  @keyframes navbar-collapse {
@@ -9,14 +9,18 @@
9
9
  <div
10
10
  ref="scroller"
11
11
  class="asd20-tab-bar__scroller"
12
+ :role="semantic ? 'tablist' : undefined"
12
13
  :aria-label="ariaLabel"
13
14
  @scroll.passive="onScroll"
14
15
  >
15
16
  <asd20-tab
16
17
  v-for="(tab, index) in tabs"
17
18
  v-bind="tab"
19
+ ref="tabComponents"
18
20
  :key="index"
21
+ :semantic="semantic"
19
22
  @click="$emit('tabClick', tab)"
23
+ @keydown="onTabKeydown($event, index)"
20
24
  />
21
25
  <slot />
22
26
  </div>
@@ -41,8 +45,11 @@ export default {
41
45
  props: {
42
46
  tabs: { type: Array, default: () => [] },
43
47
  ariaLabel: { type: String, default: 'Tab List' },
48
+ semantic: { type: Boolean, default: false },
44
49
  },
45
50
 
51
+ emits: ['tabClick'],
52
+
46
53
  data: () => ({
47
54
  canScrollLeft: false,
48
55
  canScrollRight: false,
@@ -75,6 +82,39 @@ export default {
75
82
  },
76
83
 
77
84
  methods: {
85
+ onTabKeydown(event, index) {
86
+ if (!this.semantic || !this.tabs.length) return
87
+
88
+ const lastIndex = this.tabs.length - 1
89
+ const destinations = {
90
+ ArrowLeft: index === 0 ? lastIndex : index - 1,
91
+ ArrowRight: index === lastIndex ? 0 : index + 1,
92
+ Home: 0,
93
+ End: lastIndex,
94
+ }
95
+ const destination = destinations[event.key]
96
+
97
+ if (destination === undefined) return
98
+
99
+ event.preventDefault()
100
+ this.focusAndActivateTab(destination)
101
+ },
102
+
103
+ focusAndActivateTab(index) {
104
+ const tabComponents = Array.isArray(this.$refs.tabComponents)
105
+ ? this.$refs.tabComponents
106
+ : []
107
+ const tabComponent = tabComponents[index]
108
+ const tabElement = tabComponent?.$el || tabComponent
109
+
110
+ if (typeof tabElement?.focus === 'function') {
111
+ tabElement.focus()
112
+ }
113
+ if (typeof tabElement?.click === 'function') {
114
+ tabElement.click()
115
+ }
116
+ },
117
+
78
118
  onScroll() {
79
119
  this.updateScrollState()
80
120
  },
@@ -1,6 +1,8 @@
1
1
  <template>
2
2
  <component
3
3
  :is="tag"
4
+ id="main"
5
+ tabindex="-1"
4
6
  class="asd20-video-header-content"
5
7
  >
6
8
  <div class="asd20-video-header-content__shell">
@@ -10,7 +12,6 @@
10
12
  <section class="header-messaging-section">
11
13
  <asd20-messaging
12
14
  v-bind="messages[0]"
13
- id="main"
14
15
  heading-tag-name="h1"
15
16
  ></asd20-messaging>
16
17
  </section>
@@ -262,7 +262,7 @@ export default {
262
262
  },
263
263
  {
264
264
  start: 0,
265
- end: 17,
265
+ end: 18,
266
266
  suffix: 'years',
267
267
  label: 'Accredited With Distinction',
268
268
  once: true,
@@ -49,86 +49,95 @@
49
49
 
50
50
  <!-- Page Content -->
51
51
 
52
- <slot></slot>
53
- <section aria-label="Login Tabs">
54
- <asd20-tab-bar
55
- :tabs="tabsList"
56
- class="logins-tab-bar"
57
- @tabClick="onTabClick"
58
- />
59
- <asd20-viewport>
60
- <!-- selectedGroup -->
61
- <section v-if="selectedTabLogins">
62
- <!-- Inline Notifications -->
63
- <asd20-notification-group
64
- class="asd20-notification-group--inline"
65
- :notifications="activeNotificationsByType.inline"
66
- group-type="inline"
67
- @dismiss="$emit('dismiss-notification', $event)"
68
- @toggle-all="$emit('toggle-all')"
69
- />
70
- <asd20-list
71
- class="logins-list"
72
- multi-column
73
- :column-width="480"
52
+ <main class="asd20-template-logins__main">
53
+ <slot></slot>
54
+ <section aria-label="Login Tabs">
55
+ <asd20-tab-bar
56
+ :tabs="tabsList"
57
+ aria-label="Login categories"
58
+ class="logins-tab-bar"
59
+ semantic
60
+ @tabClick="onTabClick"
61
+ />
62
+ <asd20-viewport>
63
+ <!-- selectedGroup -->
64
+ <section
65
+ v-if="selectedTabLogins"
66
+ :id="selectedTabLogins.panelId"
67
+ role="tabpanel"
68
+ :aria-labelledby="selectedTabLogins.tabId"
74
69
  >
75
- <asd20-list-item
76
- v-for="(login, index) in selectedTabLogins.logins"
77
- :key="index"
78
- :label="login.title"
79
- class="login-list-item"
80
- align-top
81
- bordered
70
+ <!-- Inline Notifications -->
71
+ <asd20-notification-group
72
+ class="asd20-notification-group--inline"
73
+ :notifications="activeNotificationsByType.inline"
74
+ group-type="inline"
75
+ @dismiss="$emit('dismiss-notification', $event)"
76
+ @toggle-all="$emit('toggle-all')"
77
+ />
78
+ <asd20-list
79
+ class="logins-list"
80
+ multi-column
81
+ :column-width="480"
82
82
  >
83
- <template #actions>
84
- <asd20-button
85
- target="_blank"
86
- horizontal
87
- reversed
88
- :label="
89
- login.callsToAction && login.callsToAction[0].label
90
- ? login.callsToAction[0].label
91
- : 'Login'
92
- "
93
- :aria-label="`Login to ${login.title}`"
94
- :link="login.callsToAction[0].url || ''"
95
- />
96
- </template>
97
- <div class="asd20-list-item__description">
98
- {{ login.summary }}
99
- <a
100
- v-if="login.bodyHtml"
101
- href="#"
102
- @click.prevent="selectedLogin = login"
103
- >
104
- {{ login.title }} details.
105
- </a>
106
- </div>
107
- </asd20-list-item>
108
- </asd20-list>
109
- </section>
110
- </asd20-viewport>
111
- </section>
112
-
113
- <asd20-modal
114
- icon="login"
115
- title="Login / Application Details"
116
- windowed
117
- dismissable
118
- :open="selectedLogin != null"
119
- class="asd20-login-details-modal"
120
- @dismiss="selectedLogin = null"
121
- >
122
- <asd20-viewport
123
- v-if="selectedLogin"
124
- padded
125
- scrollable
83
+ <asd20-list-item
84
+ v-for="(login, index) in selectedTabLogins.logins"
85
+ :key="index"
86
+ :label="login.title"
87
+ class="login-list-item"
88
+ align-top
89
+ bordered
90
+ >
91
+ <template #actions>
92
+ <asd20-button
93
+ target="_blank"
94
+ horizontal
95
+ reversed
96
+ :label="
97
+ login.callsToAction && login.callsToAction[0].label
98
+ ? login.callsToAction[0].label
99
+ : 'Login'
100
+ "
101
+ :aria-label="`Login to ${login.title}`"
102
+ :link="login.callsToAction[0].url || ''"
103
+ />
104
+ </template>
105
+ <div class="asd20-list-item__description">
106
+ {{ login.summary }}
107
+ <a
108
+ v-if="login.bodyHtml"
109
+ href="#"
110
+ @click.prevent="selectedLogin = login"
111
+ >
112
+ {{ login.title }} details.
113
+ </a>
114
+ </div>
115
+ </asd20-list-item>
116
+ </asd20-list>
117
+ </section>
118
+ </asd20-viewport>
119
+ </section>
120
+
121
+ <asd20-modal
122
+ icon="login"
123
+ title="Login / Application Details"
124
+ windowed
125
+ dismissable
126
+ :open="selectedLogin != null"
127
+ class="asd20-login-details-modal"
128
+ @dismiss="selectedLogin = null"
126
129
  >
127
- <asd20-messaging-group
128
- :messages="[selectedLogin]"
129
- ></asd20-messaging-group>
130
- </asd20-viewport>
131
- </asd20-modal>
130
+ <asd20-viewport
131
+ v-if="selectedLogin"
132
+ padded
133
+ scrollable
134
+ >
135
+ <asd20-messaging-group
136
+ :messages="[selectedLogin]"
137
+ ></asd20-messaging-group>
138
+ </asd20-viewport>
139
+ </asd20-modal>
140
+ </main>
132
141
  <!-- </asd20-page-content> -->
133
142
 
134
143
  <slot name="before-footer" />
@@ -228,12 +237,18 @@ export default {
228
237
  }
229
238
  )
230
239
 
231
- return sortedCategories.map(c => ({
232
- id: `tab-${kebabCase(c)}`,
233
- label: c,
234
- hash: `#tab-${kebabCase(c)}`,
235
- active: this.tab === `#tab-${kebabCase(c)}`,
236
- }))
240
+ return sortedCategories.map(c => {
241
+ const tabId = `tab-${kebabCase(c)}`
242
+ const hash = `#${tabId}`
243
+
244
+ return {
245
+ id: tabId,
246
+ panelId: `${tabId}-panel`,
247
+ label: c,
248
+ hash,
249
+ active: this.tab === hash,
250
+ }
251
+ })
237
252
  },
238
253
  logins() {
239
254
  return (this.messages || []).map(m => {
@@ -263,6 +278,8 @@ export default {
263
278
 
264
279
  groups.push({
265
280
  tab: t.label,
281
+ tabId: t.id,
282
+ panelId: t.panelId,
266
283
  hash: t.hash,
267
284
  logins: loginsForTab,
268
285
  })
@@ -8,6 +8,7 @@ export default {
8
8
  open: { type: Boolean, default: undefined },
9
9
  initialFocus: { type: null, default: undefined },
10
10
  allowOutsideClick: { type: [Boolean, Function], default: undefined },
11
+ returnFocusOnDeactivate: { type: Boolean, default: true },
11
12
  },
12
13
  data: () => ({
13
14
  trap: null,
@@ -24,6 +25,7 @@ export default {
24
25
  clickOutsideDeactivates: false,
25
26
  escapeDeactivates: false,
26
27
  fallbackFocus: () => this.ensureFallbackFocusTarget(),
28
+ returnFocusOnDeactivate: this.returnFocusOnDeactivate,
27
29
  }
28
30
 
29
31
  if (this.initialFocus !== undefined) {