c11e40b11b
* Fix Select component not reacting to changed list Fixes #2882. * Add msys to path on Windows in VSC settings
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,
|
|
};
|
|
};
|
|
$: 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() {
|
|
clientWidth = element?.clientWidth ?? 150;
|
|
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
|
|
>
|
|
<div class="inner">
|
|
<div class="label">{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>
|