mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-10-16 07:19:13 +08:00
Merge pull request #24046 from jmchilton/fix_story_a11y
[26.2] Repair accessibility issues across shared controls and forms
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
-3
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" }]);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user