feat: focusedChild property for containers

This commit is contained in:
Aleksi Lassila
2024-05-03 22:59:09 +03:00
parent aa1168b6b2
commit 31a442db83
13 changed files with 121 additions and 91 deletions

View File

@@ -20,19 +20,17 @@
export let direction: 'vertical' | 'horizontal' | 'grid' = 'vertical';
export let gridCols: number = 0;
export let focusOnMount = false;
export let canFocusEmpty = true;
export let trapFocus = false;
export let debugOutline = false;
export let focusOnClick = false;
export let focusChildOnMount = false;
export let focusedChild = false;
export let active = true;
export let disabled = false;
const { registerer, ...rest } = new Selectable(name)
.setDirection(direction === 'grid' ? 'horizontal' : direction)
.setGridColumns(gridCols)
.setTrapFocus(trapFocus)
.setCanFocusEmpty(canFocusEmpty)
.setOnFocus((selectable, options) => {
function stopPropagation() {
options.propagate = false;
@@ -84,6 +82,7 @@
dispatch('playPause', { selectable, options, stopPropagation, bubble });
})
.setAsFocusedChild(focusedChild)
.getStores();
export const selectable = rest.container;
@@ -93,7 +92,7 @@
export let tag = 'div';
$: selectable.setIsActive(active);
$: selectable.setIsDisabled(disabled);
$: selectable.setGridColumns(gridCols);
function handleClick(e: MouseEvent) {
@@ -106,7 +105,7 @@
}
onMount(() => {
rest.container._mountSelectable(focusOnMount, focusChildOnMount);
rest.container._mountSelectable(focusOnMount);
dispatch('mount', rest.container);
@@ -120,7 +119,7 @@
this={tag}
on:click={handleClick}
on:mousemove
tabindex={active ? 0 : -1}
tabindex={disabled ? -1 : 0}
{...$$restProps}
class={classNames($$restProps.class, {
'outline-none': debugOutline === false

View File

@@ -4,7 +4,7 @@
import classNames from 'classnames';
import AnimatedSelection from './AnimateScale.svelte';
export let inactive: boolean = false;
export let disabled: boolean = false;
export let focusOnMount: boolean = false;
export let type: 'primary' | 'secondary' = 'primary';
@@ -20,8 +20,8 @@
'selectable bg-secondary-800 px-6': type === 'primary',
'border-2 p-1 hover:border-primary-500': type === 'secondary',
'border-primary-500': type === 'secondary' && $hasFocus,
'cursor-pointer': !inactive,
'cursor-not-allowed pointer-events-none opacity-40': inactive
'cursor-pointer': !disabled,
'cursor-not-allowed pointer-events-none opacity-40': disabled
},
$$restProps.class
)}

View File

@@ -21,7 +21,7 @@
export let rating: number | undefined = undefined;
export let progress = 0;
export let focusable = true;
export let disabled = false;
export let shadow = false;
export let size: 'dynamic' | 'md' | 'lg' | 'sm' = 'md';
export let orientation: 'portrait' | 'landscape' = 'landscape';
@@ -34,7 +34,7 @@
<AnimatedSelection hasFocus={$hasFocus}>
<Container
active={focusable}
{disabled}
on:clickOrSelect={() => {
if (tmdbId || tvdbId) {
navigate(navigateWithType ? `${type}/${tmdbId || tvdbId}` : `${tmdbId || tvdbId}`);

View File

@@ -28,23 +28,23 @@
<Modal {modalId}>
<div class="h-full flex items-center justify-center bg-secondary-950/75">
<div class="bg-secondary-800 rounded-xl max-w-lg p-16">
<div class="text-xl font-semibold tracking-wide mb-2">
<div class="bg-secondary-800 rounded-2xl max-w-lg p-10">
<div class="text-2xl font-semibold tracking-wide mb-2 text-secondary-100">
<slot name="header" />
</div>
<div class="font-medium text-zinc-300 mb-8">
<div class="font-medium text-secondary-300 mb-8">
<slot />
</div>
<Container direction="horizontal" class="flex">
<Button
type="secondary"
inactive={fetching}
disabled={fetching}
on:clickOrSelect={() => handleAction(confirm)}
class="mr-4"
>
Confirm
</Button>
<Button type="secondary" inactive={fetching} on:clickOrSelect={() => handleAction(cancel)}
<Button type="secondary" disabled={fetching} on:clickOrSelect={() => handleAction(cancel)}
>Cancel</Button
>
</Container>

View File

@@ -4,7 +4,7 @@
export let focusOnMount = false;
</script>
<Container class="flex flex-col my-16" canFocusEmpty={false} {focusOnMount}>
<Container class="flex flex-col my-16" {focusOnMount}>
<h1 class="tracking-wide text-2xl font-semibold mb-4">
<slot name="header">Header is missing</slot>
</h1>

View File

@@ -33,7 +33,7 @@
<Button
focusOnMount
on:clickOrSelect={() => handleGrabRelease(release.guid || '', release.indexerId || -1)}
inactive={!!($data || $isFetching || status)}
disabled={!!($data || $isFetching || status)}
>
{#if $data || status === 'downloading'}
Downloading...

View File

@@ -104,9 +104,11 @@
>
<TableHeaderCell />
</TableHeaderRow>
{#each files as file}
<MMLocalFileRow {file} {deleteFile} />
{/each}
<Container class="contents" focusedChild>
{#each files as file}
<MMLocalFileRow {file} {deleteFile} />
{/each}
</Container>
</div>
{#if files?.length}
<Container

View File

@@ -51,7 +51,7 @@
</TableCell>
<TableCell>
<TableButton
active={!didGrab && !fetching}
disabled={didGrab || fetching}
on:clickOrSelect={handleGrabRelease}
on:enter={scrollIntoView({ vertical: 128 })}
>

View File

@@ -89,7 +89,8 @@
>
<TableHeaderCell />
</TableHeaderRow>
<Container class="contents" focusOnMount>
<Container class="contents" focusedChild>
{#each getRecommendedReleases(releases).sort(getSortFn(sortBy, sortDirection)) as release, index}
<MMReleaseListRow {release} {grabRelease} />
{/each}

View File

@@ -2,17 +2,17 @@
import Container from '../../../Container.svelte';
import classNames from 'classnames';
export let active = true;
export let disabled = false;
</script>
<Container let:hasFocus on:clickOrSelect {active} class="float-left" on:enter>
<Container let:hasFocus on:clickOrSelect {disabled} class="float-left" on:enter>
<div
class={classNames(
'border-2 rounded-2xl p-1 cursor-pointer font-medium tracking-wide transition-colors',
{
'border-zinc-400': !hasFocus,
'border-primary-500': hasFocus,
'opacity-50': !active
'opacity-50': disabled
}
)}
>

View File

@@ -111,7 +111,7 @@
Play
<Play size={19} slot="icon" />
</Button>
<Button class="mr-4" inactive={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
<Button class="mr-4" disabled={$markAsLoading} on:clickOrSelect={toggleMarkAs}>
{#if isWatched}
Mark as Unwatched
{:else}

View File

@@ -110,7 +110,7 @@
<Button
class="mr-4"
on:clickOrSelect={() => requests.handleAddToRadarr(Number(id))}
inactive={$isFetching.handleAddToRadarr}
disabled={$isFetching.handleAddToRadarr}
>
Add to Radarr
<Plus slot="icon" size={19} />

View File

@@ -85,15 +85,10 @@ export class Selectable {
private parent?: Selectable;
private children: Selectable[] = [];
private htmlElement?: HTMLElement;
private neighbors: Record<Direction, Selectable | undefined> = {
up: undefined,
down: undefined,
left: undefined,
right: undefined
};
private canFocusEmpty: boolean = true;
private makeFocusedChild: boolean = false;
private trapFocus: boolean = false;
private isActive: boolean = true;
private disabled: boolean = true;
private onNavigate: NavigationHandler = () => {};
private onFocus: FocusHandler = () => {};
@@ -223,20 +218,8 @@ export class Selectable {
/**
* @returns {boolean} whether the selectable is focusable
*/
isFocusable(canFocusEmpty = this.canFocusEmpty): boolean {
// TODO: CLEAN UP
if (!this.isActive) return false;
if (this.htmlElement && canFocusEmpty) {
return this.htmlElement.tabIndex >= 0;
} else {
for (const child of this.children) {
if (child.isFocusable()) {
return true;
}
}
}
return false;
isFocusable(): boolean {
return !this.disabled;
}
private giveFocus(direction: Direction, fireActions: boolean = true): boolean {
@@ -275,10 +258,7 @@ export class Selectable {
}
}
if (selectable.neighbors[direction]?.isFocusable()) {
return { target: selectable.neighbors[direction] };
// return selectable.neighbors[direction];
} else if (!selectable.trapFocus) {
if (!selectable.trapFocus) {
const parent = selectable.parent;
if (parent) return getSelectable(parent);
}
@@ -322,6 +302,15 @@ export class Selectable {
return currentlyFocusedObject?.giveFocus(direction, fireActions);
}
/**
* Components are mounted in reverse order, such that a child and its siblings are mounted before the parent.
* This function initializes the parent-child tree structure by iterating through the initialization stack,
* adding children under their parents. This is done in the registration phase, before anything is mounted to
* the DOM (before onMount is called on these elements). The output is a set of parent root nodes (usually
* just one), that will be mounted to the dom.
*
* See {@link finalizeTreeStructure} for the finalization
*/
private static initializeTreeStructure() {
for (let i = 0; i < Selectable._initializationStack.length; i++) {
const selectable = Selectable._initializationStack[i];
@@ -347,7 +336,19 @@ export class Selectable {
}
/**
* TODO: Add docs
* For tree structure initialization, see {@link initializeTreeStructure}.
*
* This function iterates through the root nodes, created in the initialization phase, that are to be mounted
* to the DOM. It adds the to-be-mounted root elements as children to the already mounted parent elements.
*
* The initialization and finalization phases must be separated, because when a new selectable is registered
* and assigned a htmlElement, its to-be-parent selectable hasn't been assigned a htmlElement yet. As a result,
* when a html element is registered to a selectable, we don't know if that selectable is the last parent (root)
* that will be mounted, or if there's more parents that hasn't been initialized yet, hence we can't attach it
* to already mounted parent. The solution is to first create the parent-child structure of to-be-mounted elements
* using [Svelte Actions](https://svelte.dev/docs/svelte-action) (initialization), and when the first element
* mounts (onMount is called), we have all to-be-mounted elements and can attach them to the already mounted
* parent elements (finalization).
*/
private static finalizeTreeStructure() {
const getParentSelectable = (htmlElement: HTMLElement): Selectable | undefined => {
@@ -423,6 +424,8 @@ export class Selectable {
return aboveSibling;
};
let childToFocus: Selectable | undefined = undefined;
for (const child of this._initializationStack) {
const htmlElement = child.htmlElement;
const parentSelectable = htmlElement?.parentElement
@@ -432,7 +435,19 @@ export class Selectable {
if (parentSelectable) {
const aboveSibling = getSiblingSelectable(parentSelectable, child);
const index = aboveSibling ? parentSelectable.children.indexOf(aboveSibling) : undefined;
// If parent has focus, focus the child if it's the first child to be added or if the child
// should have focus when user navigates to the container (makeFocusedChild)
if (get(parentSelectable.hasFocus)) {
if (parentSelectable.children.length === 0) {
childToFocus = child;
} else if (child.makeFocusedChild) {
childToFocus = child;
}
}
parentSelectable.addChild(child, index === undefined ? 0 : index + 1);
console.debug('Attached child tree to parent', child, parentSelectable);
} else {
console.warn('Could not attach child (probably root)', child);
@@ -440,25 +455,23 @@ export class Selectable {
}
}
childToFocus?.focus();
Selectable._initializationStack = [];
}
/** TODO update docs
* This runs after the regsterer has been called and the htmlElement
* has been set. Becasue all the children get initialized before their parents,
* we can't create the parent-child tree structure in the registerer but instead
* have to wait until every element has htmlElement and then later (here) deduce
* the parent-child relationships.
/**
* Attaches new selectable to an existing, already mounted parent selectable.
* See {@link finalizeTreeStructure} for more information.
*/
_mountSelectable(focusOnMount: boolean = false, focusChildOnMount = false) {
console.debug('Mounting', this, Selectable._initializationStack.slice());
_mountSelectable(focusOnMount: boolean = false) {
// console.debug('Mounting', this, Selectable._initializationStack.slice());
Selectable.finalizeTreeStructure();
if (!get(this.hasFocusWithin) && this.isFocusable(true) && focusOnMount) {
if (!get(this.hasFocusWithin) && this.isFocusable() && focusOnMount) {
this.focus(); // TODO: CLEAN UP
} else if (!get(this.hasFocusWithin) && focusChildOnMount) {
this.focus({ setFocusedElement: false, propagate: false });
console.log('FOCUS ON MOUNT', this);
}
if (!this.htmlElement) {
@@ -494,7 +507,7 @@ export class Selectable {
return (htmlElement: HTMLElement) => {
selectable.setHtmlElement(htmlElement);
console.debug('Registering', selectable);
// console.debug('Registering', selectable);
Selectable._initializationStack.push(selectable);
Selectable.initializeTreeStructure();
@@ -558,9 +571,24 @@ export class Selectable {
child.parent = this;
// TODO: CLEAN UP
if (index === get(this.focusIndex) && get(this.hasFocusWithin)) {
child.focus();
// console.log('added child', child, 'to', this, 'at index', index, 'children', this.children);
if (child.makeFocusedChild) {
console.log('This should be focused', child);
let el = child;
let parent = el.parent;
console.log(
'Currently focused',
get(Selectable.focusedObject),
'parent has focus',
parent && get(parent.hasFocusWithin)
);
while (parent && !get(parent.hasFocusWithin)) {
parent.focusIndex.update((prev) => parent?.children?.indexOf(el) || prev);
el = parent;
parent = el.parent;
}
}
// 1. If parent has focus but also has child(ren), focus the child instead
@@ -568,19 +596,19 @@ export class Selectable {
// prevented receiving it, check if 1. applies to the parent
// eslint-disable-next-line @typescript-eslint/no-this-alias
let el: Selectable = this;
while (firstChild) {
if (get(el.hasFocus) && el.children.length) {
el.focus();
break;
}
if (!el.canFocusEmpty && el.parent) {
el = el.parent;
} else {
break;
}
}
// let el: Selectable = this;
// while (firstChild) {
// if (get(el.hasFocus) && el.children.length) {
// el.focus();
// break;
// }
//
// if (!el.canFocusEmpty && el.parent) {
// el = el.parent;
// } else {
// break;
// }
// }
return this;
}
@@ -615,8 +643,8 @@ export class Selectable {
return this.children[get(this.focusIndex)];
}
setIsActive(isActive: boolean) {
this.isActive = isActive;
setIsDisabled(disabled: boolean) {
this.disabled = disabled;
return this;
}
@@ -638,11 +666,6 @@ export class Selectable {
return this;
}
setCanFocusEmpty(canFocusEmpty: boolean) {
this.canFocusEmpty = canFocusEmpty;
return this;
}
setOnFocus(onFocus: typeof this.onFocus) {
this.onFocus = onFocus;
return this;
@@ -667,6 +690,11 @@ export class Selectable {
this.onPlayPause = onPlayPause;
return this;
}
setAsFocusedChild(focusedChild: boolean) {
this.makeFocusedChild = focusedChild;
return this;
}
}
export function handleKeyboardNavigation(event: KeyboardEvent) {