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:
Henrik Giesel 2022-03-02 05:21:19 +01:00 committed by GitHub
parent 1219dd8f9e
commit 88217c5e7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 654 additions and 10 deletions

View File

@ -56,6 +56,7 @@
"postinstall": "patch-package --patch-dir ts/patches"
},
"dependencies": {
"@floating-ui/dom": "^0.3.0",
"@fluent/bundle": "^0.17.0",
"@mdi/svg": "^6.1.95",
"@popperjs/core": "^2.9.2",

View File

@ -75,6 +75,14 @@ sass_library(
visibility = ["//visibility:public"],
)
sass_library(
name = "elevation_lib",
srcs = [
"elevation.scss",
],
visibility = ["//visibility:public"],
)
exports_files(
["_vars.scss"],
visibility = ["//visibility:public"],

80
sass/elevation.scss Normal file
View File

@ -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);
}

View File

@ -8,6 +8,7 @@ _ts_deps = [
"//ts/sveltelib",
"@npm//@popperjs/core",
"@npm//@types/bootstrap",
"@npm//@floating-ui/dom",
"@npm//bootstrap",
"@npm//svelte",
]
@ -40,6 +41,7 @@ svelte_check(
"//sass:button_mixins_lib",
"//sass:scrollbar_lib",
"//sass:breakpoints_lib",
"//sass:elevation_lib",
"//sass/bootstrap",
"@npm//@types/bootstrap",
"//ts/lib:lib_pkg",

View File

@ -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>

View File

@ -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>

View File

@ -3,12 +3,13 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { writable } from "svelte/store";
import DropdownItem from "../../components/DropdownItem.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte";
import { withButton } from "../../components/helpers";
import IconButton from "../../components/IconButton.svelte";
import Popover from "../../components/Popover.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 { getPlatformString } from "../../lib/shortcuts";
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);
const showDropdown = writable(false);
$: if (disabled) {
$showDropdown = false;
}
</script>
<WithDropdown let:createDropdown>
<IconButton {disabled} on:mount={withButton(createDropdown)}>
<WithFloating show={showDropdown} closeOnInsideClick let:toggle>
<IconButton slot="reference" {disabled} on:click={toggle}>
{@html functionIcon}
</IconButton>
<DropdownMenu>
<Popover slot="floating">
{#each dropdownItems as [callback, keyCombination, label]}
<DropdownItem on:click={callback}>
{label}
@ -78,8 +85,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</DropdownItem>
<Shortcut {keyCombination} on:action={callback} />
{/each}
</DropdownMenu>
</WithDropdown>
</Popover>
</WithFloating>
<style lang="scss">
.shortcut {

View File

@ -421,7 +421,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags}
on:click={appendEmptyTag}
on:tagappend={appendEmptyTag}
/>
{/if}

View File

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// 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
: A extends Document
? DocumentEventMap

View File

@ -6,3 +6,11 @@ export function assertUnreachable(x: never): never {
}
export type Callback = () => void;
export function singleCallback(...callbacks: Callback[]): Callback {
return () => {
for (const cb of callbacks) {
cb();
}
};
}

View File

@ -30,6 +30,20 @@
"path": "node_modules/@eslint/eslintrc",
"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": {
"licenses": "Apache-2.0",
"repository": "https://github.com/projectfluent/fluent.js",

View File

@ -8,6 +8,7 @@ typescript(
"//ts/lib",
"@npm//svelte",
"@npm//tslib",
"@npm//@floating-ui/dom",
],
)

View File

@ -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;

View File

@ -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;

View File

@ -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 };

31
ts/sveltelib/portal.ts Normal file
View File

@ -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;

82
ts/sveltelib/position.ts Normal file
View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -338,6 +338,18 @@
minimatch "^3.0.4"
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":
version "0.17.1"
resolved "https://registry.yarnpkg.com/@fluent/bundle/-/bundle-0.17.1.tgz#239388f55f115dca593f268d48a5ab8936dc9556"