Replace (some) Bootstrap dropdowns with Floating UI (#1695)
* Implement a first version of WithFloating and Portal * Add outside slot for Portal * Execute computePosition from WithFloating * Set up a first example of new WithFloating with the Latex menu * Use autoUpdate in WithFloating * Create sveltelib/position * Add event-store * Use event-store in close-on-click * Implement subscribeToUpdates * Introduce sass/elevation * Split close-on-click to closing-click and subscribe-trigger * Have closing-* stores return a symbol - This way they act more of an EventEmitter than a store * Allow passing show store * Remove styling on float on updatePosition removal * Implement a nice border for dropdowns * Apply different border and box-shadow to Popover in dark/light theme * Fix Ctrl+Shift+T not working * Satisfy formatters and tests * Add copyright header * move copyright header to top (dae)
This commit is contained in:
parent
1219dd8f9e
commit
88217c5e7d
|
@ -56,6 +56,7 @@
|
||||||
"postinstall": "patch-package --patch-dir ts/patches"
|
"postinstall": "patch-package --patch-dir ts/patches"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/dom": "^0.3.0",
|
||||||
"@fluent/bundle": "^0.17.0",
|
"@fluent/bundle": "^0.17.0",
|
||||||
"@mdi/svg": "^6.1.95",
|
"@mdi/svg": "^6.1.95",
|
||||||
"@popperjs/core": "^2.9.2",
|
"@popperjs/core": "^2.9.2",
|
||||||
|
|
|
@ -75,6 +75,14 @@ sass_library(
|
||||||
visibility = ["//visibility:public"],
|
visibility = ["//visibility:public"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sass_library(
|
||||||
|
name = "elevation_lib",
|
||||||
|
srcs = [
|
||||||
|
"elevation.scss",
|
||||||
|
],
|
||||||
|
visibility = ["//visibility:public"],
|
||||||
|
)
|
||||||
|
|
||||||
exports_files(
|
exports_files(
|
||||||
["_vars.scss"],
|
["_vars.scss"],
|
||||||
visibility = ["//visibility:public"],
|
visibility = ["//visibility:public"],
|
||||||
|
|
|
@ -0,0 +1,80 @@
|
||||||
|
// Heavily inspired by https://github.com/material-components/material-components-web/tree/master/packages/mdc-elevation
|
||||||
|
@use "sass:map";
|
||||||
|
@use "sass:list";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maps correspond to dp levels:
|
||||||
|
* 0: 0dp
|
||||||
|
* 1: 1dp
|
||||||
|
* 2: 2dp
|
||||||
|
* 3: 3dp
|
||||||
|
* 4: 4dp
|
||||||
|
* 5: 6dp
|
||||||
|
* 6: 8dp
|
||||||
|
* 7: 12dp
|
||||||
|
* 8: 16dp
|
||||||
|
* 9: 24dp
|
||||||
|
*/
|
||||||
|
|
||||||
|
$umbra-map: (
|
||||||
|
0: "0px 0px 0px 0px",
|
||||||
|
1: "0px 2px 1px -1px",
|
||||||
|
2: "0px 3px 1px -2px",
|
||||||
|
3: "0px 3px 3px -2px",
|
||||||
|
4: "0px 2px 4px -1px",
|
||||||
|
5: "0px 3px 5px -1px",
|
||||||
|
6: "0px 5px 5px -3px",
|
||||||
|
7: "0px 7px 8px -4px",
|
||||||
|
8: "0px 8px 10px -5px",
|
||||||
|
9: "0px 11px 15px -7px",
|
||||||
|
);
|
||||||
|
|
||||||
|
$penumbra-map: (
|
||||||
|
0: "0px 0px 0px 0px",
|
||||||
|
1: "0px 1px 1px 0px",
|
||||||
|
2: "0px 2px 2px 0px",
|
||||||
|
3: "0px 3px 4px 0px",
|
||||||
|
4: "0px 4px 5px 0px",
|
||||||
|
5: "0px 6px 10px 0px",
|
||||||
|
6: "0px 8px 10px 1px",
|
||||||
|
7: "0px 12px 17px 2px",
|
||||||
|
8: "0px 16px 24px 2px",
|
||||||
|
9: "0px 24px 38px 3px",
|
||||||
|
);
|
||||||
|
|
||||||
|
$ambient-map: (
|
||||||
|
0: "0px 0px 0px 0px",
|
||||||
|
1: "0px 1px 3px 0px",
|
||||||
|
2: "0px 1px 5px 0px",
|
||||||
|
3: "0px 1px 8px 0px",
|
||||||
|
4: "0px 1px 10px 0px",
|
||||||
|
5: "0px 1px 18px 0px",
|
||||||
|
6: "0px 3px 14px 2px",
|
||||||
|
7: "0px 5px 22px 4px",
|
||||||
|
8: "0px 6px 30px 5px",
|
||||||
|
9: "0px 9px 46px 8px",
|
||||||
|
);
|
||||||
|
|
||||||
|
$umbra-opacity: 0.2;
|
||||||
|
$penumbra-opacity: 0.14;
|
||||||
|
$ambient-opacity: 0.12;
|
||||||
|
|
||||||
|
@function box-shadow($level, $opacity-boost: 0, $color: black) {
|
||||||
|
$umbra-z-value: map.get($umbra-map, $level);
|
||||||
|
$penumbra-z-value: map.get($penumbra-map, $level);
|
||||||
|
$ambient-z-value: map.get($ambient-map, $level);
|
||||||
|
|
||||||
|
$umbra-color: rgba($color, $umbra-opacity + $opacity-boost);
|
||||||
|
$penumbra-color: rgba($color, $penumbra-opacity + $opacity-boost);
|
||||||
|
$ambient-color: rgba($color, $ambient-opacity + $opacity-boost);
|
||||||
|
|
||||||
|
@return (
|
||||||
|
#{"#{$umbra-z-value} #{$umbra-color}"},
|
||||||
|
#{"#{$penumbra-z-value} #{$penumbra-color}"},
|
||||||
|
#{$ambient-z-value} $ambient-color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin elevation($level, $other: ()) {
|
||||||
|
box-shadow: list.join(box-shadow($level), $other);
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ _ts_deps = [
|
||||||
"//ts/sveltelib",
|
"//ts/sveltelib",
|
||||||
"@npm//@popperjs/core",
|
"@npm//@popperjs/core",
|
||||||
"@npm//@types/bootstrap",
|
"@npm//@types/bootstrap",
|
||||||
|
"@npm//@floating-ui/dom",
|
||||||
"@npm//bootstrap",
|
"@npm//bootstrap",
|
||||||
"@npm//svelte",
|
"@npm//svelte",
|
||||||
]
|
]
|
||||||
|
@ -40,6 +41,7 @@ svelte_check(
|
||||||
"//sass:button_mixins_lib",
|
"//sass:button_mixins_lib",
|
||||||
"//sass:scrollbar_lib",
|
"//sass:scrollbar_lib",
|
||||||
"//sass:breakpoints_lib",
|
"//sass:breakpoints_lib",
|
||||||
|
"//sass:elevation_lib",
|
||||||
"//sass/bootstrap",
|
"//sass/bootstrap",
|
||||||
"@npm//@types/bootstrap",
|
"@npm//@types/bootstrap",
|
||||||
"//ts/lib:lib_pkg",
|
"//ts/lib:lib_pkg",
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
Alternative to DropdownMenu that avoids Bootstrap
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
import { pageTheme } from "../sveltelib/theme";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="popover" class:dark={$pageTheme.isDark} on:mousedown|preventDefault>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.popover {
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: var(--frame-bg);
|
||||||
|
min-width: 1rem;
|
||||||
|
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-fg);
|
||||||
|
|
||||||
|
/* outer border */
|
||||||
|
border: 1px solid #b6b6b6;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
border-color: #060606;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* inner border */
|
||||||
|
box-shadow: inset 0 0 0 1px #eeeeee;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
box-shadow: inset 0 0 0 1px #565656;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -0,0 +1,107 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Placement } from "@floating-ui/dom";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
import isClosingClick from "../sveltelib/closing-click";
|
||||||
|
import isClosingKeyup from "../sveltelib/closing-keyup";
|
||||||
|
import { documentClick, documentKeyup } from "../sveltelib/event-store";
|
||||||
|
import portal from "../sveltelib/portal";
|
||||||
|
import position from "../sveltelib/position";
|
||||||
|
import subscribeTrigger from "../sveltelib/subscribe-trigger";
|
||||||
|
import { pageTheme } from "../sveltelib/theme";
|
||||||
|
import toggleable from "../sveltelib/toggleable";
|
||||||
|
|
||||||
|
/** TODO at the moment we only dropdowns which are placed actually below the reference */
|
||||||
|
const placement: Placement = "bottom";
|
||||||
|
|
||||||
|
export let closeOnInsideClick = false;
|
||||||
|
|
||||||
|
/** This may be passed in for more fine-grained control */
|
||||||
|
export let show = writable(false);
|
||||||
|
|
||||||
|
let reference: HTMLElement;
|
||||||
|
let floating: HTMLElement;
|
||||||
|
let arrow: HTMLElement;
|
||||||
|
|
||||||
|
const { toggle, on, off } = toggleable(show);
|
||||||
|
|
||||||
|
onMount(() =>
|
||||||
|
subscribeTrigger(
|
||||||
|
show,
|
||||||
|
isClosingClick(documentClick, {
|
||||||
|
reference,
|
||||||
|
floating,
|
||||||
|
inside: closeOnInsideClick,
|
||||||
|
outside: true,
|
||||||
|
}),
|
||||||
|
isClosingKeyup(documentKeyup, {
|
||||||
|
reference,
|
||||||
|
floating,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={reference}
|
||||||
|
class="reference"
|
||||||
|
use:position={{ floating: $show ? floating : null, placement, arrow }}
|
||||||
|
>
|
||||||
|
<slot name="reference" {show} {toggle} {on} {off} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div bind:this={floating} class="floating" hidden={!$show} use:portal>
|
||||||
|
<slot name="floating" />
|
||||||
|
|
||||||
|
<div bind:this={arrow} class="arrow" class:dark={$pageTheme.isDark} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass/elevation" as elevation;
|
||||||
|
|
||||||
|
.reference {
|
||||||
|
/* TODO This should not be necessary */
|
||||||
|
line-height: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 5px;
|
||||||
|
|
||||||
|
z-index: 90;
|
||||||
|
@include elevation.elevation(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--frame-bg);
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
z-index: 60;
|
||||||
|
|
||||||
|
/* outer border */
|
||||||
|
border: 1px solid #b6b6b6;
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
border-color: #060606;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* These are dependant on which edge the arrow is supposed to be */
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
|
||||||
|
/* inner border */
|
||||||
|
box-shadow: inset 1px 1px 0 0 #eeeeee;
|
||||||
|
/* lightmode box-shadow: inset 1px 1px 0 0 #eee; */
|
||||||
|
|
||||||
|
&.dark {
|
||||||
|
box-shadow: inset 0 0 0 1px #565656;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -3,12 +3,13 @@ Copyright: Ankitects Pty Ltd and contributors
|
||||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
import DropdownItem from "../../components/DropdownItem.svelte";
|
import DropdownItem from "../../components/DropdownItem.svelte";
|
||||||
import DropdownMenu from "../../components/DropdownMenu.svelte";
|
|
||||||
import { withButton } from "../../components/helpers";
|
|
||||||
import IconButton from "../../components/IconButton.svelte";
|
import IconButton from "../../components/IconButton.svelte";
|
||||||
|
import Popover from "../../components/Popover.svelte";
|
||||||
import Shortcut from "../../components/Shortcut.svelte";
|
import Shortcut from "../../components/Shortcut.svelte";
|
||||||
import WithDropdown from "../../components/WithDropdown.svelte";
|
import WithFloating from "../../components/WithFloating.svelte";
|
||||||
import * as tr from "../../lib/ftl";
|
import * as tr from "../../lib/ftl";
|
||||||
import { getPlatformString } from "../../lib/shortcuts";
|
import { getPlatformString } from "../../lib/shortcuts";
|
||||||
import { wrapInternal } from "../../lib/wrap";
|
import { wrapInternal } from "../../lib/wrap";
|
||||||
|
@ -61,14 +62,20 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
];
|
];
|
||||||
|
|
||||||
$: disabled = !editingInputIsRichText($focusedInput);
|
$: disabled = !editingInputIsRichText($focusedInput);
|
||||||
|
|
||||||
|
const showDropdown = writable(false);
|
||||||
|
|
||||||
|
$: if (disabled) {
|
||||||
|
$showDropdown = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<WithDropdown let:createDropdown>
|
<WithFloating show={showDropdown} closeOnInsideClick let:toggle>
|
||||||
<IconButton {disabled} on:mount={withButton(createDropdown)}>
|
<IconButton slot="reference" {disabled} on:click={toggle}>
|
||||||
{@html functionIcon}
|
{@html functionIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
<DropdownMenu>
|
<Popover slot="floating">
|
||||||
{#each dropdownItems as [callback, keyCombination, label]}
|
{#each dropdownItems as [callback, keyCombination, label]}
|
||||||
<DropdownItem on:click={callback}>
|
<DropdownItem on:click={callback}>
|
||||||
{label}
|
{label}
|
||||||
|
@ -78,8 +85,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<Shortcut {keyCombination} on:action={callback} />
|
<Shortcut {keyCombination} on:action={callback} />
|
||||||
{/each}
|
{/each}
|
||||||
</DropdownMenu>
|
</Popover>
|
||||||
</WithDropdown>
|
</WithFloating>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.shortcut {
|
.shortcut {
|
||||||
|
|
|
@ -421,7 +421,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
on:tagselectall={selectAllTags}
|
on:tagselectall={selectAllTags}
|
||||||
on:tagcopy={copySelectedTags}
|
on:tagcopy={copySelectedTags}
|
||||||
on:tagdelete={deleteSelectedTags}
|
on:tagdelete={deleteSelectedTags}
|
||||||
on:click={appendEmptyTag}
|
on:tagappend={appendEmptyTag}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright: Ankitects Pty Ltd and contributors
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
type EventTargetToMap<A extends EventTarget> = A extends HTMLElement
|
export type EventTargetToMap<A extends EventTarget> = A extends HTMLElement
|
||||||
? HTMLElementEventMap
|
? HTMLElementEventMap
|
||||||
: A extends Document
|
: A extends Document
|
||||||
? DocumentEventMap
|
? DocumentEventMap
|
||||||
|
|
|
@ -6,3 +6,11 @@ export function assertUnreachable(x: never): never {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Callback = () => void;
|
export type Callback = () => void;
|
||||||
|
|
||||||
|
export function singleCallback(...callbacks: Callback[]): Callback {
|
||||||
|
return () => {
|
||||||
|
for (const cb of callbacks) {
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -30,6 +30,20 @@
|
||||||
"path": "node_modules/@eslint/eslintrc",
|
"path": "node_modules/@eslint/eslintrc",
|
||||||
"licenseFile": "node_modules/@eslint/eslintrc/LICENSE"
|
"licenseFile": "node_modules/@eslint/eslintrc/LICENSE"
|
||||||
},
|
},
|
||||||
|
"@floating-ui/core@0.5.0": {
|
||||||
|
"licenses": "MIT",
|
||||||
|
"repository": "https://github.com/floating-ui/floating-ui",
|
||||||
|
"publisher": "atomiks",
|
||||||
|
"path": "node_modules/@floating-ui/core",
|
||||||
|
"licenseFile": "node_modules/@floating-ui/core/README.md"
|
||||||
|
},
|
||||||
|
"@floating-ui/dom@0.3.0": {
|
||||||
|
"licenses": "MIT",
|
||||||
|
"repository": "https://github.com/floating-ui/floating-ui",
|
||||||
|
"publisher": "atomiks",
|
||||||
|
"path": "node_modules/@floating-ui/dom",
|
||||||
|
"licenseFile": "node_modules/@floating-ui/dom/README.md"
|
||||||
|
},
|
||||||
"@fluent/bundle@0.17.1": {
|
"@fluent/bundle@0.17.1": {
|
||||||
"licenses": "Apache-2.0",
|
"licenses": "Apache-2.0",
|
||||||
"repository": "https://github.com/projectfluent/fluent.js",
|
"repository": "https://github.com/projectfluent/fluent.js",
|
||||||
|
|
|
@ -8,6 +8,7 @@ typescript(
|
||||||
"//ts/lib",
|
"//ts/lib",
|
||||||
"@npm//svelte",
|
"@npm//svelte",
|
||||||
"@npm//tslib",
|
"@npm//tslib",
|
||||||
|
"@npm//@floating-ui/dom",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Readable } from "svelte/store";
|
||||||
|
import { derived } from "svelte/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typically the right-sided mouse button.
|
||||||
|
*/
|
||||||
|
function isSecondaryButton(event: MouseEvent): boolean {
|
||||||
|
return event.button === 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClosingClickArgs {
|
||||||
|
/**
|
||||||
|
* Clicking on the reference element should not close.
|
||||||
|
* The reference should handle this itself.
|
||||||
|
*/
|
||||||
|
reference: EventTarget;
|
||||||
|
floating: EventTarget;
|
||||||
|
inside: boolean;
|
||||||
|
outside: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a derived store, which translates `MouseEvent`s into a boolean
|
||||||
|
* indicating whether they constitue a click that should close `floating`.
|
||||||
|
*
|
||||||
|
* @param: Should be an event store wrapping document.click.
|
||||||
|
*/
|
||||||
|
function isClosingClick(
|
||||||
|
store: Readable<MouseEvent>,
|
||||||
|
{ reference, floating, inside, outside }: ClosingClickArgs,
|
||||||
|
): Readable<symbol> {
|
||||||
|
function isTriggerClick(path: EventTarget[]): boolean {
|
||||||
|
return (
|
||||||
|
// Reference element was clicked, e.g. the button.
|
||||||
|
// The reference element needs to handle opening/closing itself.
|
||||||
|
!path.includes(reference) &&
|
||||||
|
((inside && path.includes(floating)) ||
|
||||||
|
(outside && !path.includes(floating)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldClose(event: MouseEvent): boolean {
|
||||||
|
if (isSecondaryButton(event)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTriggerClick(event.composedPath())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return derived(store, (event: MouseEvent, set: (value: symbol) => void): void => {
|
||||||
|
if (shouldClose(event)) {
|
||||||
|
set(Symbol());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default isClosingClick;
|
|
@ -0,0 +1,48 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Readable } from "svelte/store";
|
||||||
|
import { derived } from "svelte/store";
|
||||||
|
|
||||||
|
interface ClosingKeyupArgs {
|
||||||
|
/**
|
||||||
|
* Clicking on the reference element should not close.
|
||||||
|
* The reference should handle this itself.
|
||||||
|
*/
|
||||||
|
reference: Node;
|
||||||
|
floating: Node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a derived store, which translates `MouseEvent`s into a boolean
|
||||||
|
* indicating whether they constitue a click that should close `floating`.
|
||||||
|
*
|
||||||
|
* @param: Should be an event store wrapping document.click.
|
||||||
|
*/
|
||||||
|
function isClosingKeyup(
|
||||||
|
store: Readable<KeyboardEvent>,
|
||||||
|
_args: ClosingKeyupArgs,
|
||||||
|
): Readable<symbol> {
|
||||||
|
// TODO there needs to be special treatment, whether the keyup happens
|
||||||
|
// inside the floating element or outside, but I'll defer until we actually
|
||||||
|
// use this for a popover with an input field
|
||||||
|
function shouldClose(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
// Allow Tab navigation.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return derived(
|
||||||
|
store,
|
||||||
|
(event: KeyboardEvent, set: (value: symbol) => void): void => {
|
||||||
|
if (shouldClose(event)) {
|
||||||
|
set(Symbol());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default isClosingKeyup;
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Readable, Subscriber } from "svelte/store";
|
||||||
|
import { readable } from "svelte/store";
|
||||||
|
|
||||||
|
import type { EventTargetToMap } from "../lib/events";
|
||||||
|
import { on } from "../lib/events";
|
||||||
|
import type { Callback } from "../lib/typing";
|
||||||
|
|
||||||
|
type Init<T> = { new (type: string): T; prototype: T };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A store wrapping an event. Automatically adds/removes event handler upon
|
||||||
|
* first/last subscriber.
|
||||||
|
*
|
||||||
|
* @remarks
|
||||||
|
* Should probably always be used in conjunction with `subscribeToUpdates`.
|
||||||
|
*/
|
||||||
|
function eventStore<T extends EventTarget, K extends keyof EventTargetToMap<T>>(
|
||||||
|
target: T,
|
||||||
|
eventType: Exclude<K, symbol | number>,
|
||||||
|
/**
|
||||||
|
* Store need an initial value. This should probably be a freshly
|
||||||
|
* constructed event, e.g. `new MouseEvent("click")`.
|
||||||
|
*/
|
||||||
|
constructor: Init<EventTargetToMap<T>[K]>,
|
||||||
|
): Readable<EventTargetToMap<T>[K]> {
|
||||||
|
const initEvent = new constructor(eventType);
|
||||||
|
return readable(
|
||||||
|
initEvent,
|
||||||
|
(set: Subscriber<EventTargetToMap<T>[K]>): Callback =>
|
||||||
|
on(target, eventType, set),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default eventStore;
|
||||||
|
|
||||||
|
const documentClick = eventStore(document, "click", MouseEvent);
|
||||||
|
const documentKeyup = eventStore(document, "keyup", KeyboardEvent);
|
||||||
|
|
||||||
|
export { documentClick, documentKeyup };
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param element: The element to be moved.
|
||||||
|
* @param target DOM Element where element is going to be appended
|
||||||
|
*/
|
||||||
|
function portal(
|
||||||
|
element: HTMLElement,
|
||||||
|
targetElement: Element = document.body,
|
||||||
|
): { update(target: Element): void; destroy(): void } {
|
||||||
|
let target: Element = targetElement;
|
||||||
|
|
||||||
|
async function update(newTarget: Element) {
|
||||||
|
target = newTarget;
|
||||||
|
target.append(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
update(target);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default portal;
|
|
@ -0,0 +1,82 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Placement } from "@floating-ui/dom";
|
||||||
|
import { arrow, autoUpdate, computePosition, offset, shift } from "@floating-ui/dom";
|
||||||
|
|
||||||
|
interface PositionArgs {
|
||||||
|
/**
|
||||||
|
* The floating element which is positioned relative to `reference`.
|
||||||
|
*/
|
||||||
|
floating: HTMLElement | null;
|
||||||
|
placement: Placement;
|
||||||
|
arrow: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function position(
|
||||||
|
reference: HTMLElement,
|
||||||
|
positionArgs: PositionArgs,
|
||||||
|
): { update(args: PositionArgs): void; destroy(): void } {
|
||||||
|
let args = positionArgs;
|
||||||
|
|
||||||
|
async function updateInner(): Promise<void> {
|
||||||
|
const { x, y, middlewareData } = await computePosition(
|
||||||
|
reference,
|
||||||
|
args.floating!,
|
||||||
|
{
|
||||||
|
middleware: [
|
||||||
|
offset(5),
|
||||||
|
shift({ padding: 5 }),
|
||||||
|
arrow({ element: args.arrow }),
|
||||||
|
],
|
||||||
|
placement: args.placement,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const arrowX = middlewareData.arrow?.x ?? "";
|
||||||
|
|
||||||
|
Object.assign(args.arrow.style, {
|
||||||
|
left: `${arrowX}px`,
|
||||||
|
top: `-5px`,
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.assign(args.floating!.style, {
|
||||||
|
left: `${x}px`,
|
||||||
|
top: `${y}px`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let cleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
function destroy(): void {
|
||||||
|
cleanup?.();
|
||||||
|
cleanup = null;
|
||||||
|
|
||||||
|
if (!args.floating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
args.floating.style.removeProperty("left");
|
||||||
|
args.floating.style.removeProperty("top");
|
||||||
|
}
|
||||||
|
|
||||||
|
function update(updateArgs: PositionArgs): void {
|
||||||
|
destroy();
|
||||||
|
args = updateArgs;
|
||||||
|
|
||||||
|
if (!args.floating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup = autoUpdate(reference, args.floating, updateInner);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(args);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default position;
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Readable, Writable } from "svelte/store";
|
||||||
|
|
||||||
|
import { Callback, singleCallback } from "../lib/typing";
|
||||||
|
import subscribeToUpdates from "./subscribe-updates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The goal of this action is to turn itself inactive.
|
||||||
|
* Once `active` is `true`, it will unsubscribe from `store`.
|
||||||
|
*
|
||||||
|
* @param active: If `active` is `true`, all stores will be subscribed to.
|
||||||
|
* @param stores: If any `store` updates to a true value, active will be set to false.
|
||||||
|
*/
|
||||||
|
function subscribeTrigger(
|
||||||
|
active: Writable<boolean>,
|
||||||
|
...stores: Readable<unknown>[]
|
||||||
|
): Callback {
|
||||||
|
function shouldUnset(): void {
|
||||||
|
active.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let destroy: Callback | null;
|
||||||
|
|
||||||
|
function doDestroy(): void {
|
||||||
|
destroy?.();
|
||||||
|
destroy = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
active.subscribe((value: boolean): void => {
|
||||||
|
if (value && !destroy) {
|
||||||
|
destroy = singleCallback(
|
||||||
|
...stores.map((store) => subscribeToUpdates(store, shouldUnset)),
|
||||||
|
);
|
||||||
|
} else if (!value) {
|
||||||
|
doDestroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doDestroy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default subscribeTrigger;
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Readable, Subscriber, Unsubscriber } from "svelte/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In some cases, we only care for updates, and not the initial
|
||||||
|
* value of a store, e.g. when the store wraps events.
|
||||||
|
* This also means, we can not use the special store syntax.
|
||||||
|
*/
|
||||||
|
function subscribeToUpdates<T>(
|
||||||
|
store: Readable<T>,
|
||||||
|
subscription: Subscriber<T>,
|
||||||
|
): Unsubscriber {
|
||||||
|
let first = true;
|
||||||
|
|
||||||
|
return store.subscribe((value: T): void => {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
} else {
|
||||||
|
subscription(value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default subscribeToUpdates;
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
import type { Writable } from "svelte/store";
|
||||||
|
|
||||||
|
export interface Toggleable {
|
||||||
|
toggle: () => void;
|
||||||
|
on: () => void;
|
||||||
|
off: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleable(store: Writable<boolean>): Toggleable {
|
||||||
|
function toggle(): void {
|
||||||
|
store.update((value) => !value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function on(): void {
|
||||||
|
store.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function off(): void {
|
||||||
|
store.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toggle, on, off };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default toggleable;
|
12
yarn.lock
12
yarn.lock
|
@ -338,6 +338,18 @@
|
||||||
minimatch "^3.0.4"
|
minimatch "^3.0.4"
|
||||||
strip-json-comments "^3.1.1"
|
strip-json-comments "^3.1.1"
|
||||||
|
|
||||||
|
"@floating-ui/core@^0.5.0":
|
||||||
|
version "0.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-0.5.0.tgz#d3d378412c525e663f41ced012e4e13049b31908"
|
||||||
|
integrity sha512-Ouka4Ck1mnPFZhgpfyFb+YBflGdk0o/SW8uSwByoRC6VqRzCHpW/7mJbFt/TWjl1lDcRbImJ2ZWOqwlIP71LDg==
|
||||||
|
|
||||||
|
"@floating-ui/dom@^0.3.0":
|
||||||
|
version "0.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-0.3.0.tgz#0fb41e390a109a6382d0946d159d800798fb878b"
|
||||||
|
integrity sha512-5wU64oa5rLNqgOD8cLq2lU25eiOosHAb2Ryt6kADgt5aCyqr9RYDQ3VTtefL9qd6MbZ3fZxPkp1+tNtjKnmTXw==
|
||||||
|
dependencies:
|
||||||
|
"@floating-ui/core" "^0.5.0"
|
||||||
|
|
||||||
"@fluent/bundle@^0.17.0":
|
"@fluent/bundle@^0.17.0":
|
||||||
version "0.17.1"
|
version "0.17.1"
|
||||||
resolved "https://registry.yarnpkg.com/@fluent/bundle/-/bundle-0.17.1.tgz#239388f55f115dca593f268d48a5ab8936dc9556"
|
resolved "https://registry.yarnpkg.com/@fluent/bundle/-/bundle-0.17.1.tgz#239388f55f115dca593f268d48a5ab8936dc9556"
|
||||||
|
|
Loading…
Reference in New Issue