anki/ts/components/Select.svelte
RumovZ c11e40b11b
Fix Select component not reacting to changed list (#2885)
* Fix Select component not reacting to changed list

Fixes #2882.

* Add msys to path on Windows in VSC settings
2023-12-11 09:12:34 +10:00

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>