9a301665a4
* resolve TagAddButton a11y better comments to document tagindex reasoning * resolved a11y for TagsSelectedButton allow focus to TagsSelectedButton with Shift+Tab and Enter or Space to show popover * safely ignore a11y warning as container for interactables is not itself interactable * Update CONTRIBUTORS * quick fix syntax * quick fix syntax * quick fix syntax * quick fix syntax * resolved a11y in accordance with ARIA APG Disclure pattern * resolved a11y ideally should replace with with a11y-click-events-have-key-events is explicitly ignored as the alternative (adding ) seems more clunky * resolved SpinBox a11y cannot focus on these buttons, so no key event handling needed (keyboard editting already possible by just typing in the field) widget already properly follows ARIA APG Spinbutton pattern * cleanup * onEnterOrSpace() function implemented as discussed in #2787 and #2564 * I think this is the main keyboard handling of Select Still need to fix focus and handle roles and attributes * fixed the keyboard interaction focus is janky because you need to wait until after the listed options load and for some reason that needs a tiny delay on onMount I think this technically violates a11y, but it really doesn't since the delay is literally zero. But the code still needs it to happen. * Select and SelectOption reference the same focus function * SelectOption moved inside Select + started roles and a11y * quick syntax and such changes * finish handling roles and attributes * fixed keyboard handling and only visual focus * cleanup and slight refactoring * fixed syntax * what even is this? * bug fixes + revert key selection * fixed scrolling * better control scrolling and focus * Adjusted selection Up/Down Arrows: start selection on active option Enter/Space/Click: no initial selection, down arrow to first option, up arrow to last option * Only set selected the first time Select is opened, all subsequent times use the previous selected
298 lines
8.2 KiB
Svelte
298 lines
8.2 KiB
Svelte
<!--
|
|
Copyright: Ankitects Pty Ltd and contributors
|
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
-->
|
|
<script lang="ts">
|
|
import { altPressed, isArrowDown, isArrowUp } from "@tslib/keys";
|
|
import { createEventDispatcher, setContext } from "svelte";
|
|
import { writable } from "svelte/store";
|
|
|
|
import { selectKey } from "./context-keys";
|
|
import IconConstrain from "./IconConstrain.svelte";
|
|
import { chevronDown } from "./icons";
|
|
import Popover from "./Popover.svelte";
|
|
import SelectOption from "./SelectOption.svelte";
|
|
import WithFloating from "./WithFloating.svelte";
|
|
|
|
// eslint-disable
|
|
type T = $$Generic;
|
|
|
|
let className = "";
|
|
export { className as class };
|
|
|
|
export let disabled = false;
|
|
export let label = "<br>";
|
|
export let value: T;
|
|
|
|
// E may need to derive content, but we default to them being the same for convenience of usage
|
|
type E = $$Generic;
|
|
type C = $$Generic;
|
|
let selected: number | undefined = undefined;
|
|
let initialSelected: number;
|
|
export let list: E[];
|
|
export let parser: (item: E) => { content: C; value?: T; disabled?: boolean } = (
|
|
item,
|
|
) => {
|
|
return {
|
|
content: item as unknown as C,
|
|
};
|
|
};
|
|
const parsed = list
|
|
.map(parser)
|
|
.map(({ content, value: initialValue, disabled = false }, i) => {
|
|
if ((initialValue === undefined && i === value) || initialValue === value) {
|
|
initialSelected = i;
|
|
}
|
|
|
|
return {
|
|
content,
|
|
parsedValue: initialValue === undefined ? (i as T) : initialValue,
|
|
disabled,
|
|
};
|
|
});
|
|
const buttons: HTMLButtonElement[] = Array(list.length);
|
|
const last = list.length - 1;
|
|
const ids = {
|
|
popover: "popover",
|
|
focused: "focused",
|
|
};
|
|
|
|
export let id: string | undefined = undefined;
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
function setValue(v: T) {
|
|
value = v;
|
|
dispatch("change", { value });
|
|
}
|
|
|
|
export let element: HTMLElement | undefined = undefined;
|
|
|
|
export let tooltip: string | undefined = undefined;
|
|
|
|
const rtl: boolean = window.getComputedStyle(document.body).direction == "rtl";
|
|
let hover = false;
|
|
|
|
let showFloating = false;
|
|
let clientWidth: number;
|
|
|
|
const selectStore = writable({ value, setValue });
|
|
$: $selectStore.value = value;
|
|
setContext(selectKey, selectStore);
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
// In accordance with ARIA APG combobox (https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
|
|
const arrowDown = isArrowDown(event);
|
|
const arrowUp = isArrowUp(event);
|
|
const alt = altPressed(event);
|
|
if (arrowDown || arrowUp || event.code === "Space") {
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (
|
|
!showFloating &&
|
|
((arrowDown && alt) ||
|
|
event.code === "Enter" ||
|
|
event.code === "Space" ||
|
|
arrowDown ||
|
|
event.code === "Home" ||
|
|
arrowUp ||
|
|
event.code === "End")
|
|
) {
|
|
showFloating = true;
|
|
if (selected === undefined) {
|
|
selected = initialSelected;
|
|
}
|
|
return;
|
|
}
|
|
if (selected === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
event.code === "Enter" ||
|
|
event.code === "Space" ||
|
|
event.code === "Tab" ||
|
|
(arrowUp && alt)
|
|
) {
|
|
showFloating = false;
|
|
setValue(parsed[selected].parsedValue);
|
|
} else if (arrowUp) {
|
|
if (selected < 0) {
|
|
selected = last + 1;
|
|
}
|
|
selectFocus(selected - 1);
|
|
} else if (arrowDown) {
|
|
selectFocus(selected + 1);
|
|
} else if (event.code === "Escape") {
|
|
// TODO This doesn't work as the window typically catches the Escape as well
|
|
// and closes the window
|
|
// - qt/aqt/browser/browser.py:377
|
|
showFloating = false;
|
|
} else if (event.code === "Home") {
|
|
selectFocus(0);
|
|
} else if (event.code === "End") {
|
|
selectFocus(last);
|
|
}
|
|
}
|
|
|
|
function revealed() {
|
|
if (selected === undefined) {
|
|
return;
|
|
}
|
|
setTimeout(selectFocus, 0, selected);
|
|
}
|
|
|
|
/**
|
|
* Focus on an option.
|
|
* Values outside the range clip to either end
|
|
* @param num index number to focus on
|
|
*/
|
|
function selectFocus(num: number) {
|
|
if (selected === -2) {
|
|
selected = -1;
|
|
return;
|
|
}
|
|
if (num < 0) {
|
|
num = 0;
|
|
} else if (num > last) {
|
|
num = last;
|
|
}
|
|
|
|
if (selected !== undefined && 0 <= selected && selected <= last) {
|
|
buttons[selected].classList.remove("focus");
|
|
}
|
|
|
|
if (num >= 0) {
|
|
const el = buttons[num];
|
|
el.classList.add("focus");
|
|
if (!isScrolledIntoView(el)) {
|
|
el.scrollIntoView();
|
|
}
|
|
}
|
|
selected = num;
|
|
}
|
|
|
|
function isScrolledIntoView(el: HTMLElement) {
|
|
// This could probably be a helper function of some sort, I don't know where to put it
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.top >= 0 && rect.bottom <= window.innerHeight;
|
|
}
|
|
</script>
|
|
|
|
<WithFloating
|
|
show={showFloating}
|
|
offset={0}
|
|
shift={0}
|
|
hideArrow
|
|
inline
|
|
closeOnInsideClick
|
|
keepOnKeyup
|
|
on:close={() => (showFloating = false)}
|
|
let:asReference
|
|
>
|
|
<!-- TODO implement aria-label with semantic label -->
|
|
<div
|
|
{id}
|
|
class="{className} select-container"
|
|
class:rtl
|
|
class:hover
|
|
class:disabled
|
|
title={tooltip}
|
|
tabindex="0"
|
|
role="combobox"
|
|
aria-controls={ids.popover}
|
|
aria-expanded={showFloating}
|
|
aria-activedescendant={ids.focused}
|
|
on:keydown={onKeyDown}
|
|
on:mouseenter={() => (hover = true)}
|
|
on:mouseleave={() => (hover = false)}
|
|
on:click={() => {
|
|
if (selected === undefined) {
|
|
selected = initialSelected;
|
|
}
|
|
showFloating = !showFloating;
|
|
}}
|
|
bind:this={element}
|
|
use:asReference
|
|
bind:clientWidth
|
|
>
|
|
<div class="inner">
|
|
<div class="label">{@html label}</div>
|
|
</div>
|
|
<div class="chevron">
|
|
<IconConstrain iconSize={80}>
|
|
{@html chevronDown}
|
|
</IconConstrain>
|
|
</div>
|
|
</div>
|
|
<Popover
|
|
slot="floating"
|
|
scrollable
|
|
--popover-width="{clientWidth}px"
|
|
id={ids.popover}
|
|
on:revealed={revealed}
|
|
>
|
|
{#each parsed as { content, parsedValue, disabled }, idx (idx)}
|
|
<SelectOption
|
|
value={parsedValue}
|
|
bind:element={buttons[idx]}
|
|
{disabled}
|
|
selected={idx === selected}
|
|
id={ids.focused}
|
|
>
|
|
{content}
|
|
</SelectOption>
|
|
{/each}
|
|
</Popover>
|
|
</WithFloating>
|
|
|
|
<style lang="scss">
|
|
@use "sass/button-mixins" as button;
|
|
|
|
$padding-inline: 0.5rem;
|
|
|
|
.select-container {
|
|
@include button.select($with-disabled: false);
|
|
line-height: 1.5;
|
|
height: 100%;
|
|
position: relative;
|
|
display: flex;
|
|
flex-flow: row;
|
|
justify-content: space-between;
|
|
|
|
.inner {
|
|
flex-grow: 1;
|
|
position: relative;
|
|
.label {
|
|
position: absolute;
|
|
top: 0;
|
|
right: $padding-inline;
|
|
bottom: 0;
|
|
left: $padding-inline;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
}
|
|
}
|
|
|
|
.disabled {
|
|
pointer-events: none;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.chevron {
|
|
height: 100%;
|
|
align-self: flex-end;
|
|
border-left: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
:global([dir="rtl"]) {
|
|
.chevron {
|
|
border-left: none;
|
|
border-right: 1px solid var(--border-subtle);
|
|
}
|
|
}
|
|
</style>
|