@rancher/shell 3.0.5-rc.1 → 3.0.5-rc.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.
Files changed (200) hide show
  1. package/assets/images/providers/sks.svg +1 -0
  2. package/assets/styles/base/_helpers.scss +4 -0
  3. package/assets/styles/base/_variables.scss +1 -0
  4. package/assets/translations/en-us.yaml +31 -15
  5. package/assets/translations/zh-hans.yaml +4 -3
  6. package/chart/monitoring/index.vue +3 -1
  7. package/components/ActionDropdownShell.vue +71 -0
  8. package/components/AppModal.vue +18 -4
  9. package/components/CommunityLinks.vue +3 -58
  10. package/components/CruResource.vue +6 -1
  11. package/components/ExplorerProjectsNamespaces.vue +12 -4
  12. package/components/GlobalRoleBindings.vue +5 -1
  13. package/components/GrowlManager.vue +1 -0
  14. package/components/LandingPagePreference.vue +2 -0
  15. package/components/LocaleSelector.vue +1 -1
  16. package/components/ModalManager.vue +55 -0
  17. package/components/PromptModal.vue +47 -8
  18. package/components/ResourceDetail/Masthead.vue +38 -12
  19. package/components/ResourceDetail/__tests__/Masthead.test.ts +5 -1
  20. package/components/ResourceDetail/index.vue +47 -12
  21. package/components/ResourceTable.vue +54 -19
  22. package/components/SideNav.vue +5 -1
  23. package/components/SlideInPanelManager.vue +126 -0
  24. package/components/SortableTable/THead.vue +5 -2
  25. package/components/SortableTable/actions.js +1 -1
  26. package/components/SortableTable/index.vue +54 -40
  27. package/components/SortableTable/paging.js +16 -19
  28. package/components/SortableTable/selection.js +0 -11
  29. package/components/Wizard.vue +2 -2
  30. package/components/__tests__/ModalManager.spec.ts +176 -0
  31. package/components/__tests__/PromptModal.test.ts +148 -0
  32. package/components/__tests__/SlideInPanelManager.spec.ts +166 -0
  33. package/components/auth/AuthBanner.vue +13 -11
  34. package/components/auth/Principal.vue +1 -0
  35. package/components/auth/login/ldap.vue +1 -1
  36. package/components/fleet/FleetResources.vue +21 -6
  37. package/components/form/ArrayList.vue +10 -6
  38. package/components/form/BannerSettings.vue +17 -2
  39. package/components/form/ColorInput.vue +35 -6
  40. package/components/form/EnvVars.vue +1 -0
  41. package/components/form/LabeledSelect.vue +18 -23
  42. package/components/form/MatchExpressions.vue +4 -1
  43. package/components/form/NameNsDescription.vue +5 -1
  44. package/components/form/NotificationSettings.vue +15 -1
  45. package/components/form/Password.vue +1 -0
  46. package/components/form/Probe.vue +1 -0
  47. package/components/form/SSHKnownHosts/__tests__/KnownHostsEditDialog.test.ts +15 -34
  48. package/components/form/SSHKnownHosts/index.vue +14 -11
  49. package/components/form/Select.vue +1 -15
  50. package/components/form/ValueFromResource.vue +12 -12
  51. package/components/form/__tests__/ArrayList.test.ts +2 -2
  52. package/components/form/__tests__/ColorInput.test.ts +35 -0
  53. package/components/form/__tests__/LabeledSelect.test.ts +40 -0
  54. package/components/form/__tests__/SSHKnownHosts.test.ts +11 -2
  55. package/components/nav/Group.vue +12 -4
  56. package/components/nav/Header.vue +16 -43
  57. package/components/nav/NamespaceFilter.vue +134 -86
  58. package/components/nav/TopLevelMenu.vue +4 -5
  59. package/components/nav/WindowManager/ContainerLogs.vue +87 -61
  60. package/components/nav/WindowManager/ContainerLogsActions.vue +76 -0
  61. package/components/templates/default.vue +6 -3
  62. package/components/templates/home.vue +6 -0
  63. package/components/templates/plain.vue +6 -3
  64. package/composables/focusTrap.ts +12 -4
  65. package/config/store.js +4 -0
  66. package/config/uiplugins.js +5 -1
  67. package/core/types.ts +7 -6
  68. package/detail/catalog.cattle.io.app.vue +6 -1
  69. package/detail/fleet.cattle.io.bundle.vue +70 -6
  70. package/detail/fleet.cattle.io.gitrepo.vue +1 -1
  71. package/detail/namespace.vue +0 -3
  72. package/detail/node.vue +17 -13
  73. package/detail/provisioning.cattle.io.cluster.vue +72 -6
  74. package/dialog/AddCustomBadgeDialog.vue +0 -1
  75. package/{pages/c/_cluster/uiplugins/AddExtensionRepos.vue → dialog/AddExtensionReposDialog.vue} +72 -42
  76. package/dialog/AssignToDialog.vue +176 -0
  77. package/dialog/ChangePasswordDialog.vue +106 -0
  78. package/{pages/c/_cluster/uiplugins/DeveloperInstallDialog.vue → dialog/DeveloperLoadExtensionDialog.vue} +74 -71
  79. package/dialog/DisableAuthProviderDialog.vue +101 -0
  80. package/dialog/DrainNode.vue +1 -1
  81. package/{pages/c/_cluster/uiplugins/CatalogList/CatalogLoadDialog.vue → dialog/ExtensionCatalogInstallDialog.vue} +100 -88
  82. package/{pages/c/_cluster/uiplugins/CatalogList/CatalogUninstallDialog.vue → dialog/ExtensionCatalogUninstallDialog.vue} +69 -57
  83. package/dialog/FeatureFlagListDialog.vue +288 -0
  84. package/dialog/ForceMachineRemoveDialog.vue +1 -1
  85. package/{components/Import.vue → dialog/ImportDialog.vue} +0 -5
  86. package/{pages/c/_cluster/uiplugins/InstallDialog.vue → dialog/InstallExtensionDialog.vue} +124 -106
  87. package/{components/form/SSHKnownHosts → dialog}/KnownHostsEditDialog.vue +52 -62
  88. package/dialog/MoveNamespaceDialog.vue +157 -0
  89. package/dialog/ScalePoolDownDialog.vue +1 -1
  90. package/{components/nav/Jump.vue → dialog/SearchDialog.vue} +34 -14
  91. package/{pages/c/_cluster/uiplugins/UninstallDialog.vue → dialog/UninstallExtensionDialog.vue} +67 -58
  92. package/dialog/WechatDialog.vue +57 -0
  93. package/edit/auth/azuread.vue +1 -1
  94. package/edit/auth/github.vue +1 -1
  95. package/edit/auth/googleoauth.vue +1 -1
  96. package/edit/auth/ldap/index.vue +1 -1
  97. package/edit/auth/oidc.vue +1 -1
  98. package/edit/auth/saml.vue +1 -1
  99. package/edit/cloudcredential.vue +24 -10
  100. package/edit/management.cattle.io.user.vue +28 -3
  101. package/edit/namespace.vue +1 -4
  102. package/edit/provisioning.cattle.io.cluster/CustomCommand.vue +4 -1
  103. package/edit/provisioning.cattle.io.cluster/SelectCredential.vue +26 -10
  104. package/edit/provisioning.cattle.io.cluster/__tests__/Advanced.test.ts +8 -8
  105. package/edit/provisioning.cattle.io.cluster/__tests__/DirectoryConfig.test.ts +26 -12
  106. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +66 -0
  107. package/edit/provisioning.cattle.io.cluster/__tests__/utils/rke2-test-data.ts +58 -0
  108. package/edit/provisioning.cattle.io.cluster/rke2.vue +24 -7
  109. package/edit/provisioning.cattle.io.cluster/tabs/DirectoryConfig.vue +5 -3
  110. package/edit/provisioning.cattle.io.cluster/tabs/MachinePool.vue +4 -1
  111. package/initialize/install-plugins.js +2 -1
  112. package/list/harvesterhci.io.management.cluster.vue +4 -1
  113. package/list/management.cattle.io.feature.vue +4 -288
  114. package/machine-config/azure.vue +16 -4
  115. package/mixins/vue-select-overrides.js +0 -4
  116. package/models/fleet.cattle.io.cluster.js +8 -2
  117. package/models/fleet.cattle.io.gitrepo.js +8 -34
  118. package/models/management.cattle.io.feature.js +7 -1
  119. package/models/namespace.js +7 -1
  120. package/package.json +1 -1
  121. package/pages/about.vue +13 -3
  122. package/pages/account/index.vue +12 -5
  123. package/pages/auth/login.vue +7 -4
  124. package/pages/auth/setup.vue +1 -0
  125. package/pages/auth/verify.vue +9 -7
  126. package/pages/c/_cluster/apps/charts/install.vue +26 -26
  127. package/pages/c/_cluster/auth/config/index.vue +10 -12
  128. package/pages/c/_cluster/explorer/EventsTable.vue +38 -33
  129. package/pages/c/_cluster/explorer/index.vue +17 -15
  130. package/pages/c/_cluster/istio/index.vue +2 -2
  131. package/pages/c/_cluster/longhorn/index.vue +1 -1
  132. package/pages/c/_cluster/monitoring/index.vue +1 -1
  133. package/pages/c/_cluster/monitoring/monitor/_namespace/_id.vue +4 -2
  134. package/pages/c/_cluster/monitoring/monitor/create.vue +4 -2
  135. package/pages/c/_cluster/monitoring/route-receiver/_id.vue +4 -2
  136. package/pages/c/_cluster/monitoring/route-receiver/create.vue +5 -2
  137. package/pages/c/_cluster/neuvector/index.vue +1 -1
  138. package/pages/c/_cluster/settings/banners.vue +4 -3
  139. package/pages/c/_cluster/uiplugins/CatalogList/index.vue +8 -10
  140. package/pages/c/_cluster/uiplugins/__tests__/AddExtensionRepos.test.ts +4 -7
  141. package/pages/c/_cluster/uiplugins/index.vue +98 -55
  142. package/pages/diagnostic.vue +12 -9
  143. package/pages/fail-whale.vue +8 -5
  144. package/pages/prefs.vue +7 -6
  145. package/plugins/internal-api/index.ts +37 -0
  146. package/plugins/internal-api/shared/base-api.ts +13 -0
  147. package/plugins/internal-api/shell/shell.api.ts +108 -0
  148. package/plugins/steve/actions.js +0 -12
  149. package/public/index.html +1 -0
  150. package/rancher-components/Card/Card.vue +1 -1
  151. package/rancher-components/Form/Checkbox/Checkbox.test.ts +59 -1
  152. package/rancher-components/Form/Checkbox/Checkbox.vue +27 -3
  153. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +47 -0
  154. package/rancher-components/Form/LabeledInput/LabeledInput.vue +20 -2
  155. package/rancher-components/Form/Radio/RadioButton.test.ts +36 -1
  156. package/rancher-components/Form/Radio/RadioButton.vue +20 -4
  157. package/rancher-components/Form/Radio/RadioGroup.test.ts +60 -0
  158. package/rancher-components/Form/Radio/RadioGroup.vue +75 -35
  159. package/rancher-components/Form/ToggleSwitch/ToggleSwitch.test.ts +17 -0
  160. package/rancher-components/Form/ToggleSwitch/ToggleSwitch.vue +5 -0
  161. package/rancher-components/LabeledTooltip/LabeledTooltip.vue +10 -1
  162. package/rancher-components/RcButton/RcButton.vue +2 -1
  163. package/rancher-components/RcButton/types.ts +1 -0
  164. package/rancher-components/RcDropdown/RcDropdown.vue +17 -6
  165. package/rancher-components/RcDropdown/RcDropdownItem.vue +3 -56
  166. package/rancher-components/RcDropdown/RcDropdownItemCheckbox.vue +68 -0
  167. package/rancher-components/RcDropdown/RcDropdownItemSelect.vue +92 -0
  168. package/rancher-components/RcDropdown/index.ts +2 -0
  169. package/rancher-components/RcDropdown/useDropdownItem.ts +63 -0
  170. package/scripts/extension/bundle +20 -0
  171. package/scripts/extension/helm/charts/ui-plugin-server/templates/cr.yaml +2 -1
  172. package/scripts/extension/helm/charts/ui-plugin-server/values.yaml +2 -0
  173. package/scripts/extension/helmpatch +44 -31
  174. package/scripts/extension/publish +12 -13
  175. package/scripts/typegen.sh +2 -4
  176. package/store/action-menu.js +26 -56
  177. package/store/index.js +5 -0
  178. package/store/modal.ts +71 -0
  179. package/store/slideInPanel.ts +47 -0
  180. package/store/type-map.js +8 -1
  181. package/store/type-map.utils.ts +4 -4
  182. package/types/global-vue.d.ts +5 -0
  183. package/types/internal-api/shell/growl.d.ts +25 -0
  184. package/types/internal-api/shell/modal.d.ts +77 -0
  185. package/types/internal-api/shell/slideIn.d.ts +15 -0
  186. package/types/resources/fleet.d.ts +0 -14
  187. package/types/shell/index.d.ts +35 -23
  188. package/types/vue-shim.d.ts +4 -1
  189. package/utils/__mocks__/tabbable.js +13 -0
  190. package/utils/__tests__/object.test.ts +38 -4
  191. package/utils/fleet.ts +15 -73
  192. package/utils/object.js +48 -5
  193. package/utils/validators/formRules/__tests__/index.test.ts +10 -1
  194. package/utils/validators/formRules/index.ts +27 -3
  195. package/components/AssignTo.vue +0 -199
  196. package/components/DisableAuthProviderModal.vue +0 -115
  197. package/components/MoveModal.vue +0 -167
  198. package/components/PromptChangePassword.vue +0 -123
  199. package/components/fleet/FleetBundleResources.vue +0 -86
  200. package/types/vue-shim.d +0 -20
@@ -124,6 +124,15 @@ export default defineComponent({
124
124
  type: String,
125
125
  default: undefined
126
126
  },
127
+
128
+ /**
129
+ * Inherited global identifier prefix for tests
130
+ * Define a term based on the parent component to avoid conflicts on multiple components
131
+ */
132
+ componentTestid: {
133
+ type: String,
134
+ default: 'checkbox'
135
+ },
127
136
  },
128
137
 
129
138
  emits: ['update:value'],
@@ -133,6 +142,18 @@ export default defineComponent({
133
142
  },
134
143
 
135
144
  computed: {
145
+ ariaDescribedBy(): string | undefined {
146
+ const inheritedDescribedBy = this.$attrs['aria-describedby'];
147
+ const internalDescribedBy = this.descriptionKey || this.description ? this.describedById : undefined;
148
+
149
+ if (inheritedDescribedBy && internalDescribedBy) {
150
+ return `${ inheritedDescribedBy } ${ internalDescribedBy }`;
151
+ } else if (inheritedDescribedBy || internalDescribedBy) {
152
+ return `${ inheritedDescribedBy || internalDescribedBy }`;
153
+ }
154
+
155
+ return undefined;
156
+ },
136
157
  /**
137
158
  * Determines if the checkbox is disabled.
138
159
  * @returns boolean: True when the disabled prop is true or when mode is
@@ -167,7 +188,7 @@ export default defineComponent({
167
188
  },
168
189
 
169
190
  idForLabel():string {
170
- return `${ this.id }-label`;
191
+ return `${ generateRandomAlphaString(12) }-checkbox-label`;
171
192
  }
172
193
  },
173
194
 
@@ -271,10 +292,11 @@ export default defineComponent({
271
292
  class="checkbox-custom"
272
293
  :class="{indeterminate: indeterminate}"
273
294
  :tabindex="isDisabled ? -1 : 0"
295
+ :aria-disabled="isDisabled"
274
296
  :aria-label="replacementLabel"
275
297
  :aria-checked="!!value"
276
298
  :aria-labelledby="labelKey || label ? idForLabel : undefined"
277
- :aria-describedby="descriptionKey || description ? describedById : undefined"
299
+ :aria-describedby="ariaDescribedBy"
278
300
  role="checkbox"
279
301
  />
280
302
  <span
@@ -298,6 +320,7 @@ export default defineComponent({
298
320
  v-clean-tooltip="{content: t(tooltipKey), triggers: ['hover', 'touch', 'focus']}"
299
321
  v-stripped-aria-label="t(tooltipKey)"
300
322
  class="checkbox-info icon icon-info icon-lg"
323
+ :data-testid="componentTestid + '-info-icon'"
301
324
  :tabindex="isDisabled ? -1 : 0"
302
325
  />
303
326
  <i
@@ -305,6 +328,7 @@ export default defineComponent({
305
328
  v-clean-tooltip="{content: tooltip, triggers: ['hover', 'touch', 'focus']}"
306
329
  v-stripped-aria-label="tooltip"
307
330
  class="checkbox-info icon icon-info icon-lg"
331
+ :data-testid="componentTestid + '-info-icon'"
308
332
  :tabindex="isDisabled ? -1 : 0"
309
333
  />
310
334
  </slot>
@@ -374,7 +398,7 @@ $fontColor: var(--input-label);
374
398
 
375
399
  .checkbox-info {
376
400
  line-height: normal;
377
- margin-left: 2px;
401
+ margin-left: 4px;
378
402
 
379
403
  &:focus-visible {
380
404
  @include focus-outline;
@@ -54,4 +54,51 @@ describe('component: LabeledInput', () => {
54
54
  expect(subLabel.text()).toBe(hint);
55
55
  });
56
56
  });
57
+
58
+ describe('a11y: adding ARIA props', () => {
59
+ const ariaLabelVal = 'some-aria-label';
60
+ const subLabelVal = 'some-sublabel';
61
+ const ariaDescribedByIdVal = 'some-external-id';
62
+ const ariaRequiredVal = 'true';
63
+
64
+ it.each([
65
+ ['text', 'input', ariaLabelVal, subLabelVal, ariaDescribedByIdVal],
66
+ ['cron', 'input', ariaLabelVal, subLabelVal, ariaDescribedByIdVal],
67
+ ['multiline', 'textarea', ariaLabelVal, subLabelVal, ariaDescribedByIdVal],
68
+ ['multiline-password', 'textarea', ariaLabelVal, subLabelVal, ariaDescribedByIdVal],
69
+ ])('for type %p should correctly fill out the appropriate fields on the component', (type, validationType, ariaLabel, subLabel, ariaDescribedById) => {
70
+ const wrapper = mount(LabeledInput, {
71
+ propsData: {
72
+ value: '', type, ariaLabel, subLabel, required: true
73
+ },
74
+ attrs: { 'aria-describedby': ariaDescribedById },
75
+ mocks: { $store: { getters: { 'i18n/t': jest.fn() } } }
76
+ });
77
+
78
+ const field = wrapper.find(validationType);
79
+ const ariaLabelProp = field.attributes('aria-label');
80
+ const ariaDescribedBy = field.attributes('aria-describedby');
81
+ const ariaRequired = field.attributes('aria-required');
82
+
83
+ // validates type of input rendered
84
+ expect(field.exists()).toBe(true);
85
+ expect(ariaLabelProp).toBe(ariaLabel);
86
+ expect(ariaDescribedBy).toBe(`${ ariaDescribedById } ${ wrapper.vm.describedById }`);
87
+ expect(ariaRequired).toBe(ariaRequiredVal);
88
+ });
89
+ });
90
+
91
+ it('a11y: rendering a "label" should not render an "aria-label" prop', () => {
92
+ const label = 'some-label';
93
+
94
+ const wrapper = mount(LabeledInput, {
95
+ propsData: { type: 'text', label },
96
+ mocks: { $store: { getters: { 'i18n/t': jest.fn() } } }
97
+ });
98
+
99
+ const mainInput = wrapper.find('input[type="text"]');
100
+
101
+ expect(mainInput.attributes('aria-label')).toBeUndefined();
102
+ expect(wrapper.find('label').text()).toBe(label);
103
+ });
57
104
  });
@@ -161,6 +161,19 @@ export default defineComponent({
161
161
  return this.isCompact ? false : !!this.label || !!this.labelKey || !!this.$slots.label;
162
162
  },
163
163
 
164
+ ariaDescribedBy(): string | undefined {
165
+ const inheritedDescribedBy = this.$attrs['aria-describedby'];
166
+ const internalDescribedBy = this.cronHint || this.subLabel ? this.describedById : undefined;
167
+
168
+ if (inheritedDescribedBy && internalDescribedBy) {
169
+ return `${ inheritedDescribedBy } ${ internalDescribedBy }`;
170
+ } else if (inheritedDescribedBy || internalDescribedBy) {
171
+ return `${ inheritedDescribedBy || internalDescribedBy }`;
172
+ }
173
+
174
+ return undefined;
175
+ },
176
+
164
177
  /**
165
178
  * Determines if the Labeled Input should display a tooltip.
166
179
  */
@@ -362,6 +375,7 @@ export default defineComponent({
362
375
  <span
363
376
  v-if="requiredField"
364
377
  class="required"
378
+ :aria-hidden="true"
365
379
  >*</span>
366
380
  </label>
367
381
  </slot>
@@ -377,11 +391,13 @@ export default defineComponent({
377
391
  v-stripped-aria-label="!hasLabel && ariaLabel ? ariaLabel : undefined"
378
392
  :maxlength="_maxlength"
379
393
  :disabled="isDisabled"
394
+ :aria-disabled="isDisabled"
380
395
  :value="value || ''"
381
396
  :placeholder="_placeholder"
382
397
  autocapitalize="off"
383
398
  :class="{ conceal: type === 'multiline-password' }"
384
- :aria-describedby="cronHint || subLabel ? describedById : undefined"
399
+ :aria-describedby="ariaDescribedBy"
400
+ :aria-required="requiredField"
385
401
  @update:value="onInput"
386
402
  @focus="onFocus"
387
403
  @blur="onBlur"
@@ -396,13 +412,15 @@ export default defineComponent({
396
412
  v-bind="$attrs"
397
413
  :maxlength="_maxlength"
398
414
  :disabled="isDisabled"
415
+ :aria-disabled="isDisabled"
399
416
  :type="type === 'cron' ? 'text' : type"
400
417
  :value="value"
401
418
  :placeholder="_placeholder"
402
419
  autocomplete="off"
403
420
  autocapitalize="off"
404
421
  :data-lpignore="ignorePasswordManagers"
405
- :aria-describedby="cronHint || subLabel ? describedById : undefined"
422
+ :aria-describedby="ariaDescribedBy"
423
+ :aria-required="requiredField"
406
424
  @input="onInput"
407
425
  @focus="onFocus"
408
426
  @blur="onBlur"
@@ -1,4 +1,4 @@
1
- import { shallowMount } from '@vue/test-utils';
1
+ import { shallowMount, mount } from '@vue/test-utils';
2
2
  import { RadioButton } from './index';
3
3
 
4
4
  describe('radioButton.vue', () => {
@@ -30,4 +30,39 @@ describe('radioButton.vue', () => {
30
30
 
31
31
  expect(wrapper.find('.radio-label').text()).toBe('Test Label - Slot');
32
32
  });
33
+
34
+ it('a11y: adding ARIA props should correctly fill out the appropriate fields on the component', async() => {
35
+ const val = 'foo';
36
+ const value = 'foo';
37
+ const description = 'some-description';
38
+ const itemLabel = 'some-label';
39
+ const radioOptionId = 'some-id-from-parent';
40
+
41
+ const wrapper = mount(
42
+ RadioButton,
43
+ {
44
+ propsData: {
45
+ label: itemLabel,
46
+ val,
47
+ value,
48
+ description,
49
+ radioOptionId
50
+ }
51
+ });
52
+
53
+ const radioInputElem = wrapper.find('span[role="radio"]');
54
+ const role = radioInputElem.attributes('role');
55
+ const ariaLabel = radioInputElem.attributes('aria-label');
56
+ const ariaChecked = radioInputElem.attributes('aria-checked');
57
+ const ariaDisabled = radioInputElem.attributes('aria-disabled');
58
+ const ariaDescribedBy = radioInputElem.attributes('aria-describedby');
59
+ const itemId = radioInputElem.attributes('id');
60
+
61
+ expect(role).toBe('radio');
62
+ expect(ariaLabel).toBe(itemLabel);
63
+ expect(ariaChecked).toBe('true');
64
+ expect(ariaDisabled).toBe('false');
65
+ expect(ariaDescribedBy).toBe(wrapper.vm.describeById);
66
+ expect(itemId).toBe(radioOptionId);
67
+ });
33
68
  });
@@ -1,10 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { defineComponent } from 'vue';
3
3
  import { _VIEW } from '@shell/config/query-params';
4
- import { randomStr } from '@shell/utils/string';
4
+ import { generateRandomAlphaString } from '@shell/utils/string';
5
5
 
6
6
  export default defineComponent({
7
- props: {
7
+
8
+ inheritAttrs: false,
9
+ props: {
8
10
  /**
9
11
  * The name of the input, for grouping.
10
12
  */
@@ -76,7 +78,16 @@ export default defineComponent({
76
78
  preventFocusOnRadioGroups: {
77
79
  type: Boolean,
78
80
  default: false
79
- }
81
+ },
82
+
83
+ /**
84
+ * Radio option Id - used to link to aria-activedescendant
85
+ * when using inside of the context of a Radio Group
86
+ */
87
+ radioOptionId: {
88
+ type: String,
89
+ default: undefined
90
+ },
80
91
  },
81
92
 
82
93
  emits: ['update:value'],
@@ -84,7 +95,8 @@ export default defineComponent({
84
95
  data() {
85
96
  return {
86
97
  isChecked: this.value === this.val,
87
- randomString: `${ randomStr() }-radio`,
98
+ randomString: `${ generateRandomAlphaString(12) }-radio`,
99
+ describeById: `${ generateRandomAlphaString(12) }-radio-described-id`,
88
100
  };
89
101
  },
90
102
 
@@ -165,11 +177,14 @@ export default defineComponent({
165
177
  @click.stop.prevent
166
178
  >
167
179
  <span
180
+ :id="radioOptionId"
168
181
  ref="custom"
169
182
  :class="[ isDisabled ? 'text-muted' : '', 'radio-custom']"
170
183
  :tabindex="isDisabled || preventFocusOnRadioGroups ? -1 : 0"
171
184
  :aria-label="label"
172
185
  :aria-checked="isChecked"
186
+ :aria-disabled="isDisabled"
187
+ :aria-describedby="descriptionKey || description ? describeById : undefined"
173
188
  role="radio"
174
189
  />
175
190
  <div class="labeling">
@@ -190,6 +205,7 @@ export default defineComponent({
190
205
  </label>
191
206
  <div
192
207
  v-if="descriptionKey || description"
208
+ :id="describeById"
193
209
  class="radio-button-outer-container-description"
194
210
  >
195
211
  <t
@@ -24,4 +24,64 @@ describe('component: RadioGroup', () => {
24
24
  expect(slot.disabled).toBe(disabled);
25
25
  });
26
26
  });
27
+
28
+ it('a11y: adding ARIA props should correctly fill out the appropriate fields on the component', async() => {
29
+ const inputLabel = 'some-label';
30
+ const ariaDescribedById = 'some-external-id';
31
+ const currValue = 'whatever';
32
+
33
+ const wrapper = mount(RadioGroup, {
34
+ propsData: {
35
+ name: 'some-name',
36
+ label: inputLabel,
37
+ value: currValue,
38
+ options: [{ label: currValue, value: currValue }]
39
+ },
40
+ attrs: { 'aria-describedby': ariaDescribedById }
41
+ });
42
+
43
+ const field = wrapper.find('[role="radiogroup"]');
44
+ const role = field.attributes('role');
45
+ const ariaLabel = field.attributes('aria-label');
46
+ const ariaDescribedBy = field.attributes('aria-describedby');
47
+ const ariaActiveDescendant = field.attributes('aria-activedescendant');
48
+
49
+ expect(ariaLabel).toBe(inputLabel);
50
+ expect(role).toBe('radiogroup');
51
+ expect(ariaActiveDescendant).toBe(`${ wrapper.vm.radioOptionsIdPrefix }0`);
52
+ expect(ariaDescribedBy).toBe(ariaDescribedById);
53
+
54
+ const radioOption = wrapper.find(`.radio-custom`);
55
+
56
+ // make sure we validate when using RadioGroup without custom slot data
57
+ // we do assign an ID that is important to get 'aria-activedescendant' working
58
+ expect(radioOption.attributes('id')).toBe(`${ wrapper.vm.radioOptionsIdPrefix }0`);
59
+ });
60
+
61
+ it('a11y: adding aria-label ($attrs) from parent should override label-based aria-label', async() => {
62
+ const inputLabel = 'some-label';
63
+ const overrideLabel = 'some-override-label';
64
+ const currValue = 'whatever';
65
+
66
+ const wrapper = mount(RadioGroup, {
67
+ propsData: {
68
+ name: 'some-name',
69
+ label: inputLabel,
70
+ value: currValue,
71
+ disabled: true,
72
+ options: [{ label: currValue, value: currValue }]
73
+ },
74
+ attrs: { 'aria-label': overrideLabel }
75
+ });
76
+
77
+ const field = wrapper.find('[role="radiogroup"]');
78
+ const ariaLabel = field.attributes('aria-label');
79
+ const ariaDisabled = field.attributes('aria-disabled');
80
+ const tabIndex = field.attributes('tabindex');
81
+
82
+ expect(ariaLabel).toBe(overrideLabel);
83
+ expect(ariaLabel).not.toBe(inputLabel);
84
+ expect(ariaDisabled).toBe('true');
85
+ expect(tabIndex).toBe('-1');
86
+ });
27
87
  });
@@ -2,11 +2,13 @@
2
2
  import { PropType, defineComponent } from 'vue';
3
3
  import { _VIEW } from '@shell/config/query-params';
4
4
  import RadioButton from '@components/Form/Radio/RadioButton.vue';
5
+ import { generateRandomAlphaString } from '@shell/utils/string';
5
6
 
6
7
  interface Option {
7
8
  value: unknown,
8
9
  label: string,
9
10
  description?: string,
11
+ radioOptionId?: string,
10
12
  }
11
13
 
12
14
  export default defineComponent({
@@ -106,7 +108,10 @@ export default defineComponent({
106
108
  emits: ['update:value'],
107
109
 
108
110
  data() {
109
- return { currFocusedElem: undefined as undefined | EventTarget | null };
111
+ return {
112
+ currFocusedElem: undefined as undefined | EventTarget | null,
113
+ radioOptionsIdPrefix: `radio-option-${ generateRandomAlphaString(12) }-`
114
+ };
110
115
  },
111
116
 
112
117
  computed: {
@@ -120,16 +125,21 @@ export default defineComponent({
120
125
  const opt = this.options[i];
121
126
 
122
127
  if (typeof opt === 'object' && opt) {
123
- out.push(opt);
128
+ out.push({
129
+ ...opt,
130
+ radioOptionId: `${ this.radioOptionsIdPrefix }${ i }`
131
+ });
124
132
  } else if (this.labels) {
125
133
  out.push({
126
- label: this.labels[i],
127
- value: opt
134
+ label: this.labels[i],
135
+ value: opt,
136
+ radioOptionId: `${ this.radioOptionsIdPrefix }${ i }`
128
137
  });
129
138
  } else {
130
139
  out.push({
131
- label: opt,
132
- value: opt
140
+ label: opt,
141
+ value: opt,
142
+ radioOptionId: `${ this.radioOptionsIdPrefix }${ i }`
133
143
  });
134
144
  }
135
145
  }
@@ -150,8 +160,36 @@ export default defineComponent({
150
160
  isDisabled(): boolean {
151
161
  return (this.disabled || this.isView);
152
162
  },
153
- radioGroupLabel(): string {
154
- return this.labelKey ? this.t(this.labelKey) : this.label ? this.label : '';
163
+ /**
164
+ * Radio Group Aria Label based on the label present on this input
165
+ */
166
+ radioGroupAriaLabel(): string | undefined {
167
+ // seems like VoiceOver screen reader isn't really picking up aria-labelledby
168
+ // let's just gather the label that comes in and assign it.
169
+ // We allow override with $attrs['aria-label'] for more control
170
+ if (this.$attrs['aria-label']) {
171
+ return this.$attrs['aria-label'] as string || undefined;
172
+ }
173
+
174
+ return this.labelKey ? this.t(this.labelKey) : this.label ? this.label : undefined;
175
+ },
176
+ /**
177
+ * Radio Group Aria DescribedBy parent attribute for extendability
178
+ */
179
+ radioGroupAriaDescribedBy(): string | undefined {
180
+ return this.$attrs['aria-describedby'] as string || undefined;
181
+ },
182
+ /**
183
+ * Radio Group value for aria-activedescendant HTML prop
184
+ */
185
+ ariaActiveDescendant(): string | undefined {
186
+ const activeOpt = this.normalizedOptions.find((opt) => opt.value === this.value);
187
+
188
+ if (this.value && activeOpt) {
189
+ return activeOpt.radioOptionId;
190
+ }
191
+
192
+ return '';
155
193
  }
156
194
  },
157
195
 
@@ -199,45 +237,46 @@ export default defineComponent({
199
237
  </script>
200
238
 
201
239
  <template>
202
- <fieldset>
240
+ <div>
203
241
  <!-- Label -->
204
242
  <div
205
243
  v-if="label || labelKey || tooltip || tooltipKey || $slots.label"
206
244
  class="radio-group label"
207
245
  >
208
- <legend>
209
- <slot name="label">
210
- <h3>
211
- <t
212
- v-if="labelKey"
213
- :k="labelKey"
214
- />
215
- <template v-else-if="label">
216
- {{ label }}
217
- </template>
218
- <i
219
- v-if="tooltipKey"
220
- v-clean-tooltip="t(tooltipKey)"
221
- class="icon icon-info icon-lg"
222
- />
223
- <i
224
- v-else-if="tooltip"
225
- v-clean-tooltip="tooltip"
226
- class="icon icon-info icon-lg"
227
- />
228
- </h3>
229
- </slot>
230
- </legend>
246
+ <slot name="label">
247
+ <h3>
248
+ <t
249
+ v-if="labelKey"
250
+ :k="labelKey"
251
+ />
252
+ <template v-else-if="label">
253
+ {{ label }}
254
+ </template>
255
+ <i
256
+ v-if="tooltipKey"
257
+ v-clean-tooltip="t(tooltipKey)"
258
+ class="icon icon-info icon-lg"
259
+ />
260
+ <i
261
+ v-else-if="tooltip"
262
+ v-clean-tooltip="tooltip"
263
+ class="icon icon-info icon-lg"
264
+ />
265
+ </h3>
266
+ </slot>
231
267
  </div>
232
268
 
233
269
  <!-- Group -->
234
270
  <div
235
271
  ref="radioGroup"
236
272
  role="radiogroup"
237
- :aria-label="radioGroupLabel"
273
+ :aria-label="radioGroupAriaLabel"
274
+ :aria-describedby="radioGroupAriaDescribedBy"
275
+ :aria-activedescendant="ariaActiveDescendant"
238
276
  class="radio-group"
239
277
  :class="{'row':row}"
240
- tabindex="0"
278
+ :tabindex="isDisabled ? -1 : 0"
279
+ :aria-disabled="isDisabled"
241
280
  @keydown.down.prevent.stop="clickNext(1)"
242
281
  @keydown.up.prevent.stop="clickNext(-1)"
243
282
  @keydown.space.enter.stop.prevent
@@ -257,6 +296,7 @@ export default defineComponent({
257
296
  :name="name"
258
297
  :value="value"
259
298
  :label="option.label"
299
+ :radio-option-id="option.radioOptionId"
260
300
  :description="option.description"
261
301
  :val="option.value"
262
302
  :disabled="isDisabled"
@@ -268,7 +308,7 @@ export default defineComponent({
268
308
  </slot>
269
309
  </div>
270
310
  </div>
271
- </fieldset>
311
+ </div>
272
312
  </template>
273
313
 
274
314
  <style lang='scss'>
@@ -91,4 +91,21 @@ describe('toggleSwitch.vue', () => {
91
91
  expect(wrapper.emitted('update:value')).toHaveLength(1);
92
92
  expect(wrapper.emitted('update:value')[0][0]).toBe(offValue);
93
93
  });
94
+
95
+ it('adds focus class when input is focused', async() => {
96
+ const wrapper = shallowMount(ToggleSwitch);
97
+
98
+ await wrapper.find('input').trigger('focus');
99
+
100
+ expect(wrapper.find('.slider').classes()).toContain('focus');
101
+ });
102
+
103
+ it('removes focus class when input is blurred', async() => {
104
+ const wrapper = shallowMount(ToggleSwitch);
105
+
106
+ await wrapper.find('input').trigger('focus');
107
+ await wrapper.find('input').trigger('blur');
108
+
109
+ expect(wrapper.find('.slider').classes()).not.toContain('focus');
110
+ });
94
111
  });
@@ -54,6 +54,11 @@ export default defineComponent({
54
54
  switchInput.value?.removeEventListener('focus', focus);
55
55
  switchInput.value?.removeEventListener('blur', blur);
56
56
  });
57
+
58
+ return {
59
+ switchChrome,
60
+ switchInput,
61
+ };
57
62
  },
58
63
 
59
64
  data() {
@@ -26,7 +26,15 @@ export default defineComponent({
26
26
  hover: {
27
27
  type: Boolean,
28
28
  default: true
29
- }
29
+ },
30
+ /**
31
+ * Inherited global identifier prefix for tests
32
+ * Define a term based on the parent component to avoid conflicts on multiple components
33
+ */
34
+ componentTestid: {
35
+ type: String,
36
+ default: 'labeledTooltip-info-icon'
37
+ },
30
38
  },
31
39
  computed: {
32
40
  iconClass(): string {
@@ -64,6 +72,7 @@ export default defineComponent({
64
72
  :class="{'hover':!value, [iconClass]: true}"
65
73
  class="icon status-icon"
66
74
  tabindex="0"
75
+ :data-testid="componentTestid"
67
76
  />
68
77
  </template>
69
78
  <template v-else>
@@ -15,6 +15,7 @@ const buttonRoles: { role: keyof ButtonRoleProps, className: string }[] = [
15
15
  { role: 'secondary', className: 'role-secondary' },
16
16
  { role: 'tertiary', className: 'role-tertiary' },
17
17
  { role: 'link', className: 'role-link' },
18
+ { role: 'multiAction', className: 'role-multi-action' },
18
19
  { role: 'ghost', className: 'role-ghost' },
19
20
  ];
20
21
 
@@ -50,7 +51,7 @@ defineExpose({ focus });
50
51
  <button
51
52
  ref="RcFocusTarget"
52
53
  role="button"
53
- :class="{ ...buttonClass, ...($attrs.class || { }) }"
54
+ :class="{ ...buttonClass }"
54
55
  >
55
56
  <slot name="before">
56
57
  <!-- Empty Content -->
@@ -9,6 +9,7 @@ export type ButtonRoleProps = {
9
9
  secondary?: boolean;
10
10
  tertiary?: boolean;
11
11
  link?: boolean;
12
+ multiAction?: boolean;
12
13
  ghost?: boolean;
13
14
  }
14
15