@asd20/ui-next 2.21.11 → 2.22.1

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.22.1](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.22.0...ui-next-v2.22.1) (2026-08-28)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * accessibility improvements; native space/enter activation in nav; fix skip-link; logins tab relationships ([e198494](https://github.com/academydistrict20/asd20-ui-next/commit/e198494e9aa59b83857cf8ca8c6d933afa32e284))
9
+
10
+ # [2.22.0](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.21.11...ui-next-v2.22.0) (2026-08-28)
11
+
12
+
13
+ ### Features
14
+
15
+ * accessibility improvements for directory; close modal with esc; focus changes ([8e29660](https://github.com/academydistrict20/asd20-ui-next/commit/8e2966030e00f4165e5622ecb60c80799649b2fb))
16
+
3
17
  ## [2.21.11](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.21.10...ui-next-v2.21.11) (2026-08-27)
4
18
 
5
19
  ## [2.21.10](https://github.com/academydistrict20/asd20-ui-next/compare/ui-next-v2.21.9...ui-next-v2.21.10) (2026-08-27)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asd20/ui-next",
3
- "version": "2.21.11",
3
+ "version": "2.22.1",
4
4
  "private": false,
5
5
  "description": "ASD20 UI component library for Vue 3.",
6
6
  "license": "MIT",
@@ -34,6 +34,7 @@
34
34
  <asd20-button
35
35
  :aria-labelledby="headingId"
36
36
  :aria-controls="contentId"
37
+ :aria-expanded="open"
37
38
  :icon-angle="open ? 180 : 90"
38
39
  icon="chevron"
39
40
  size="sm"
@@ -45,7 +46,6 @@
45
46
  <div
46
47
  :id="contentId"
47
48
  ref="content"
48
- :aria-expanded="open"
49
49
  :aria-hidden="open ? 'false' : 'true'"
50
50
  :inert="open ? null : ''"
51
51
  :class="{
@@ -9,12 +9,19 @@
9
9
  >
10
10
  <div
11
11
  v-if="resolvedOpen"
12
+ ref="container"
12
13
  :class="classes"
13
14
  >
14
15
  <focus-trap
15
16
  :open="resolvedOpen"
17
+ :return-focus-on-deactivate="false"
16
18
  >
17
- <div class="asd20-modal">
19
+ <div
20
+ class="asd20-modal"
21
+ role="dialog"
22
+ aria-modal="true"
23
+ :aria-labelledby="hasTitle ? titleId : undefined"
24
+ >
18
25
  <div
19
26
  v-if="title || $slots.title"
20
27
  class="asd20-modal__header"
@@ -24,7 +31,9 @@
24
31
  :name="icon"
25
32
  size="md"
26
33
  />
27
- <h2>{{ title }}<slot name="title" /></h2>
34
+ <h2 :id="titleId">
35
+ {{ title }}<slot name="title" />
36
+ </h2>
28
37
 
29
38
  <asd20-button
30
39
  v-if="dismissable"
@@ -58,6 +67,7 @@
58
67
  import Asd20Icon from '../../atoms/Asd20Icon'
59
68
  import Asd20Button from '../../atoms/Asd20Button'
60
69
  import FocusTrap from '../../utils/FocusTrap'
70
+ import createComponentInstanceId from '../../../utils/createComponentInstanceId'
61
71
 
62
72
  export default {
63
73
  name: 'Asd20Modal',
@@ -70,9 +80,14 @@ export default {
70
80
  dismissable: { type: Boolean, default: false },
71
81
  windowed: { type: Boolean, default: false },
72
82
  },
73
- data: () => ({
74
- transitioning: false,
75
- }),
83
+ data() {
84
+ return {
85
+ listeningForEscape: false,
86
+ transitioning: false,
87
+ titleId: createComponentInstanceId('asd20-modal-title'),
88
+ previouslyFocusedElement: null,
89
+ }
90
+ },
76
91
  computed: {
77
92
  resolvedOpen() {
78
93
  return typeof this.modelValue === 'boolean'
@@ -85,26 +100,74 @@ export default {
85
100
  'asd20-modal-container--windowed': this.windowed,
86
101
  }
87
102
  },
103
+ hasTitle() {
104
+ return Boolean(this.title || this.$slots.title)
105
+ },
88
106
  },
89
107
  watch: {
90
- resolvedOpen: function(newVal) {
108
+ resolvedOpen: function(newVal, oldVal) {
91
109
  if (newVal) {
110
+ if (!oldVal) this.rememberFocus()
111
+ this.startListeningForEscape()
92
112
  this.setFocus()
113
+ return
93
114
  }
115
+
116
+ this.stopListeningForEscape()
117
+ if (oldVal) this.restoreFocus()
94
118
  },
95
119
  },
96
120
 
121
+ beforeMount() {
122
+ if (this.resolvedOpen) this.rememberFocus()
123
+ },
124
+
97
125
  mounted() {
98
126
  if (this.resolvedOpen === true) {
127
+ this.startListeningForEscape()
99
128
  this.setFocus()
100
129
  }
101
130
  },
131
+ beforeUnmount() {
132
+ this.stopListeningForEscape()
133
+ if (this.resolvedOpen) this.restoreFocus()
134
+ },
102
135
  methods: {
103
136
  dismiss(value = false) {
104
137
  this.$emit('dismiss', value)
105
138
  this.$emit('update:open', value)
106
139
  this.$emit('update:modelValue', value)
107
140
  },
141
+ dismissOnEscape(event) {
142
+ const openModals = document.querySelectorAll('.asd20-modal-container')
143
+ const topmostModal = openModals[openModals.length - 1]
144
+ const currentModal = this.$refs.container
145
+
146
+ if (
147
+ event.key !== 'Escape' ||
148
+ !this.dismissable ||
149
+ event.defaultPrevented ||
150
+ topmostModal !== currentModal
151
+ ) {
152
+ return
153
+ }
154
+
155
+ event.preventDefault()
156
+ event.stopImmediatePropagation()
157
+ this.dismiss(false)
158
+ },
159
+ startListeningForEscape() {
160
+ if (this.listeningForEscape) return
161
+
162
+ document.addEventListener('keydown', this.dismissOnEscape)
163
+ this.listeningForEscape = true
164
+ },
165
+ stopListeningForEscape() {
166
+ if (!this.listeningForEscape) return
167
+
168
+ document.removeEventListener('keydown', this.dismissOnEscape)
169
+ this.listeningForEscape = false
170
+ },
108
171
  focusRef(refName) {
109
172
  const ref = this.$refs[refName]
110
173
  const focusTarget = ref?.$el || ref
@@ -112,6 +175,26 @@ export default {
112
175
  focusTarget.focus()
113
176
  }
114
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
+ },
115
198
  setFocus() {
116
199
  this.$nextTick(() => {
117
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,
@@ -106,9 +115,9 @@ export default {
106
115
  transition: color 0.2s ease-out, border-color 0.2s ease-out,
107
116
  opacity 0.2s ease-out;
108
117
  font-weight: bold;
109
- color: var(--color__primary-t50);
118
+ color: var(--color__primary-t40);
110
119
  text-decoration: none;
111
- opacity: 0.8;
120
+ opacity: 1;
112
121
 
113
122
  &::after {
114
123
  content: '';
@@ -136,19 +145,29 @@ export default {
136
145
  }
137
146
 
138
147
  &:focus {
139
- outline: none !important;
148
+ outline: 2px solid var(--color__accent) !important;
149
+ outline-offset: -2px;
140
150
  box-shadow: none !important;
141
151
  }
142
152
 
143
153
  &:focus-visible {
144
- outline: none !important;
154
+ outline: 2px solid var(--color__accent) !important;
155
+ outline-offset: -2px;
145
156
  box-shadow: none !important;
146
157
  }
147
158
 
159
+ &:focus:not(:focus-visible) {
160
+ outline: none !important;
161
+ }
162
+
148
163
  &:focus::after,
149
164
  &:focus-visible::after {
150
- background: var(--color__accent-t60);
151
- box-shadow: 0 0 0 2px var(--color__accent-t80);
165
+ background: var(--color__accent);
166
+ box-shadow: none;
167
+ }
168
+
169
+ &:focus:not(:focus-visible)::after {
170
+ background: transparent;
152
171
  }
153
172
 
154
173
  &--active {
@@ -157,20 +176,9 @@ export default {
157
176
  &::after {
158
177
  background: var(--color__accent);
159
178
  }
160
- &:focus {
161
- outline: none !important;
162
- box-shadow: none !important;
163
- }
164
-
165
- &:focus-visible {
166
- outline: none !important;
167
- box-shadow: none !important;
168
- }
169
179
 
170
- &:focus::after,
171
- &:focus-visible::after {
180
+ &:focus:not(:focus-visible)::after {
172
181
  background: var(--color__accent);
173
- box-shadow: 0 0 0 2px var(--color__accent-t70);
174
182
  }
175
183
  }
176
184
 
@@ -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>
@@ -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) {