Merge pull request #24046 from jmchilton/fix_story_a11y

[26.2] Repair accessibility issues across shared controls and forms
This commit is contained in:
John Chilton
2026-10-14 20:29:14 -04:00
committed by GitHub
68 changed files with 1087 additions and 258 deletions
+17 -2
View File
@@ -76,7 +76,15 @@ onUpdated(() => nextTick(checkClamped));
<template>
<div v-if="props.separator" class="separator heading word-wrap-break">
<GButton v-if="collapsible" transparent size="small" icon-only inline @click="$emit('click')">
<GButton
v-if="collapsible"
:aria-label="collapsed ? 'Expand section' : 'Collapse section'"
:aria-expanded="collapsed ? 'false' : 'true'"
transparent
size="small"
icon-only
inline
@click="$emit('click')">
<FontAwesomeIcon v-if="collapsed" fixed-width :icon="faAngleDoubleDown" />
<FontAwesomeIcon v-else fixed-width :icon="faAngleDoubleUp" />
</GButton>
@@ -114,7 +122,14 @@ onUpdated(() => nextTick(checkClamped));
]"
:style="clampStyle"
@click="$emit('click')">
<GButton v-if="collapsible" transparent size="small" icon-only inline>
<GButton
v-if="collapsible"
:aria-label="collapsed ? 'Expand section' : 'Collapse section'"
:aria-expanded="collapsed ? 'false' : 'true'"
transparent
size="small"
icon-only
inline>
<FontAwesomeIcon v-if="collapsed" fixed-width :icon="faAngleDoubleDown" />
<FontAwesomeIcon v-else fixed-width :icon="faAngleDoubleUp" />
</GButton>
@@ -226,6 +226,7 @@ defineExpose({ showModal, hideModal });
<GButton
icon-only
class="g-modal-close-button"
aria-label="Close dialog"
transparent
size="large"
:disabled="cancelDisabled"
@@ -378,10 +378,13 @@ function updateContent(el: HTMLElement, bindingValue: unknown, state: TooltipSta
// Set aria-label so icon-only buttons have an accessible name
const target = getLabelTarget(el);
const labelledHere = !!target.dataset.gTooltipAriaLabel;
// A menu toggle with visible text or a label of its own is already named
const namedToggle =
target !== el && !labelledHere && (target.hasAttribute("aria-label") || !!target.textContent?.trim());
if (label && !namedToggle) {
// Tooltips describe text controls without replacing their visible or explicit names.
const alreadyNamed =
!!target.textContent?.trim() ||
target.hasAttribute("aria-labelledby") ||
(!labelledHere && target.hasAttribute("aria-label"));
const canName = target.matches('button, a[href], input, select, textarea, [role="button"], [role="img"]');
if (label && canName && !alreadyNamed) {
target.setAttribute("aria-label", label);
target.dataset.gTooltipAriaLabel = "1";
} else if (labelledHere) {
+84 -10
View File
@@ -1,8 +1,27 @@
diff --git a/dist/vue-multiselect.esm.js b/dist/vue-multiselect.esm.js
index 82db370faaa2623639583fccf1b061a50b1c23c1..7e004b3ee4122e7912f72dba843630082e06374d 100644
--- a/dist/vue-multiselect.esm.js
+++ b/dist/vue-multiselect.esm.js
@@ -790,10 +790,14 @@ var pointerMixin = {
@@ -595,6 +595,19 @@
* @param {type} option description
* @return {type} description
*/
+ removeTag (option, event) {
+ const button = event.currentTarget;
+ const restoreFocus = document.activeElement === button;
+ const index = Array.from(this.$refs.tags.querySelectorAll('button.multiselect__tag-icon')).indexOf(button);
+ this.removeElement(option);
+ if (restoreFocus) this.$nextTick(() => {
+ // Keep keyboard focus within the control after its selected tag disappears.
+ if (button.isConnected) return
+ const buttons = this.$refs.tags.querySelectorAll('button.multiselect__tag-icon');
+ const target = buttons[index] || buttons[index - 1] || this.$refs.search || this.$el;
+ target.focus();
+ });
+ },
removeElement (option, shouldClose = true) {
/* istanbul ignore else */
if (this.disabled) return
@@ -790,10 +803,14 @@
filteredOptions () {
this.pointerAdjust();
},
@@ -14,20 +33,75 @@ index 82db370faaa2623639583fccf1b061a50b1c23c1..7e004b3ee4122e7912f72dba84363008
+ if (!val && this.$refs.search) this.$refs.search.removeAttribute('aria-activedescendant');
},
pointer () {
+ if (!this.isOpen) return
+ if (!this.isOpen || !this.filteredOptions[this.pointer]) return
this.$refs.search && this.$refs.search.setAttribute('aria-activedescendant', this.id + '-' + this.pointer.toString());
}
},
@@ -1248,7 +1252,7 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
@@ -1214,17 +1231,17 @@
}
};
-const _hoisted_1 = ["tabindex", "aria-expanded", "aria-owns", "aria-activedescendant"];
+const _hoisted_1 = ["tabindex", "role", "aria-expanded", "aria-controls", "aria-activedescendant"];
const _hoisted_2 = {
ref: "tags",
class: "multiselect__tags"
};
const _hoisted_3 = { class: "multiselect__tags-wrap" };
const _hoisted_4 = ["textContent"];
-const _hoisted_5 = ["onKeydown", "onMousedown"];
+const _hoisted_5 = ["disabled", "aria-label", "onMousedown", "onClick"];
const _hoisted_6 = ["textContent"];
const _hoisted_7 = { class: "multiselect__spinner" };
-const _hoisted_8 = ["name", "id", "spellcheck", "placeholder", "required", "value", "disabled", "tabindex", "aria-label", "aria-controls"];
+const _hoisted_8 = ["name", "id", "spellcheck", "placeholder", "required", "value", "disabled", "tabindex", "aria-label", "aria-controls", "aria-expanded", "aria-activedescendant"];
const _hoisted_9 = ["id", "aria-multiselectable"];
const _hoisted_10 = { key: 0 };
const _hoisted_11 = { class: "multiselect__option" };
@@ -1246,10 +1263,10 @@
_cache[18] || (_cache[18] = withKeys(withModifiers($event => (_ctx.addPointerElement($event)), ["stop","self"]), ["enter","tab"]))
],
onKeyup: _cache[19] || (_cache[19] = withKeys($event => (_ctx.deactivate()), ["esc"])),
role: "combobox",
"aria-expanded": _ctx.isOpen,
- role: "combobox",
- "aria-expanded": _ctx.isOpen,
- "aria-owns": 'listbox-'+_ctx.id,
+ "aria-owns": _ctx.isOpen && $data.ready ? 'listbox-'+_ctx.id : null,
"aria-activedescendant": _ctx.isOpen && _ctx.pointer !== null ? _ctx.id + '-' + _ctx.pointer : null
- "aria-activedescendant": _ctx.isOpen && _ctx.pointer !== null ? _ctx.id + '-' + _ctx.pointer : null
+ role: _ctx.searchable ? "group" : "combobox",
+ "aria-expanded": !_ctx.searchable ? _ctx.isOpen : null,
+ "aria-controls": !_ctx.searchable && _ctx.isOpen && $data.ready ? 'listbox-'+_ctx.id : null,
+ "aria-activedescendant": !_ctx.searchable && _ctx.isOpen && _ctx.filteredOptions[_ctx.pointer] ? _ctx.id + '-' + _ctx.pointer : null
}, [
renderSlot(_ctx.$slots, "caret", { toggle: _ctx.toggle }, () => [
@@ -1326,7 +1330,8 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
createElementVNode("div", {
@@ -1280,10 +1297,12 @@
createElementVNode("span", {
textContent: toDisplayString(_ctx.getOptionLabel(option))
}, null, 8 /* PROPS */, _hoisted_4),
- createElementVNode("i", {
- tabindex: "1",
- onKeydown: withKeys(withModifiers($event => (_ctx.removeElement(option)), ["prevent"]), ["enter"]),
- onMousedown: withModifiers($event => (_ctx.removeElement(option)), ["prevent"]),
+ createElementVNode("button", {
+ type: "button",
+ disabled: $props.disabled,
+ "aria-label": 'Remove ' + _ctx.getOptionLabel(option),
+ onMousedown: withModifiers(() => {}, ["prevent"]),
+ onClick: withModifiers($event => (_ctx.removeTag(option, $event)), ["stop"]),
class: "multiselect__tag-icon"
}, null, 40 /* PROPS, NEED_HYDRATION */, _hoisted_5)
], 32 /* NEED_HYDRATION */))
@@ -1318,6 +1337,10 @@
name: $props.name,
id: _ctx.id,
type: "text",
+ role: "combobox",
+ "aria-expanded": _ctx.isOpen,
+ "aria-autocomplete": "list",
+ "aria-activedescendant": _ctx.isOpen && _ctx.filteredOptions[_ctx.pointer] ? _ctx.id + '-' + _ctx.pointer : null,
autocomplete: "off",
spellcheck: $props.spellcheck,
placeholder: _ctx.placeholder,
@@ -1326,7 +1349,8 @@
value: _ctx.search,
disabled: $props.disabled,
tabindex: $props.tabindex,
@@ -37,7 +111,7 @@ index 82db370faaa2623639583fccf1b061a50b1c23c1..7e004b3ee4122e7912f72dba84363008
onInput: _cache[2] || (_cache[2] = $event => (_ctx.updateSearch($event.target.value))),
onFocus: _cache[3] || (_cache[3] = withModifiers($event => (_ctx.activate()), ["prevent"])),
onBlur: _cache[4] || (_cache[4] = withModifiers($event => (_ctx.deactivate()), ["prevent"])),
@@ -1338,7 +1343,7 @@ function render(_ctx, _cache, $props, $setup, $data, $options) {
@@ -1338,7 +1362,7 @@
_cache[9] || (_cache[9] = withKeys(withModifiers($event => (_ctx.removeLastElement()), ["stop"]), ["delete"]))
],
class: "multiselect__input",
+3 -3
View File
@@ -21,7 +21,7 @@ patchedDependencies:
hash: 6520cf36ee58d1243c35ef0c96b26c7047ecde505686574659dc9d9e653407b2
path: patches/bootstrap-vue@2.23.1.patch
vue-multiselect@3.5.0:
hash: 3d3efaaf793c705789f73bb9c7dce2e3b7c28771bc92dba6bc265d59544e9da1
hash: 5a705b04177227e6cd45209b2333d41993de5178f0841ed61b490a7939a06394
path: patches/vue-multiselect@3.5.0.patch
importers:
@@ -321,7 +321,7 @@ importers:
version: 1.9.5
vue-multiselect:
specifier: ^3.3
version: 3.5.0(patch_hash=3d3efaaf793c705789f73bb9c7dce2e3b7c28771bc92dba6bc265d59544e9da1)
version: 3.5.0(patch_hash=5a705b04177227e6cd45209b2333d41993de5178f0841ed61b490a7939a06394)
vue-observe-visibility:
specifier: ^1.0.0
version: 1.0.0
@@ -10966,7 +10966,7 @@ snapshots:
vue-json-pretty@1.9.5: {}
vue-multiselect@3.5.0(patch_hash=3d3efaaf793c705789f73bb9c7dce2e3b7c28771bc92dba6bc265d59544e9da1): {}
vue-multiselect@3.5.0(patch_hash=5a705b04177227e6cd45209b2333d41993de5178f0841ed61b490a7939a06394): {}
vue-observe-visibility@1.0.0: {}
@@ -0,0 +1,24 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import GHeading from "../Common/Heading.vue";
describe("collapsible headings", () => {
it.each([false, true])("names the toggle and keeps its state in sync (separator=%s)", async (separator) => {
const wrapper = mount(GHeading, {
global: getLocalVue(),
props: { collapse: "closed", separator },
slots: { default: "Installation options" },
});
const toggle = wrapper.get("button");
expect(toggle.attributes("aria-label")).toBe("Expand section");
expect(toggle.attributes("aria-expanded")).toBe("false");
await toggle.trigger("click");
expect(wrapper.emitted("click")).toHaveLength(1);
await wrapper.setProps({ collapse: "open" });
expect(toggle.attributes("aria-label")).toBe("Collapse section");
expect(toggle.attributes("aria-expanded")).toBe("true");
wrapper.unmount();
});
});
@@ -21,6 +21,7 @@ describe("GModal", () => {
});
await flushPromises();
expect(showModal).toHaveBeenCalledOnce();
expect(wrapper.get("button.g-modal-close-button").attributes("aria-label")).toBe("Close dialog");
(wrapper.vm as unknown as { showModal: () => void }).showModal();
expect(showModal).toHaveBeenCalledOnce();
wrapper.unmount();
@@ -69,8 +69,8 @@ function inListElements(wrapper: ReturnType<typeof mount>): HDASummary[] {
async function selectIntoList(wrapper: ReturnType<typeof mount>, names: string[]) {
for (const name of names) {
const option = wrapper
.findAll(".options-list.unselected > button")
.find((candidate) => candidate.text().includes(name));
.findAll(".options-list.unselected .option-button")
.find((candidate) => candidate.attributes("aria-label") === `Select ${name}`);
await option?.trigger("click");
await flushPromises();
}
@@ -650,6 +650,7 @@ function selectionAsHdaSummary(value: any): HDASummary {
:class="{ selected: getSelectedDatasetElements.includes(element.id) }"
:element="element"
has-actions
selectable
:selected="getSelectedDatasetElements.includes(element.id)"
:show-hid="showHid"
@element-is-selected="elementSelected"
@@ -0,0 +1,66 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import type { HDASummary } from "@/api";
import FixedIdentifierDatasetCollectionElementView from "./FixedIdentifierDatasetCollectionElementView.vue";
import ListDatasetCollectionElementView from "./ListDatasetCollectionElementView.vue";
describe("List dataset actions", () => {
it("offers removal without an inert selection control for fixed identifiers", async () => {
const element = { id: "a", hid: 1, name: "Dataset", extension: "txt" } as HDASummary;
const wrapper = mount(FixedIdentifierDatasetCollectionElementView, {
props: { element },
global: getLocalVue(),
});
expect(wrapper.find(".dataset-selection-button").exists()).toBe(false);
await wrapper.get("button.btn-sm").trigger("click");
expect(wrapper.emitted("element-is-discarded")?.[0]).toEqual([element]);
});
it("keeps non-selectable read-only rows out of the tab order", () => {
const element = { id: "a", name: "Dataset" } as HDASummary;
const wrapper = mount(ListDatasetCollectionElementView, {
props: { element, notEditable: true },
global: getLocalVue(),
});
expect(wrapper.element.tagName).toBe("DIV");
expect(wrapper.find("button").exists()).toBe(false);
});
it("renders read-only pairing choices as keyboard-operable native buttons", async () => {
const element = { id: "a", hid: 1, name: "Dataset", extension: "txt" } as HDASummary;
const wrapper = mount(ListDatasetCollectionElementView, {
props: { element, notEditable: true, selectable: true },
global: getLocalVue(),
});
expect(wrapper.element.tagName).toBe("BUTTON");
expect(wrapper.attributes("type")).toBe("button");
expect(wrapper.text()).toContain("Dataset");
expect(wrapper.element.querySelector("button")).toBeNull();
await wrapper.trigger("click");
expect(wrapper.emitted("element-is-selected")?.[0]).toEqual([element]);
});
it("keeps selecting, renaming and removing independent", async () => {
const element = { id: "a", hid: 1, name: "Dataset", extension: "txt" } as HDASummary;
const wrapper = mount(ListDatasetCollectionElementView, {
props: { element, hasActions: true, selectable: true, selected: false },
global: getLocalVue(),
});
expect(wrapper.attributes("role")).toBeUndefined();
const select = wrapper.get(".dataset-selection-button");
expect(select.attributes("aria-label")).toContain("Dataset");
expect(select.attributes("aria-pressed")).toBe("false");
await select.trigger("click");
expect(wrapper.emitted("element-is-selected")).toHaveLength(1);
await wrapper.get(".click-to-edit-label").trigger("click");
await wrapper.get("input").setValue("Renamed");
await wrapper.get("input").trigger("keyup.enter");
expect(wrapper.emitted("onRename")?.[0]).toEqual(["Renamed"]);
expect(wrapper.emitted("element-is-selected")).toHaveLength(1);
await wrapper.get("button.btn-sm").trigger("click");
expect(wrapper.emitted("element-is-discarded")?.[0]).toEqual([element]);
expect(wrapper.emitted("element-is-selected")).toHaveLength(1);
});
});
@@ -11,6 +11,7 @@ import ClickToEdit from "@/components/Collections/common/ClickToEdit.vue";
interface Props {
element: HDASummary;
selected?: boolean;
selectable?: boolean;
hasActions?: boolean;
notEditable?: boolean;
hideExtension?: boolean;
@@ -45,16 +46,25 @@ watch(
</script>
<template>
<div
<component
:is="selectable && notEditable && !hasActions ? 'button' : 'div'"
:type="selectable && notEditable && !hasActions ? 'button' : undefined"
:aria-pressed="selectable && notEditable && !hasActions ? (selected ? 'true' : 'false') : undefined"
class="d-flex justify-content-between"
:data-hid="element.hid"
:class="{ 'collection-element': !textOnly, 'with-actions': hasActions }"
role="button"
data-description="list dataset collection element"
tabindex="0"
@keyup.enter="emit('element-is-selected', element)"
@click="emit('element-is-selected', element)">
<span class="d-flex flex-gapx-1 align-items-center">
@click="selectable && emit('element-is-selected', element)">
<button
v-if="hasActions && selectable"
type="button"
class="dataset-selection-button"
:aria-label="`${localize('Select dataset')} ${elementName}`"
:aria-pressed="selected ? 'true' : 'false'"
@click.stop="emit('element-is-selected', element)">
<FontAwesomeIcon :icon="faCheck" :class="{ 'text-muted': !selected }" />
</button>
<span class="d-flex flex-gapx-1 align-items-center flex-grow-1">
<span v-if="!hideHid && (element.hid ?? true)" data-description="dataset hid">{{ element.hid }}:</span>
<strong>
<ClickToEdit
@@ -69,19 +79,41 @@ watch(
<div v-if="hasActions" class="float-right">
<i v-if="!selected" class="mr-2">
<FontAwesomeIcon :icon="faCheck" class="text-success" /> Added to collection
<FontAwesomeIcon :icon="faCheck" class="dataset-added-icon" /> Added to collection
</i>
<i v-else class="text-secondary">Selected</i>
<button class="btn-sm" :title="localize('Remove this dataset from the list')" @click="clickDiscard">
<button
type="button"
class="btn-sm"
:title="localize('Remove this dataset from the list')"
@click.stop="clickDiscard">
<FontAwesomeIcon :icon="faMinus" fixed-width />
{{ localize("Remove") }}
</button>
</div>
</div>
</component>
</template>
<style scoped lang="scss">
.dataset-selection-button {
border: 0;
background: transparent;
color: inherit;
min-width: 24px;
min-height: 24px;
padding: 0;
margin-right: 4px;
}
.dataset-added-icon {
color: #237c3b;
}
.collection-element {
height: auto;
min-height: 24px;
text-align: left;
color: inherit;
background: transparent;
}
</style>
@@ -483,6 +483,7 @@ function _guessNameForPair(fwd: HDASummary, rev: HDASummary, removeExtensions: b
}"
:element="element"
not-editable
selectable
:selected="[pairElements.forward, pairElements.reverse].includes(element)"
@element-is-selected="selectElement"
@onRename="(name: string) => (element.name = name)" />
@@ -0,0 +1,35 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import ClickToEdit from "./ClickToEdit.vue";
describe("ClickToEdit accessibility", () => {
it.each(["enter", "space"])("starts editing with the %s key and saves the name", async (key) => {
const wrapper = mount(ClickToEdit, { props: { value: "Dataset" }, global: getLocalVue() });
const trigger = wrapper.get("button");
expect(trigger.attributes("type")).toBe("button");
await trigger.trigger(`keydown.${key}`);
const input = wrapper.get("input");
await input.setValue("Renamed dataset");
await input.trigger("keyup.enter");
expect(wrapper.emitted("input")?.[0]).toEqual(["Renamed dataset"]);
await wrapper.setProps({ value: "Renamed dataset" });
expect(wrapper.get("button").text()).toBe("Renamed dataset");
});
it("gives concurrently edited dataset inputs distinct ids", async () => {
const wrapper = mount(
{
components: { ClickToEdit },
template: '<div><ClickToEdit value="First" /><ClickToEdit value="Second" /></div>',
},
{ global: getLocalVue() },
);
const triggers = wrapper.findAll("button");
await triggers[0]!.trigger("click");
await triggers[1]!.trigger("click");
const inputs = wrapper.findAll("input");
expect(inputs[0]!.attributes("id")).not.toBe(inputs[1]!.attributes("id"));
});
});
@@ -2,7 +2,7 @@
import { faLevelDownAlt } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BFormInput } from "bootstrap-vue";
import { computed, ref, watch } from "vue";
import { computed, ref, useId, watch } from "vue";
interface Props {
value: string;
@@ -17,6 +17,8 @@ const emit = defineEmits<{
(e: "input", value: string): void;
}>();
const inputId = `click-to-edit-${useId()}`;
const clickToEditInput = ref<HTMLInputElement | null>(null);
const editable = ref(false);
const localValue = ref(props.value);
@@ -62,7 +64,7 @@ function revertToOriginal() {
<template>
<div v-if="editable" class="d-flex flex-gapx-1 input-icon-wrapper">
<BFormInput
id="click-to-edit-input"
:id="inputId"
ref="clickToEditInput"
v-model="localValue"
class="w-100 input-with-icon"
@@ -80,15 +82,16 @@ function revertToOriginal() {
</div>
<component
:is="props.component || 'label'"
:is="props.component || 'button'"
v-else
v-g-tooltip.onoverflow
role="button"
for="click-to-edit-input"
:type="!props.component || props.component === 'button' ? 'button' : undefined"
class="click-to-edit-label text-break"
tabindex="0"
:title="computedValue || title"
@keyup.enter="editable = true"
@keydown.enter.stop.prevent="editable = true"
@keydown.space.stop.prevent="editable = true"
@click.stop="editable = true">
<span v-if="computedValue">{{ computedValue }}</span>
<i v-else>{{ title }}</i>
@@ -98,11 +101,21 @@ function revertToOriginal() {
<style scoped lang="scss">
.click-to-edit-label {
cursor: text;
border: 0;
padding: 0;
min-height: 24px;
background: transparent;
color: inherit;
text-align: inherit;
&:hover > * {
text-decoration: underline;
}
}
button.click-to-edit-label {
font: inherit;
}
.input-icon-wrapper {
position: relative;
}
@@ -878,7 +878,9 @@ describe("CommandPalette", () => {
land();
await settle();
expect(wrapper.find("[data-description='palette searching']").exists()).toBe(false);
expect(wrapper.find("[data-description='palette empty']").exists()).toBe(true);
const empty = wrapper.get("[data-description='palette empty']");
expect(empty.element.closest("[role='listbox']")).toBeNull();
expect(wrapper.find("[role='group']").exists()).toBe(false);
} finally {
workflowsProvider.searchScoped = original;
}
@@ -586,48 +586,44 @@ watchImmediate(isPaletteOpen, (open) => {
</button>
</div>
<div
:id="listboxId"
ref="resultsElement"
class="palette-results"
role="listbox"
:aria-label="localize('Search results')">
<div
v-for="(section, sectionIdx) in visibleSections"
:key="section.id"
role="group"
:aria-label="localize(section.title)"
:data-description="`palette section ${section.id}`">
<div class="palette-section-title" aria-hidden="true">{{ localize(section.title) }}</div>
<div ref="resultsElement" class="palette-results" tabindex="0">
<div :id="listboxId" role="listbox" :aria-label="localize('Search results')">
<div
v-for="(section, sectionIdx) in visibleSections"
:key="section.id"
:role="section.items.length ? 'group' : undefined"
:aria-label="section.items.length ? localize(section.title) : undefined"
:data-description="`palette section ${section.id}`">
<div class="palette-section-title" aria-hidden="true">{{ localize(section.title) }}</div>
<!-- Nothing rendered and nothing answered yet, so the section holds its place -->
<template v-if="section.loading && section.items.length === 0">
<div
v-for="row in SKELETON_ROWS"
:key="row"
class="palette-skeleton"
aria-hidden="true"
data-description="palette skeleton">
<span class="skeleton-bar skeleton-title"></span>
<!-- Nothing rendered and nothing answered yet, so the section holds its place -->
<template v-if="section.loading && section.items.length === 0">
<div
v-for="row in SKELETON_ROWS"
:key="row"
class="palette-skeleton"
aria-hidden="true"
data-description="palette skeleton">
<span class="skeleton-bar skeleton-title"></span>
<span class="skeleton-bar skeleton-subtitle"></span>
</div>
</template>
<span class="skeleton-bar skeleton-subtitle"></span>
</div>
</template>
<template v-else>
<CommandPaletteItem
v-for="(item, itemIdx) in section.items"
:id="optionId(optionIndex(sectionIdx, itemIdx))"
:key="item.id"
:active="optionIndex(sectionIdx, itemIdx) === selectedIndex"
:item="item"
:secondary-hint="secondaryHint(optionIndex(sectionIdx, itemIdx), item)"
:show-external="showExternalIcon(optionIndex(sectionIdx, itemIdx), item)"
@select="runItem(item, $event)"
@highlight="highlightItem(optionIndex(sectionIdx, itemIdx))" />
</template>
<template v-else>
<CommandPaletteItem
v-for="(item, itemIdx) in section.items"
:id="optionId(optionIndex(sectionIdx, itemIdx))"
:key="item.id"
:active="optionIndex(sectionIdx, itemIdx) === selectedIndex"
:item="item"
:secondary-hint="secondaryHint(optionIndex(sectionIdx, itemIdx), item)"
:show-external="showExternalIcon(optionIndex(sectionIdx, itemIdx), item)"
@select="runItem(item, $event)"
@highlight="highlightItem(optionIndex(sectionIdx, itemIdx))" />
</template>
</div>
</div>
<div v-if="scopeHint" class="palette-hint" data-description="palette scope hint">
{{ scopeHint }}
</div>
+7 -2
View File
@@ -46,7 +46,12 @@ const doExport = () => {
<template>
<div class="export-to-remote-file">
<BFormGroup id="fieldset-directory" label-for="directory" :description="directoryDescription" class="mt-3">
<BFormGroup
id="fieldset-directory"
label="Repository directory"
label-for="directory"
:description="directoryDescription"
class="mt-3">
<FilesInput
id="directory"
:value="directory"
@@ -56,7 +61,7 @@ const doExport = () => {
@input="(v: string) => (directory = v)" />
</BFormGroup>
<BFormGroup id="fieldset-name" label-for="name" :description="nameDescription" class="mt-3">
<BFormGroup id="fieldset-name" label="File name" label-for="name" :description="nameDescription" class="mt-3">
<BFormInput id="name" v-model="name" :placeholder="namePlaceholder" required />
</BFormGroup>
@@ -41,6 +41,36 @@ describe("GenericWizard", () => {
expect(call?.[0]).toContain('target="_blank"');
});
it("keeps completed steps named when their number becomes an icon", async () => {
const host = defineComponent({
setup() {
const wizard = useWizard({
format: {
label: "Select format",
instructions: "Choose format",
isValid: () => true,
isSkippable: () => false,
},
destination: {
label: "Select destination",
instructions: "Choose destination",
isValid: () => true,
isSkippable: () => false,
},
});
return () => h(GenericWizard, { use: wizard });
},
});
const wrapper = mount(host, { localVue: getLocalVue() });
await wrapper.get(".go-next-btn").trigger("click");
const completedStep = wrapper.get(".step-number.done");
expect(completedStep.text()).toBe("");
expect(completedStep.attributes("aria-label")).toBe("1: Select format");
await completedStep.trigger("click");
expect(wrapper.get(".step-instructions").text()).toBe("Choose format");
wrapper.unmount();
});
it("wraps the wizard in a card by default", () => {
const wrapper = mount(Host as object, { localVue: getLocalVue(), propsData: { description: "Pick one" } });
@@ -195,6 +195,8 @@ const bodyStyle = computed(() => {
:class="step.isSkippable() ? 'skipped ' : ''">
<button
class="step-number"
type="button"
:aria-label="`${determineDisplayStepIndex(i)}: ${step.label}`"
:class="{ active: props.use.isCurrent(id), done: isStepDone(i) }"
:disabled="(!allStepsBeforeAreValid(i) && props.use.isBefore(id)) || isBusy"
@click="props.use.goTo(id)">
@@ -26,6 +26,19 @@ describe("FormOptionalText", () => {
expect(emittedArg(wrapper, "input", 1)).toEqual("");
});
it("names an optional input and keeps its label associated after toggling", async () => {
const wrapper = await mountFormOptionalText({ value: "hg38" });
const label = wrapper.find("label.sr-only");
expect(label.text()).toBe("Optional value");
expect(label.attributes("for")).toBe(wrapper.find("input[type=text]").attributes("id"));
await wrapper.find("input[type=checkbox]").setValue(false);
await wrapper.setProps({ value: null });
expect(wrapper.find("label.sr-only").exists()).toBe(false);
await wrapper.find("input[type=checkbox]").setValue(true);
await wrapper.setProps({ value: "" });
expect(wrapper.find("label.sr-only").attributes("for")).toBe(label.attributes("for"));
});
it("should initialize with null if value does not exist", async () => {
const v = null;
const wrapper = await mountFormOptionalText({ value: v });
@@ -3,9 +3,10 @@
<BFormCheckbox v-model="currentStatus" class="ui-switch" switch>
Set value for this optional select field?
</BFormCheckbox>
<label v-if="currentStatus && !id" :for="inputId" class="sr-only">Optional value</label>
<FormText
v-if="currentStatus"
:id="id"
:id="inputId"
v-model="currentValue"
:readonly="readonly"
:value="value"
@@ -20,6 +21,8 @@
<script>
import { BFormCheckbox } from "bootstrap-vue";
import { uid } from "@/utils/utils";
import FormText from "./FormText.vue";
export default {
@@ -67,9 +70,13 @@ export default {
data() {
return {
status: false,
generatedId: `optional-text-${uid()}`,
};
},
computed: {
inputId() {
return this.id || this.generatedId;
},
currentValue: {
get() {
return this.value;
@@ -6,7 +6,6 @@ import { computed, type ComputedRef, onMounted, type PropType, ref, watch } from
import Multiselect from "vue-multiselect";
import { useFilterObjectArray } from "@/composables/filter";
import { useMultiselect } from "@/composables/useMultiselect";
import { uid } from "@/utils/utils";
import { type DataOption, isDataOption, itemUniqueKey } from "./FormData/types";
@@ -14,8 +13,6 @@ import { type DataOption, isDataOption, itemUniqueKey } from "./FormData/types";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import StatelessTags from "@/components/TagsMultiselect/StatelessTags.vue";
const { ariaExpanded, onOpen, onClose } = useMultiselect();
// vue-multiselect's types only allow a string here, but at runtime an explicit null
// (not just omitting the prop) suppresses the label instead of falling back to its
// default text -- its .d.ts doesn't account for that.
@@ -280,7 +277,6 @@ function isSelected(item: SelectValue): boolean {
:name="id"
:data-filter-pending="filterPending ? 'true' : undefined"
:allow-empty="optional || multiple"
:aria-expanded="ariaExpanded"
:close-on-select="!multiple"
:disabled="disabled"
:deselect-label="noLabel"
@@ -292,9 +288,7 @@ function isSelected(item: SelectValue): boolean {
:select-label="noLabel"
:track-by="trackBy"
:internal-search="false"
@search-change="onSearchChange"
@open="onOpen"
@close="onClose">
@search-change="onSearchChange">
<template v-slot:option="{ option }">
<!-- Replace recycled option content when its identity changes. -->
<div
@@ -23,10 +23,10 @@ function mountSelectMany(props: Partial<InstanceType<typeof FormSelectMany>["$pr
}
const selectors = {
unselectedOptions: ".options-list.unselected > button",
unselectedHighlighted: ".options-list.unselected > button.highlighted",
selectedOptions: ".options-list:not(.unselected) > button",
selectedHighlighted: ".options-list:not(.unselected) > button.highlighted",
unselectedOptions: ".options-list.unselected .option-button",
unselectedHighlighted: ".options-list.unselected > .option-row.highlighted .option-button",
selectedOptions: ".options-list:not(.unselected) .option-button",
selectedHighlighted: ".options-list:not(.unselected) > .option-row.highlighted .option-button",
selectAll: ".selection-button.select",
deselectAll: ".selection-button.deselect",
selectedCount: ".selected-count",
@@ -82,6 +82,22 @@ async function search(wrapper: ReturnType<typeof mountSelectMany>, value: string
}
describe("FormSelectMany", () => {
it("keeps custom label actions beside the option button", async () => {
const wrapper = mount(FormSelectMany as any, {
props: { options: [{ label: "Dataset", value: "dataset" }], value: [] },
slots: { "label-area": '<button class="rename" @click.stop>Rename dataset</button>' },
pinia,
global: localVue,
});
const select = wrapper.get(".option-button");
expect(select.attributes("aria-label")).toBe("Select Dataset");
expect(select.find(".rename").exists()).toBe(false);
await wrapper.get(".rename").trigger("click");
expect(wrapper.emitted("input")).toBeUndefined();
await select.trigger("click");
expect(wrapper.emitted("input")?.[0]).toEqual([["dataset"]]);
});
it("displays all options", async () => {
const options = generateOptionsFromArrays([["foo", "bar", "baz"], ["@"], ["galaxy"], [".com", ".org"]]);
const wrapper = mountSelectMany({ options });
@@ -340,11 +340,15 @@ const selectedCount = computed(() => {
type="search"
:state="regexInvalid ? false : undefined"
:debounce="300"
:placeholder="props.placeholder" />
:placeholder="props.placeholder"
:aria-label="props.placeholder" />
<button
class="inline-icon-button"
:class="{ hidden: searchValue === '' }"
type="button"
title="Clear search"
aria-label="Clear search"
:disabled="searchValue === ''"
@click="searchValue = ''">
<FontAwesomeIcon :icon="faTimes" />
</button>
@@ -393,18 +397,28 @@ const selectedCount = computed(() => {
tabindex="-1"
@keydown.up.down.prevent
@blur="highlightUnselected.abortHighlight">
<button
<div
v-for="(option, i) in unselectedOptionsFiltered"
:id="`${props.id}-unselected-${i}`"
:key="option.label"
:tabindex="i === 0 ? 0 : -1"
:class="{ highlighted: highlightUnselected.highlightedIndexes.includes(i) }"
@click="(e: MouseEvent) => selectOption(e, i)"
@keydown="(e: KeyboardEvent) => optionOnKey('unselected', e, i)">
<slot v-bind="{ option, selected: false }" name="label-area">
{{ option.label }}
</slot>
</button>
class="option-row"
:class="{
highlighted: highlightUnselected.highlightedIndexes.includes(i),
'with-custom-label': !!$slots['label-area'],
}"
@click="(e: MouseEvent) => selectOption(e, i)">
<button
:id="`${props.id}-unselected-${i}`"
type="button"
class="option-button"
:tabindex="i === 0 ? 0 : -1"
:aria-label="`Select ${option.label}`"
@click.stop="(e: MouseEvent) => selectOption(e, i)"
@keydown="(e: KeyboardEvent) => optionOnKey('unselected', e, i)">
<FontAwesomeIcon v-if="$slots['label-area']" :icon="faLongArrowAltRight" />
<template v-else>{{ option.label }}</template>
</button>
<slot v-bind="{ option, selected: false }" name="label-area" />
</div>
<span v-if="moreUnselected" class="show-more-indicator">
Limited to {{ unselectedDisplayCount }} options.
@@ -429,18 +443,28 @@ const selectedCount = computed(() => {
tabindex="-1"
@keydown.up.down.prevent
@blur="highlightSelected.abortHighlight">
<button
<div
v-for="(option, i) in selectedOptionsFiltered"
:id="`${props.id}-selected-${i}`"
:key="option.label"
:tabindex="i === 0 ? 0 : -1"
:class="{ highlighted: highlightSelected.highlightedIndexes.includes(i) }"
@click="(e: MouseEvent) => deselectOption(e, i)"
@keydown="(e: KeyboardEvent) => optionOnKey('selected', e, i)">
<slot v-bind="{ option, selected: true }" name="label-area">
{{ option.label }}
</slot>
</button>
class="option-row"
:class="{
highlighted: highlightSelected.highlightedIndexes.includes(i),
'with-custom-label': !!$slots['label-area'],
}"
@click="(e: MouseEvent) => deselectOption(e, i)">
<button
:id="`${props.id}-selected-${i}`"
type="button"
class="option-button"
:tabindex="i === 0 ? 0 : -1"
:aria-label="`Deselect ${option.label}`"
@click.stop="(e: MouseEvent) => deselectOption(e, i)"
@keydown="(e: KeyboardEvent) => optionOnKey('selected', e, i)">
<FontAwesomeIcon v-if="$slots['label-area']" :icon="faLongArrowAltLeft" />
<template v-else>{{ option.label }}</template>
</button>
<slot v-bind="{ option, selected: true }" name="label-area" />
</div>
<span v-if="moreSelected" class="show-more-indicator">
Limited to {{ selectedDisplayCount }} options.
@@ -524,7 +548,7 @@ const selectedCount = computed(() => {
justify-content: space-between;
.selection-button {
height: 20px;
min-height: 24px;
padding: 0 0.5rem;
display: flex;
align-items: center;
@@ -538,38 +562,56 @@ const selectedCount = computed(() => {
display: flex;
flex-direction: column;
button {
text-align: start;
border: none;
padding: 0 0.5rem;
background: none;
transition: none;
.option-row {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 24px;
cursor: pointer;
}
.option-button {
min-height: 24px;
min-width: 24px;
flex-grow: 1;
}
.option-row.with-custom-label .option-button {
flex-grow: 0;
}
.option-row {
text-align: start;
background: none;
&.highlighted {
background-color: $brand-info;
border-radius: 0;
color: $white;
&:hover,
&:focus {
&:focus-within {
background-color: $brand-primary;
&::after {
color: $white;
}
}
}
}
.option-button {
border: none;
padding: 0 0.5rem;
background: none;
color: inherit;
transition: none;
display: flex;
justify-content: space-between;
text-align: start;
&:focus-visible {
outline-color: $brand-primary;
outline-width: 2px;
outline: 2px solid $brand-primary;
outline-offset: -2px;
outline-style: solid;
box-shadow: none;
}
}
.option-row:not(.with-custom-label) .option-button {
&:hover,
&:focus {
background-color: $brand-secondary;
@@ -586,6 +628,17 @@ const selectedCount = computed(() => {
}
}
.option-row.highlighted:not(.with-custom-label) .option-button {
&:hover,
&:focus {
background-color: $brand-primary;
&::after {
color: $white;
}
}
}
.show-more-indicator {
display: flex;
font-style: italic;
@@ -605,7 +658,7 @@ const selectedCount = computed(() => {
}
.options-list.selected {
button {
.option-row:not(.with-custom-label) .option-button {
&:hover,
&:focus {
&::after {
@@ -621,7 +674,7 @@ const selectedCount = computed(() => {
.bottom-row-info {
font-style: italic;
color: darken($gray-400, 10%);
color: $gray-700;
font-size: 0.75rem;
padding: 0 0.25rem;
width: 100%;
@@ -3,6 +3,7 @@ import { storeToRefs } from "pinia";
import { computed, type PropType, ref, watch } from "vue";
import { useUserFlagsStore } from "@/stores/userFlagsStore";
import { uid } from "@/utils/utils";
import FormCheck from "./FormCheck.vue";
import FormRadio from "./FormRadio.vue";
@@ -22,6 +23,7 @@ const emit = defineEmits<{
}>();
const props = defineProps({
id: { type: String, default: () => `form-selection-${uid()}` },
value: {
type: null as unknown as PropType<any>,
default: null,
@@ -153,11 +155,12 @@ defineExpose({
</script>
<template>
<div class="form-selection">
<div :id="`${id}-container`" class="form-selection">
<FormCheck v-if="display === 'checkboxes'" v-model="currentValue" :options="currentOptions" />
<FormRadio v-else-if="display === 'radio'" v-model="currentValue" :options="currentOptions" />
<FormSelectMany
v-else-if="displayMany"
:id="id"
v-model="currentValue"
:options="currentOptions"
:total-estimate="totalEstimate"
@@ -168,6 +171,7 @@ defineExpose({
</FormSelectMany>
<FormSelect
v-else
:id="id"
v-model="currentValue"
:multiple="multiple"
:optional="optional"
@@ -30,14 +30,17 @@ const { preferredFormSelectElement } = storeToRefs(useUserFlagsStore());
<template>
<div class="d-flex">
<button v-if="showManyButton" class="ui-link ml-1" @click="emit('use-many', true)">
<button v-if="showManyButton" class="ui-link ml-1 select-preference-switch" @click="emit('use-many', true)">
switch to column select
</button>
<button v-else-if="showMultiButton" class="ui-link ml-1" @click="emit('use-many', false)">
<button
v-else-if="showMultiButton"
class="ui-link ml-1 select-preference-switch"
@click="emit('use-many', false)">
switch to simple select
</button>
<GDropdown toggle-class="inline-icon-button d-block px-1" variant="link" no-caret>
<GDropdown toggle-class="inline-icon-button d-block px-1 select-preference-toggle" variant="link" no-caret>
<template v-slot:button-content>
<FontAwesomeIcon :icon="faCaretDown" />
<span class="sr-only">select element preferences</span>
@@ -60,3 +63,11 @@ const { preferredFormSelectElement } = storeToRefs(useUserFlagsStore());
</GDropdown>
</div>
</template>
<style scoped>
.select-preference-switch,
:deep(.select-preference-toggle) {
min-width: 24px;
min-height: 24px;
}
</style>
@@ -0,0 +1,101 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount, type VueWrapper } from "@vue/test-utils";
import { afterEach, describe, expect, it } from "vitest";
import { nextTick } from "vue";
import Multiselect from "vue-multiselect";
// Exercise Galaxy's dependency patch against the actual multiselect, without a stub.
describe("multiselect accessibility", () => {
let wrapper: VueWrapper;
afterEach(() => wrapper?.unmount());
it("puts combobox state on the focused input and references only mounted options", async () => {
wrapper = mount(Multiselect, {
global: getLocalVue(),
props: {
id: "selection",
options: ["Alpha", "Beta"],
modelValue: "Alpha",
placeholder: "Choose a dataset",
},
attachTo: document.body,
});
const input = wrapper.find("input");
expect(input.attributes("role")).toBe("combobox");
expect(input.attributes("aria-expanded")).toBe("false");
expect(input.attributes("aria-controls")).toBeUndefined();
expect(input.attributes("aria-activedescendant")).toBeUndefined();
await input.trigger("focus");
expect(input.attributes("aria-expanded")).toBe("true");
expect(document.getElementById(input.attributes("aria-controls")!)?.getAttribute("role")).toBe("listbox");
await input.trigger("keydown", { key: "ArrowDown" });
expect(document.getElementById(input.attributes("aria-activedescendant")!)?.getAttribute("role")).toBe(
"option",
);
await input.trigger("keyup", { key: "Escape" });
expect(input.attributes("aria-expanded")).toBe("false");
expect(input.attributes("aria-controls")).toBeUndefined();
expect(input.attributes("aria-activedescendant")).toBeUndefined();
});
it.each([true, false])("retains focus through tag removals (searchable=%s)", async (searchable) => {
wrapper = mount(Multiselect, {
global: getLocalVue(),
props: {
id: "focus-selection",
options: ["Alpha", "Beta", "Gamma"],
modelValue: ["Alpha", "Beta", "Gamma"],
multiple: true,
searchable,
"onUpdate:modelValue": (modelValue: unknown) => wrapper.setProps({ modelValue }),
},
attachTo: document.body,
});
for (const [removed, next] of [
["Beta", "Gamma"],
["Gamma", "Alpha"],
["Alpha", null],
]) {
const button = wrapper.get(`button[aria-label="Remove ${removed}"]`);
(button.element as HTMLButtonElement).focus();
await button.trigger("click", { detail: 0 });
await nextTick();
expect(wrapper.find(`button[aria-label="Remove ${removed}"]`).exists()).toBe(false);
const target = next
? wrapper.get(`button[aria-label="Remove ${next}"]`).element
: searchable
? wrapper.get("input").element
: wrapper.element;
expect(document.activeElement).toBe(target);
}
});
it("keeps focus on a tag whose removal is disallowed", async () => {
wrapper = mount(Multiselect, {
global: getLocalVue(),
props: { options: ["Alpha"], modelValue: ["Alpha"], multiple: true, allowEmpty: false },
attachTo: document.body,
});
const button = wrapper.get("button.multiselect__tag-icon");
(button.element as HTMLButtonElement).focus();
await button.trigger("click", { detail: 0 });
expect(wrapper.emitted("update:modelValue")).toBeUndefined();
expect(document.activeElement).toBe(button.element);
});
it("uses named native remove buttons in the normal tab order and removes only their selection", async () => {
wrapper = mount(Multiselect, {
global: getLocalVue(),
props: { id: "permissions", options: ["Alpha", "Beta"], modelValue: ["Alpha", "Beta"], multiple: true },
});
const buttons = wrapper.findAll("button.multiselect__tag-icon");
expect(buttons).toHaveLength(2);
expect(buttons.map((button) => button.attributes("aria-label"))).toEqual(["Remove Alpha", "Remove Beta"]);
expect(buttons.map((button) => (button.element as HTMLButtonElement).tabIndex)).toEqual([0, 0]);
expect(buttons.every((button) => button.attributes("type") === "button")).toBe(true);
await buttons[0]!.trigger("click");
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([["Beta"]]);
await wrapper.setProps({ disabled: true });
expect(wrapper.findAll("button").every((button) => (button.element as HTMLButtonElement).disabled)).toBe(true);
});
});
@@ -2,6 +2,7 @@
withDefaults(
defineProps<{
help?: string;
labelFor?: string;
required?: boolean;
title: string;
condition?: boolean;
@@ -15,14 +16,14 @@ withDefaults(
<template>
<div>
<div class="font-weight-bold py-2">
<component :is="labelFor ? 'label' : 'div'" :for="labelFor" class="font-weight-bold py-2">
<span>{{ title }}</span>
<small v-if="required && condition" class="align-top">*</small>
<small v-else-if="required" class="text-danger">
<span class="align-top">*</span>
<small class="align-top">required</small>
</small>
</div>
</component>
<slot />
<small v-localize class="text-muted py-2">{{ help }}</small>
</div>
@@ -0,0 +1,22 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import ZoomControl from "./ZoomControl.vue";
const localVue = getLocalVue();
describe("ZoomControl", () => {
it("keeps the displayed zoom percentage in the reset button's accessible name", async () => {
const wrapper = mount(ZoomControl, { props: { zoomLevel: 0.75 }, global: localVue });
const reset = wrapper.get(".zoom-reset");
expect(reset.text()).toContain("75%");
expect(reset.attributes("aria-label")).toBe("75% zoom. Reset Zoom Level");
await reset.trigger("click");
expect(wrapper.emitted("onZoom")).toEqual([[1]]);
await wrapper.setProps({ zoomLevel: 1.25 });
expect(reset.attributes("aria-label")).toBe("125% zoom. Reset Zoom Level");
});
});
+1 -1
View File
@@ -50,7 +50,7 @@ function onZoomReset() {
title="Reset Zoom Level"
pill
size="small"
aria-label="Reset Zoom Level"
:aria-label="`${zoomPercentage}% zoom. Reset Zoom Level`"
@click="onZoomReset">
{{ zoomPercentage }}%
</GButton>
@@ -80,6 +80,15 @@ describe("ContentItem", () => {
});
});
it("names the converted-item count with its action", async () => {
await wrapper.setProps({ item: { ...item, sub_items: [{ id: "converted-1" }, { id: "converted-2" }] } });
const button = wrapper.get("button.display-btn");
expect(button.attributes("aria-label")).toBe("Show converted items (2)");
await button.trigger("click");
await wrapper.setProps({ item: { ...item, sub_items: [{ id: "converted-1" }] } });
expect(wrapper.get("button.display-btn").attributes("aria-label")).toBe("Show converted items (1)");
});
it("check basics", async () => {
expect(wrapper.attributes("data-hid")).toBe("1");
expect(wrapper.find(".content-title").text()).toBe("name");
@@ -17,6 +17,7 @@ import { updateContentFields } from "@/components/History/model/queries";
import { useWindowAwareNavigation } from "@/composables/windowAwareNavigation";
import { useEntryPointStore } from "@/stores/entryPointStore";
import DATASET_STATES from "@/utils/datasetStates";
import localize from "@/utils/localization";
import { clearDrag } from "@/utils/setDrag";
import { getContentItemState, type State, STATES } from "./model/states";
@@ -380,6 +381,7 @@ function unexpandedClick(event: Event) {
v-if="item.sub_items?.length && !isSubItem"
v-g-tooltip.hover
title="Show converted items"
:aria-label="`${localize('Show converted items')} (${item.sub_items.length})`"
tabindex="0"
class="display-btn px-1 align-items-center"
size="small"
@@ -4,7 +4,7 @@ import flushPromises from "flush-promises";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RegisteredUser } from "@/api";
import type { HistorySummaryExtended, RegisteredUser } from "@/api";
import { useServerMock } from "@/api/client/__mocks__";
import { setSseConnected, setSseHasEverConnected, sseMockFactory } from "@/stores/_testing/sseStoreSupport";
import { useConfigStore } from "@/stores/configurationStore";
@@ -207,6 +207,36 @@ describe("HistoryCounter — refresh button", () => {
});
});
it("names count controls with their actions and current counts", async () => {
setEnableSse(false);
const history: HistorySummaryExtended = {
...baseHistory,
annotation: "",
count: 6,
model_class: "History",
tags: [],
url: "/api/histories/hist-1",
contents_active: { active: 3, deleted: 2, hidden: 1 },
};
const wrapper = mount(HistoryCounter, {
props: { history, lastChecked: new Date() },
global: { ...localVue, plugins: [pinia] },
});
await flushPromises();
for (const [description, label] of [
["show active items button", "Show active (3)"],
["include deleted items button", "Include deleted (2)"],
["include hidden items button", "Include hidden (1)"],
["storage dashboard button", "History Size (0 B)"],
]) {
expect(wrapper.get(`button[data-description="${description}"]`).attributes("aria-label")).toBe(label);
}
await wrapper.setProps({ history: { ...history, contents_active: { active: 4, deleted: 5, hidden: 2 } } });
expect(wrapper.get('button[data-description="include deleted items button"]').attributes("aria-label")).toBe(
"Include deleted (5)",
);
});
it("emits reloadContents when the refresh button is clicked", async () => {
setEnableSse(true);
setSseConnected(sseState, true);
@@ -191,6 +191,7 @@ onMounted(() => {
<GButton
tooltip
:title="localize('History Size')"
:aria-label="`${localize('History Size')} (${niceHistorySize})`"
transparent
size="small"
color="blue"
@@ -221,6 +222,7 @@ onMounted(() => {
<GButton
v-g-tooltip.hover
:title="localize('Show active')"
:aria-label="`${localize('Show active')} (${numItemsActive})`"
transparent
color="blue"
size="small"
@@ -235,6 +237,7 @@ onMounted(() => {
v-if="numItemsDeleted"
v-g-tooltip.hover
:title="localize('Include deleted')"
:aria-label="`${localize('Include deleted')} (${numItemsDeleted})`"
transparent
color="blue"
size="small"
@@ -250,6 +253,7 @@ onMounted(() => {
v-if="numItemsHidden"
v-g-tooltip.hover
:title="localize('Include hidden')"
:aria-label="`${localize('Include hidden')} (${numItemsHidden})`"
transparent
color="blue"
size="small"
@@ -289,7 +289,9 @@ function resetWizard() {
:header="target.label"
class="wizard-selection-card"
@click="exportData.destination = target.destination">
<div v-sanitize-html:links="renderMarkdown(target.markdownDescription)" />
<div
v-sanitize-html:links="renderMarkdown(target.markdownDescription)"
class="destination-description" />
</BCard>
</BCardGroup>
</div>
@@ -331,6 +333,7 @@ function resetWizard() {
<div v-if="wizard.isCurrent('export-summary')">
<BFormGroup
v-if="needsFileName"
label="File name"
label-for="exported-file-name"
:description="`Give the exported file a name.`"
class="mt-3">
@@ -372,6 +375,10 @@ function resetWizard() {
</template>
<style scoped lang="scss">
.destination-description :deep(a) {
text-decoration: underline;
}
.zenodo-info {
display: flex;
align-items: flex-start;
@@ -47,6 +47,17 @@ describe("ChangePassword", () => {
});
});
it("associates each password field with its visible label", async () => {
await wrapper.setProps({ expiredUser: "expired_user" });
const labels = wrapper.findAll("label");
expect(labels.map((label) => label.text())).toEqual(["Current Password", "New Password", "Confirm password"]);
const ids = labels.map((label) => label.attributes("for"));
expect(new Set(ids).size).toBe(3);
for (const id of ids) {
expect(wrapper.find(`input[id="${id}"]`).attributes("type")).toBe("password");
}
});
it("basics", async () => {
const cardHeader = wrapper.find(".card-header");
expect(cardHeader.text()).toBe("Change your password");
+12 -6
View File
@@ -6,6 +6,7 @@ import { useRouter } from "vue-router";
import { withPrefix } from "@/utils/redirect";
import { errorMessageAsString } from "@/utils/simple-error";
import { uid } from "@/utils/utils";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
@@ -20,6 +21,7 @@ interface Props {
const props = defineProps<Props>();
const router = useRouter();
const fieldId = `change-password-${uid()}`;
const confirm = ref(null);
const current = ref(null);
@@ -52,16 +54,20 @@ async function submit() {
</GAlert>
<BCard header="Change your password">
<BFormGroup v-if="expiredUser" label="Current Password">
<BFormInput v-model="current" type="password" autocomplete="current-password" />
<BFormGroup v-if="expiredUser" label="Current Password" :label-for="`${fieldId}-current`">
<BFormInput
:id="`${fieldId}-current`"
v-model="current"
type="password"
autocomplete="current-password" />
</BFormGroup>
<BFormGroup label="New Password">
<BFormInput v-model="password" type="password" autocomplete="new-password" />
<BFormGroup label="New Password" :label-for="`${fieldId}-new`">
<BFormInput :id="`${fieldId}-new`" v-model="password" type="password" autocomplete="new-password" />
</BFormGroup>
<BFormGroup label="Confirm password">
<BFormInput v-model="confirm" type="password" autocomplete="new-password" />
<BFormGroup label="Confirm password" :label-for="`${fieldId}-confirm`">
<BFormInput :id="`${fieldId}-confirm`" v-model="confirm" type="password" autocomplete="new-password" />
</BFormGroup>
<GButton type="submit">Save new password</GButton>
@@ -11,5 +11,11 @@ const props = defineProps<Props>();
const markdownHtml = computed(() => markup(props.markdown ?? "", props.admin));
</script>
<template>
<div v-sanitize-html:links="markdownHtml" />
<div v-sanitize-html:links="markdownHtml" class="configuration-markdown" />
</template>
<style scoped>
.configuration-markdown :deep(a) {
text-decoration: underline;
}
</style>
@@ -59,6 +59,14 @@ describe("ObjectStoreBadge", () => {
return Array.from(element.querySelectorAll("p")).map((p) => p.textContent);
}
it("exposes the badge meaning and administrator message without relying on hover", async () => {
const wrapper = mountBadge({ type: "more_secure", message: TEST_MESSAGE, source: "admin" }, false);
const graphic = wrapper.get('[role="img"]');
expect(graphic.attributes("aria-label")).toBe(`${MESSAGES.more_secure} ${TEST_MESSAGE}`);
expect(graphic.findAll("svg").every((icon) => icon.attributes("aria-hidden") === "true")).toBe(true);
});
it("should render a valid badge for more_secure type", async () => {
const { shown } = await hoverBadge({ type: "more_secure", message: TEST_MESSAGE, source: "admin" });
expect(shown.textContent).toContain(TEST_MESSAGE);
+33
View File
@@ -0,0 +1,33 @@
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it } from "vitest";
import ProgressBar from "./ProgressBar.vue";
const localVue = getLocalVue();
describe("ProgressBar", () => {
it("distinguishes completed, failed, running and waiting counts for assistive technology", () => {
const wrapper = mount(ProgressBar, {
props: {
note: "2 of 10 jobs complete.",
total: 10,
okCount: 2,
errorCount: 1,
runningCount: 3,
newCount: 4,
},
global: localVue,
});
const bars = wrapper.findAll('[role="progressbar"]');
expect(bars.map((bar) => bar.attributes("aria-label"))).toEqual([
"2 of 10 jobs complete. Completed",
"2 of 10 jobs complete. Failed",
"2 of 10 jobs complete. Running",
"2 of 10 jobs complete. Waiting",
]);
expect(bars.map((bar) => bar.attributes("aria-valuenow"))).toEqual(["2", "1", "3", "4"]);
expect(bars.every((bar) => bar.attributes("aria-valuemax") === "10")).toBe(true);
});
});
+4 -4
View File
@@ -30,10 +30,10 @@ const props = withDefaults(defineProps<Props>(), {
{{ props.note }}<span v-if="props.loading">.<span class="blinking">..</span></span>
</small>
<BProgress :max="props.total">
<BProgressBar variant="success" :value="props.okCount" />
<BProgressBar variant="danger" :value="props.errorCount" />
<BProgressBar variant="warning" :value="props.runningCount" />
<BProgressBar variant="warning" :value="props.newCount" />
<BProgressBar variant="success" :value="props.okCount" :aria-label="`${props.note} Completed`" />
<BProgressBar variant="danger" :value="props.errorCount" :aria-label="`${props.note} Failed`" />
<BProgressBar variant="warning" :value="props.runningCount" :aria-label="`${props.note} Running`" />
<BProgressBar variant="warning" :value="props.newCount" :aria-label="`${props.note} Waiting`" />
</BProgress>
</div>
</template>
@@ -98,6 +98,22 @@ const testLoader = vi.fn(
},
);
describe("ScrollList accessibility", () => {
it("keeps footer messages out of the list and makes scrolling keyboard reachable", () => {
const wrapper = mount(ScrollList as object, {
props: { propItems: TEST_ITEMS.slice(0, 2), propTotalCount: 2, itemKey: (item: TestItem) => item.id },
slots: { item: TEST_ITEM_SLOT },
global: getLocalVue(),
});
expect(wrapper.get(".scroll-list").attributes("tabindex")).toBe("0");
expect(wrapper.get("[role='list']").findAll("[role='listitem']")).toHaveLength(2);
expect(wrapper.get(".list-end").element.closest("[role='list']")).toBeNull();
const top = wrapper.get("button[aria-label='Scroll to top']");
expect(top.attributes("tabindex")).toBe("-1");
expect(top.attributes("aria-disabled")).toBe("true");
});
});
describe("ScrollList with local loader and data", () => {
let wrapper: ScrollListWrapper;
+18 -13
View File
@@ -231,7 +231,9 @@ watch(
'in-panel': inPanel,
toolMenuContainer: inPanel,
}"
role="list">
role="region"
:aria-label="props.namePlural"
tabindex="0">
<template v-if="items.length === 0">
<GAlert v-if="errorMessage" variant="danger">
<p>{{ errorMessage }}</p>
@@ -246,28 +248,30 @@ watch(
<component
:is="props.gridView ? 'div' : BListGroup"
class="pt-1"
role="list"
:aria-label="props.namePlural"
:class="{ 'card-list d-flex flex-wrap': props.gridView }">
<!-- Use component wrapper with v-for to provide proper keying while avoiding layout interference -->
<component
:is="'div'"
v-for="(item, index) in items"
:key="itemKey(item as T)"
role="listitem"
style="display: contents">
<slot name="item" :item="item" :index="index" />
</component>
<template v-if="!busy">
<slot v-if="allLoaded && items.length === 0" name="none-loaded-footer">
<div class="list-end" data-description="no items found footer">
- No {{ props.namePlural }} found -
</div>
</slot>
<slot v-else-if="allLoaded" name="all-loaded-footer">
<div class="list-end">- {{ listEndText }} -</div>
</slot>
</template>
</component>
<template v-if="!busy">
<slot v-if="allLoaded && items.length === 0" name="none-loaded-footer">
<div class="list-end" data-description="no items found footer">
- No {{ props.namePlural }} found -
</div>
</slot>
<slot v-else-if="allLoaded" name="all-loaded-footer">
<div class="list-end">- {{ listEndText }} -</div>
</slot>
</template>
</div>
<ScrollToTopButton :offset="scrollTop" @click="scrollToTop" />
</div>
@@ -288,6 +292,7 @@ watch(
tooltip
:disabled="busy"
title="Load More"
aria-label="Load More"
transparent
@click="loadItems(true)">
<FontAwesomeIcon :icon="busy ? faSpinner : faArrowDown" :spin="busy" />
@@ -37,9 +37,11 @@ function reset() {
<template>
<BInputGroup class="w-100">
<BFormInput v-model="filter" :placeholder="placeholder" debounce="500" />
<BFormInput v-model="filter" :placeholder="placeholder" :aria-label="placeholder" debounce="500" />
<BInputGroupAppend>
<GButton :disabled="!filter" @click="reset"><FontAwesomeIcon :icon="faTimes" /></GButton>
<GButton aria-label="Clear search" :disabled="!filter" @click="reset"
><FontAwesomeIcon :icon="faTimes"
/></GButton>
</BInputGroupAppend>
</BInputGroup>
</template>
@@ -34,6 +34,22 @@ describe("SelectionDialog.vue", () => {
expect(wrapper.findComponent(GTable).exists()).toBeTruthy();
});
it("opens a folder without also selecting its row", async () => {
const folder = { id: "folder", label: "Results", url: "gxfiles://results", isLeaf: false };
await wrapper.setProps({ optionsShow: true, selectable: true, items: [folder] });
const open = wrapper.get("button.selection-dialog-folder");
expect(open.text()).toContain("Results");
expect(open.find("a").exists()).toBe(false);
await open.trigger("click");
expect(wrapper.emitted("onOpen")).toEqual([[folder]]);
expect(wrapper.emitted("onClick")).toBeUndefined();
});
it("names the search and its clear button", () => {
expect(wrapper.get("input[type='text']").attributes("aria-label")).toContain("search");
expect(wrapper.get("button[aria-label='Clear search']").exists()).toBe(true);
});
it("loads header correctly", async () => {
await nextTick();
expect(wrapper.findComponent(DataDialogSearch).exists()).toBeTruthy();
@@ -2,7 +2,7 @@
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faCaretLeft, faCheck, faFolder, faSpinner, faTimes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { BLink, BPagination } from "bootstrap-vue";
import { BPagination } from "bootstrap-vue";
import { computed, ref, watch } from "vue";
import type { RowClickEvent, RowSelectEvent, TableField } from "@/components/Common/GTable.types";
@@ -337,16 +337,15 @@ defineExpose({
<i :class="leafIcon" />
<span :title="`label-${data.item.url}`">{{ data.value ? data.value : "-" }}</span>
</div>
<div
<button
v-else
role="button"
tabindex="0"
@click.stop="onOpen(data.item)"
@keydown.enter.stop="onOpen(data.item)"
@keydown.space.stop.prevent="onOpen(data.item)">
type="button"
class="selection-dialog-folder"
:title="`label-${data.item.url}`"
@click.stop="onOpen(data.item)">
<FontAwesomeIcon :icon="props.folderIcon" />
<BLink :title="`label-${data.item.url}`">{{ data.value ? data.value : "-" }}</BLink>
</div>
{{ data.value ? data.value : "-" }}
</button>
</span>
</div>
</template>
@@ -424,3 +423,14 @@ defineExpose({
</template>
</GModal>
</template>
<style scoped lang="scss">
.selection-dialog-folder {
background: transparent;
border: 0;
padding: 0;
min-height: 24px;
text-align: left;
color: var(--color-blue-700);
}
</style>
@@ -16,9 +16,9 @@
@dragleave.prevent="onDragLeave"
@dragover.prevent
@drop.prevent="onDrop">
<label class="form-label font-weight-bold mb-2" for="multiselect">{{ title }}:</label>
<label class="form-label font-weight-bold mb-2" :for="selectId">{{ title }}:</label>
<Multiselect
id="multiselect"
:id="selectId"
v-model="currentValue"
name="multiselect"
deselect-label=""
@@ -46,12 +46,14 @@ import {
import { type EventData, useEventStore } from "@/stores/eventStore";
import { useHistoryItemsStore } from "@/stores/historyItemsStore";
import { useHistoryStore } from "@/stores/historyStore";
import { uid } from "@/utils/utils";
import type { OptionType } from "./types";
import GAlert from "@/components/BaseComponents/GAlert.vue";
import LoadingSpan from "@/components/LoadingSpan.vue";
const selectId = `selection-field-${uid()}`;
const DEFAULT_NAME = "...";
const DELAY = 300;
@@ -14,7 +14,6 @@ import { loadWebhooks } from "@/utils/webhooks";
import { copyId, copyLink, downloadTool, openLink } from "../utilities";
import ToolTourGeneratorItem from "./ToolTourGeneratorItem.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import GDropdown from "@/components/BaseComponents/GDropdown.vue";
import GDropdownItem from "@/components/BaseComponents/GDropdownItem.vue";
import GModal from "@/components/BaseComponents/GModal.vue";
@@ -88,12 +87,10 @@ loadToolMenuWebhooks();
variant="link"
aria-label="View all Options"
class="tool-dropdown"
toggle-class="p-0"
toggle-class="px-2 py-1"
size="sm">
<template v-slot:button-content>
<GButton class="d-block" color="blue" transparent size="small" tooltip title="Options">
<FontAwesomeIcon :icon="faCaretDown" />
</GButton>
<FontAwesomeIcon :icon="faCaretDown" />
</template>
<GDropdownItem @click="onCopyLink">
@@ -128,3 +125,10 @@ loadToolMenuWebhooks();
</GModal>
</div>
</template>
<style scoped>
:deep(.dropdown-toggle) {
min-width: 24px;
min-height: 24px;
}
</style>
@@ -3,7 +3,6 @@ import { faCheck, faCube, faCubes } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { computed } from "vue";
import GButton from "@/components/BaseComponents/GButton.vue";
import GDropdown from "@/components/BaseComponents/GDropdown.vue";
import GDropdownItem from "@/components/BaseComponents/GDropdownItem.vue";
@@ -26,12 +25,10 @@ const availableVersions = computed(() => [...props.versions].reverse());
variant="link"
aria-label="Select Versions"
class="tool-versions"
toggle-class="p-0"
toggle-class="px-2 py-1"
size="sm">
<template v-slot:button-content>
<GButton class="d-block" color="blue" transparent size="small" tooltip title="Versions">
<FontAwesomeIcon :icon="faCubes" />
</GButton>
<FontAwesomeIcon :icon="faCubes" />
</template>
<GDropdownItem
v-for="v of availableVersions"
@@ -45,3 +42,10 @@ const availableVersions = computed(() => [...props.versions].reverse());
</GDropdownItem>
</GDropdown>
</template>
<style scoped>
:deep(.dropdown-toggle) {
min-width: 24px;
min-height: 24px;
}
</style>
@@ -124,6 +124,22 @@ describe("ToolCard", () => {
expect(router.currentRoute.value.fullPath).toBe("/?tool_id=identifier&version=latest");
});
it("offers a single focusable toggle for each tool menu", async () => {
await wrapper.setProps({
version: "1.0",
options: { ...wrapper.props("options"), versions: ["1.0", "2.0"] },
});
for (const menu of [".tool-dropdown", ".tool-versions"]) {
const toggle = wrapper.get(`${menu} button.dropdown-toggle`);
expect(toggle.find("button, a, [tabindex]").exists()).toBe(false);
expect(toggle.attributes("aria-label")).toBeTruthy();
await toggle.trigger("keydown", { key: "ArrowDown" });
await flushPromises();
expect(toggle.attributes("aria-expanded")).toBe("true");
await toggle.trigger("keydown", { key: "Escape" });
}
});
it("does not show newer version badge for the latest lineage version", async () => {
await wrapper.setProps({
version: "2.0",
@@ -15,7 +15,10 @@ const props = defineProps<Props>();
v-g-tooltip.hover
class="back-to-top"
:class="{ show: props.offset > 100 }"
:title="props.offset > 100 ? 'Scroll To Top' : ''"
title="Scroll To Top"
aria-label="Scroll to top"
:disabled="props.offset <= 100"
:tabindex="props.offset <= 100 ? -1 : 0"
color="blue"
@click="$emit('click')">
<FontAwesomeIcon :icon="faChevronUp" />
@@ -52,6 +52,10 @@ describe("InstallationSettings", () => {
expect(wrapper.find(".description").text()).toBe("long_description");
expect(wrapper.find(".revision").text()).toBe("owner rev. changesetRevision");
const sectionInput = wrapper.get("#repository-target-section");
expect(wrapper.get('label[for="repository-target-section"]').text()).toBe("Target Section:");
expect(sectionInput.attributes("list")).toBe("sectionSelect");
expect(wrapper.vm.installToolDependencies).toBe(true);
expect(wrapper.vm.installRepositoryDependencies).toBe(true);
expect(wrapper.vm.installResolverDependencies).toBe(true);
@@ -14,8 +14,9 @@
<BFormGroup
v-if="requiresPanel"
label="Target Section:"
label-for="repository-target-section"
description="Choose an existing tool panel section or create a new section to contain the installed tools (optional).">
<BFormInput v-model="toolSection" list="sectionSelect" />
<BFormInput id="repository-target-section" v-model="toolSection" list="sectionSelect" />
<datalist id="sectionSelect">
<option v-for="section in toolSections" :key="section.id">
{{ section.name }}
@@ -28,6 +28,20 @@ function mountQuotaUsageBarWith(quotaUsage: QuotaUsage) {
}
describe("QuotaUsageBar.vue", () => {
it("names each progress bar by its storage source and retains its quota value", () => {
const wrapper = mountQuotaUsageBarWith(NON_DEFAULT_QUOTA_USAGE);
const progress = wrapper.get('[role="progressbar"]');
expect(progress.attributes("aria-label")).toBe("The label disk quota used");
expect(progress.attributes("aria-valuenow")).toBe("20");
expect(progress.attributes("aria-valuemax")).toBe("100");
});
it("names the usage bar for an unlimited storage source", () => {
const wrapper = mountQuotaUsageBarWith(UNLIMITED_USAGE);
expect(wrapper.get('[role="progressbar"]').attributes("aria-label")).toBe("Unlimited disk quota used");
});
it("should display the quota source label when the quota source is not the default one", async () => {
const wrapper = mountQuotaUsageBarWith(NON_DEFAULT_QUOTA_USAGE);
const storageSourceLabel = wrapper.find(".storage-source-label");
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { BProgress } from "bootstrap-vue";
import { BProgress, BProgressBar } from "bootstrap-vue";
import { computed, ref } from "vue";
import localize from "@/utils/localization";
@@ -77,10 +77,11 @@ defineExpose({
<span v-if="quotaHasLimit && !compact" class="quota-percent-text" :data-quota-percent="quotaUsage.quotaPercent">
{{ quotaUsage.quotaPercent }}{{ percentOfDiskQuotaUsedText }}
</span>
<BProgress
v-if="quotaHasLimit || !(embedded || compact)"
:value="quotaUsage.quotaPercent"
:variant="progressVariant"
max="100" />
<BProgress v-if="quotaHasLimit || !(embedded || compact)" max="100">
<BProgressBar
:value="quotaUsage.quotaPercent"
:variant="progressVariant"
:aria-label="`${quotaUsage.sourceLabel} disk quota used`" />
</BProgress>
</div>
</template>
@@ -155,7 +155,7 @@ vi.mock("@/stores/workflowStore", async () => {
* @param fullPage Whether to render the header as well or just the invocation state tabs
* @returns The mounted wrapper
*/
async function mountWorkflowInvocationState(invocationId: string, isFullPage = false) {
async function mountWorkflowInvocationState(invocationId: string, isFullPage = false, renderNavigation = false) {
const pinia = createTestingPinia({ createSpy: vi.fn });
setActivePinia(pinia);
@@ -165,7 +165,10 @@ async function mountWorkflowInvocationState(invocationId: string, isFullPage = f
isFullPage,
},
pinia,
global: localVue,
global: {
...localVue,
stubs: renderNavigation ? { BNav: false, BNavItem: false } : {},
},
});
await flushPromises();
return wrapper;
@@ -244,6 +247,13 @@ describe("WorkflowInvocationState check invocation and job terminal states", ()
});
describe("WorkflowInvocationState check 'Report' and 'Export' tab disabled state and header", () => {
it("keeps invocation actions inside a list item alongside navigation tabs", async () => {
const wrapper = await mountWorkflowInvocationState(invocationData.id, true, true);
const navigation = wrapper.get("ul.nav");
expect(Array.from(navigation.element.children).every((child) => child.tagName === "LI")).toBe(true);
expect(navigation.get("li.ml-auto").element.tagName).toBe("LI");
});
it("for non-terminal invocation", async () => {
const wrapper = await mountWorkflowInvocationState("non-terminal-id", true);
const reportTab = wrapper.find(selectors.invocationReportTab);
@@ -438,7 +438,7 @@ async function onCancel() {
Debug
</BNavItem>
<div class="ml-auto d-flex align-items-center flex-gapx-1">
<li class="ml-auto d-flex align-items-center flex-gapx-1">
<WorkflowInvocationSearch
v-if="!props.tab"
:invocation-id="props.invocationId"
@@ -460,7 +460,7 @@ async function onCancel() {
<FontAwesomeIcon :icon="faTimes" fixed-width />
Cancel Workflow
</GButton>
</div>
</li>
</BNav>
<div class="mt-1 d-flex flex-column overflow-auto tab-content-container">
+21 -1
View File
@@ -109,7 +109,7 @@ async function mountTarget(quota?: ReturnType<typeof quotaDetails>) {
}
async function choose(wrapper: VueWrapper, selectionId: string, value: string) {
const selection = wrapper.findAllComponents(FormSelection).find((w) => w.attributes("id") === selectionId);
const selection = wrapper.findAllComponents(FormSelection).find((w) => w.props("id") === selectionId);
selection!.vm.$emit("input", value);
await flushPromises();
}
@@ -241,6 +241,26 @@ describe("QuotaForm.vue create mode", () => {
await wrapper.find("#admin-quota-amount").setValue("10 GB");
}
it("associates the visible field labels with editable controls", async () => {
useGroups();
const wrapper = await mountTarget();
const ids = [
"admin-quota-name",
"admin-quota-description",
"admin-quota-amount",
"admin-quota-operation",
"admin-quota-default",
"admin-quota-groups-select",
"admin-quota-users-select",
];
for (const id of ids) {
const label = wrapper.find(`label[for="${id}"]`);
expect(label.exists(), `Label for ${id}`).toBe(true);
expect(label.text().length).toBeGreaterThan(0);
expect(wrapper.find(`input[id="${id}"]`).exists(), `Input for ${id}`).toBe(true);
}
});
it("creates a quota for a labeled object store", async () => {
setMockConfig({ quota_source_labels: ["mylabel"] });
const requests = captureRequests();
+27 -7
View File
@@ -205,26 +205,43 @@ loadData();
<template v-if="!loadFailed">
<FormCard :title="isEditMode ? `Quota '${savedQuotaName}'` : 'Create Quota'" icon="fa-database">
<template v-slot:body>
<FormElementLabel title="Name" :required="true" :condition="!!quotaName">
<FormElementLabel
title="Name"
label-for="admin-quota-name"
:required="true"
:condition="!!quotaName">
<GFormInput id="admin-quota-name" v-model="quotaName" />
</FormElementLabel>
<FormElementLabel title="Description" :required="!isEditMode" :condition="!!description">
<FormElementLabel
title="Description"
label-for="admin-quota-description"
:required="!isEditMode"
:condition="!!description">
<GFormInput id="admin-quota-description" v-model="description" />
</FormElementLabel>
<FormElementLabel title="Amount" :required="true" :condition="!!amount" :help="AMOUNT_HELP">
<FormElementLabel
title="Amount"
label-for="admin-quota-amount"
:required="true"
:condition="!!amount"
:help="AMOUNT_HELP">
<GFormInput id="admin-quota-amount" v-model="amount" />
</FormElementLabel>
<FormElementLabel title="Assign, increase by amount, or decrease by amount?">
<FormElementLabel
title="Assign, increase by amount, or decrease by amount?"
label-for="admin-quota-operation">
<FormSelection id="admin-quota-operation" v-model="operation" :data="operationOptions" />
</FormElementLabel>
<FormElementLabel
title="Is this quota a default for a class of users (if yes, what type)?"
label-for="admin-quota-default"
help="Default quotas cannot be associated with specific users and groups.">
<FormSelection id="admin-quota-default" v-model="defaultType" :data="defaultOptions" />
</FormElementLabel>
<FormElementLabel
v-if="!isEditMode && quotaSourceOptions.length > 1"
title="Apply quota to labeled object stores.">
title="Apply quota to labeled object stores."
label-for="admin-quota-source-label">
<FormSelection
id="admin-quota-source-label"
v-model="quotaSourceLabel"
@@ -232,7 +249,10 @@ loadData();
</FormElementLabel>
<template v-if="defaultType === 'no'">
<FormElementLabel id="admin-quota-groups" title="Groups">
<FormElementLabel
id="admin-quota-groups"
title="Groups"
label-for="admin-quota-groups-select">
<Multiselect
id="admin-quota-groups-select"
v-model="selectedGroups"
@@ -245,7 +265,7 @@ loadData();
placeholder="Select groups..." />
</FormElementLabel>
<FormElementLabel id="admin-quota-users" title="Users">
<FormElementLabel id="admin-quota-users" title="Users" label-for="admin-quota-users-select">
<Multiselect
id="admin-quota-users-select"
v-model="selectedUsers"
+13 -1
View File
@@ -84,6 +84,18 @@ beforeEach(() => {
});
describe("RoleForm.vue create mode", () => {
it("associates the visible field labels with editable controls", async () => {
useGroups();
const wrapper = await mountTarget();
const ids = ["role-name", "role-description", "role-type", "role-groups-select", "role-users-select"];
for (const id of ids) {
const label = wrapper.find(`label[for="${id}"]`);
expect(label.exists(), `Label for ${id}`).toBe(true);
expect(label.text().length).toBeGreaterThan(0);
expect(wrapper.find(`input[id="${id}"]`).exists(), `Input for ${id}`).toBe(true);
}
});
it("shows a loading spinner until the groups are loaded", async () => {
useGroups();
const wrapper = mount(RoleForm as object, { localVue, stubs: { FontAwesomeIcon: true } });
@@ -119,7 +131,7 @@ describe("RoleForm.vue create mode", () => {
const wrapper = await mountTarget();
await wrapper.find("#role-name").setValue("Test Role");
await wrapper.find("#role-description").setValue("Test Description");
const roleType = wrapper.findAllComponents(FormSelection).find((w) => w.attributes("id") === "role-type");
const roleType = wrapper.findAllComponents(FormSelection).find((w) => w.props("id") === "role-type");
roleType!.vm.$emit("input", "user_tool_execute");
multiselect(wrapper, "role-groups").vm.$emit("update:modelValue", [{ id: "g1", name: "Group 1" }]);
multiselect(wrapper, "role-users").vm.$emit("update:modelValue", [{ id: "u1", email: "user1@example.org" }]);
+9 -5
View File
@@ -163,16 +163,20 @@ fetchData();
<template v-if="!loadFailed">
<FormCard :title="isEditMode ? `Role '${savedName}'` : 'Create a new Role'" icon="fa-file-contract">
<template v-slot:body>
<FormElementLabel title="Name" :required="true" :condition="!!name">
<FormElementLabel title="Name" label-for="role-name" :required="true" :condition="!!name">
<FormInput id="role-name" v-model="name" />
</FormElementLabel>
<FormElementLabel title="Description" :required="!isEditMode" :condition="!!description">
<FormElementLabel
title="Description"
label-for="role-description"
:required="!isEditMode"
:condition="!!description">
<FormInput id="role-description" v-model="description" />
</FormElementLabel>
<FormElementLabel v-if="!isEditMode" title="Role Type" :required="true">
<FormElementLabel v-if="!isEditMode" title="Role Type" label-for="role-type" :required="true">
<FormSelection id="role-type" v-model="roleType" :data="roleTypes" />
</FormElementLabel>
<FormElementLabel id="role-groups" title="Groups">
<FormElementLabel id="role-groups" title="Groups" label-for="role-groups-select">
<Multiselect
id="role-groups-select"
v-model="selectedGroups"
@@ -184,7 +188,7 @@ fetchData();
track-by="id"
placeholder="Select groups..." />
</FormElementLabel>
<FormElementLabel id="role-users" title="Users">
<FormElementLabel id="role-users" title="Users" label-for="role-users-select">
<Multiselect
id="role-users-select"
v-model="selectedUsers"
-18
View File
@@ -1,18 +0,0 @@
import { computed, ref } from "vue";
export function useMultiselect() {
const editing = ref(false);
const ariaExpanded = computed(() => {
return editing.value ? "true" : "false";
});
function onOpen() {
editing.value = true;
}
function onClose() {
editing.value = false;
}
return { editing, ariaExpanded, onOpen, onClose };
}
+36
View File
@@ -162,6 +162,42 @@ describe("vGTooltip", () => {
vGTooltip.unmounted?.(element, bindingForCleanup(), unusedVNode, null);
});
test("describes visible text without replacing its accessible name", async () => {
const wrapper = mount(
{
directives: { "g-tooltip": vGTooltip },
data: () => ({ text: "Run tool" }),
template: `<button v-g-tooltip title="Execute the analysis">{{ text }}</button>`,
} as object,
{ attachTo: document.body },
);
expect(wrapper.attributes("aria-label")).toBeUndefined();
await wrapper.setData({ text: "" });
expect(wrapper.attributes("aria-label")).toBe("Execute the analysis");
await wrapper.setData({ text: "Run again" });
expect(wrapper.attributes("aria-label")).toBeUndefined();
wrapper.unmount();
});
test("preserves explicit names and does not name generic tooltip containers", () => {
const wrapper = mount(
{
directives: { "g-tooltip": vGTooltip },
template: `<div>
<button v-g-tooltip title="More detail" aria-label="Run" />
<button v-g-tooltip title="More detail" aria-labelledby="run-label" />
<span id="run-label">Run</span>
<span v-g-tooltip title="Badge information"><svg /></span>
</div>`,
} as object,
{ attachTo: document.body },
);
expect(wrapper.get("button[aria-label]").attributes("aria-label")).toBe("Run");
expect(wrapper.get("button[aria-labelledby]").attributes("aria-label")).toBeUndefined();
expect(wrapper.get("span[title]").attributes("aria-label")).toBeUndefined();
wrapper.unmount();
});
describe("with a controlled show value", () => {
function mountControlled(show: boolean) {
return mount(
+14 -1
View File
@@ -59,13 +59,20 @@
}
}
.multiselect__placeholder,
.multiselect__input::placeholder {
color: $text-color;
opacity: 1;
}
.multiselect__placeholder {
padding-top: 0px;
margin-bottom: 7px;
}
.multiselect__tag {
min-height: unset;
min-height: 24px;
padding-right: 28px;
white-space: normal;
word-break: break-all;
background: $brand-primary;
@@ -73,6 +80,12 @@
margin-bottom: 0.1rem;
.multiselect__tag-icon {
min-width: 24px;
min-height: 24px;
padding: 0;
border: 0;
background: transparent;
font: inherit;
color: $white;
&:hover,
+17 -16
View File
@@ -330,7 +330,7 @@ history_panel:
selector: 'name input'
type: data-description
history_name_edit_label: '[data-description="name display"]'
history_name_edit_input: '[data-description="name display"] #click-to-edit-input'
history_name_edit_input: '[data-description="name display"] input'
contents: '.history-index .content-item'
empty_message: '#empty-history-message'
size: '.history-size'
@@ -400,8 +400,8 @@ edit_dataset_attributes:
info_input: '[aria-labelledby="dataset-attributes-heading"] textarea#info'
annotation_input: '[aria-labelledby="dataset-attributes-heading"] textarea#annotation'
datatypes_tab: '.dataset-edit-datatype-tab'
datatype_dropdown: ' #datatype .multiselect'
dbkey_dropdown: '#dbkey .multiselect'
datatype_dropdown: '#datatype-container .multiselect'
dbkey_dropdown: '#dbkey-container .multiselect'
save_button: '#dataset-attributes-default-save'
auto_detect_datatype_button: '#dataset-attributes-autodetect-datatype'
alert: '.dataset-attributes-alert'
@@ -574,9 +574,9 @@ histories:
files_dialog:
selectors:
ftp_label: 'a[title="label-gxftp://"]'
ftp_label: 'button[title="label-gxftp://"]'
ftp_details: 'span[title="details-gxftp://"]'
row: 'span[title="label-${uri}"]'
row: '[title="label-${uri}"]'
back_btn: '#back-btn'
options_ready: '[data-description="selection dialog options"] .selection-dialog-table[aria-busy="false"]'
ok: '[data-description="selection dialog ok"]'
@@ -831,7 +831,7 @@ tool_form:
parameter_color_input: 'div.ui-form-element[id="form-element-${parameter}"] input[type="color"]'
parameter_text_input: 'div.ui-form-element[id="form-element-${parameter}"] input'
parameter_form_selection: 'div.ui-form-element[id="form-element-${parameter}"] .form-selection'
parameter_multiselect_tag_close: 'div.ui-form-element[id="form-element-${parameter}"] i.multiselect__tag-icon'
parameter_multiselect_tag_close: 'div.ui-form-element[id="form-element-${parameter}"] button.multiselect__tag-icon'
parameter_drilldown_option: 'div.ui-form-element[id="form-element-${parameter}"] #drilldown-option-${value}'
parameter_value_list_input:
type: xpath
@@ -1185,11 +1185,11 @@ workflow_editor:
type: xpath
selector: >
//div[@data-label='Rename dataset']//input
change_datatype: "#pja__${output}__ChangeDatatypeAction__newtype [role='combobox']"
select_datatype_text_search: "#pja__${output}__ChangeDatatypeAction__newtype [role='combobox'] input"
select_datatype: "#pja__${output}__ChangeDatatypeAction__newtype [data-option-value='${datatype}']"
select_datatype_option_not_matching: "#pja__${output}__ChangeDatatypeAction__newtype [data-option-value]:not([data-option-value*='${text}' i])"
selected_datatype: "#pja__${output}__ChangeDatatypeAction__newtype [data-selected-value]"
change_datatype: "#pja__${output}__ChangeDatatypeAction__newtype-container .multiselect__select"
select_datatype_text_search: "#pja__${output}__ChangeDatatypeAction__newtype[role='combobox']"
select_datatype: "#pja__${output}__ChangeDatatypeAction__newtype-container [data-option-value='${datatype}']"
select_datatype_option_not_matching: "#pja__${output}__ChangeDatatypeAction__newtype-container [data-option-value]:not([data-option-value*='${text}' i])"
selected_datatype: "#pja__${output}__ChangeDatatypeAction__newtype-container [data-selected-value]"
add_tags_button:
type: xpath
selector: >
@@ -1429,7 +1429,7 @@ admin:
selector: "//div[@id='admin-quota-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]"
remove_selected_user:
type: xpath
selector: "//div[@id='admin-quota-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]/i[contains(@class, 'multiselect__tag-icon')]"
selector: "//div[@id='admin-quota-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]/button[contains(@class, 'multiselect__tag-icon')]"
submit: '#admin-quota-submit'
role:
@@ -1445,7 +1445,7 @@ admin:
selector: "//div[@id='role-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]"
remove_selected_user:
type: xpath
selector: "//div[@id='role-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]/i[contains(@class, 'multiselect__tag-icon')]"
selector: "//div[@id='role-users']//span[contains(@class, 'multiselect__tag')][span[text()='${email}']]/button[contains(@class, 'multiselect__tag-icon')]"
groups: '#role-groups .multiselect'
groups_input: '#role-groups input.multiselect__input'
group_option:
@@ -1456,7 +1456,7 @@ admin:
selector: "//div[@id='role-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]"
remove_selected_group:
type: xpath
selector: "//div[@id='role-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/i[contains(@class, 'multiselect__tag-icon')]"
selector: "//div[@id='role-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/button[contains(@class, 'multiselect__tag-icon')]"
submit: '#role-submit'
user_roles_groups:
@@ -1472,7 +1472,7 @@ admin:
selector: "//div[@id='admin-user-roles']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]"
remove_selected_role:
type: xpath
selector: "//div[@id='admin-user-roles']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/i[contains(@class, 'multiselect__tag-icon')]"
selector: "//div[@id='admin-user-roles']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/button[contains(@class, 'multiselect__tag-icon')]"
groups: '#admin-user-groups .multiselect'
groups_input: '#admin-user-groups input.multiselect__input'
group_option:
@@ -1483,7 +1483,7 @@ admin:
selector: "//div[@id='admin-user-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]"
remove_selected_group:
type: xpath
selector: "//div[@id='admin-user-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/i[contains(@class, 'multiselect__tag-icon')]"
selector: "//div[@id='admin-user-groups']//span[contains(@class, 'multiselect__tag')][span[text()='${name}']]/button[contains(@class, 'multiselect__tag-icon')]"
submit: '#admin-user-roles-groups-submit'
index:
@@ -1569,6 +1569,7 @@ libraries:
import_datasets_from_history_modal_list_is_ready: '.selection-dialog-modal [data-description="selection dialog busy spinners"]'
import_datasets_from_history_modal_select_list_items: '.selection-dialog-modal table tbody tr'
import_datasets_from_history_modal_select_list_item_by_index: '.selection-dialog-modal table tbody tr[aria-rowindex="${row_index}"] td[id^="g-table-cell-label-"]'
import_datasets_from_history_modal_open_history_by_index: '.selection-dialog-modal table tbody tr[aria-rowindex="${row_index}"] button.selection-dialog-folder'
import_datasets_from_history_modal_ok: '.selection-dialog-modal [data-description="selection dialog ok"]'
import_datasets_from_history_modal_cancel: '.selection-dialog-modal [data-description="selection dialog cancel"]'
add_to_history_as_collection:
+5 -1
View File
@@ -3021,11 +3021,15 @@ class NavigatesGalaxy(HasDriverProxy[WaitType]):
container_elem = self.wait_for_selector(container_selector_or_elem)
else:
container_elem = container_selector_or_elem
if "multiselect__input" in (container_elem.get_attribute("class") or "").split():
container_elem = container_elem.find_element(
By.XPATH, "ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' multiselect ')][1]"
)
trigger_elem = container_elem.find_element(By.CSS_SELECTOR, ".multiselect__select")
self.scroll_into_view(trigger_elem)
trigger_elem.click()
try:
text_input = container_elem.find_element(By.CSS_SELECTOR, "input[class='multiselect__input']")
text_input = container_elem.find_element(By.CSS_SELECTOR, "input.multiselect__input")
except Exception:
text_input = None
if text_input:
@@ -187,6 +187,6 @@ class TestLibraryContents(SeleniumTestCase, UsesLibraryAssertions, UsesUploadAct
def _search_and_select_history(self, row_index=1, label_text="Unnamed history"):
self.libraries_dataset_import_from_history_search_for(label_text)
self.sleep_for(self.wait_types.UX_RENDER)
self.components.libraries.folder.import_datasets_from_history_modal_select_list_item_by_index(
self.components.libraries.folder.import_datasets_from_history_modal_open_history_by_index(
row_index=row_index
).wait_for_and_click()
@@ -1604,17 +1604,7 @@ steps:
self.assert_workflow_has_changes_and_save()
def switch_param_type(self, element, param_type):
if self.backend_type == "playwright":
pw_driver = cast("HasPlaywrightDriver", self._driver_impl)
element.click()
self.sleep_for(self.wait_types.UX_RENDER)
pw_driver.page.keyboard.type(param_type)
self.sleep_for(self.wait_types.UX_RENDER)
pw_driver.page.keyboard.press("Enter")
else:
self.action_chains().move_to_element(element).click().pause(1).send_keys(param_type).pause(1).send_keys(
Keys.ENTER
).perform()
self.select_set_value(element, param_type)
@selenium_test
def test_editor_invalid_tool_state(self):