a new start
This commit is contained in:
86
packages/hydecorp/drawer/src/calc.ts
Normal file
86
packages/hydecorp/drawer/src/calc.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { VELOCITY_THRESHOLD } from "./constants";
|
||||
|
||||
import { Coord } from "./observables";
|
||||
|
||||
// Using shorthands for common functions
|
||||
const min = Math.min.bind(Math);
|
||||
const max = Math.max.bind(Math);
|
||||
|
||||
export class CalcMixin {
|
||||
side!: "left" | "right";
|
||||
range!: [number, number];
|
||||
|
||||
calcIsInRange({ clientX }: Coord, opened: boolean) {
|
||||
// console.log(this.range, this.align);
|
||||
switch (this.side) {
|
||||
case "left": {
|
||||
const [lower, upper] = this.range;
|
||||
return clientX > lower && (opened || clientX < upper);
|
||||
}
|
||||
case "right": {
|
||||
const upper = window.innerWidth - this.range[0];
|
||||
const lower = window.innerWidth - this.range[1];
|
||||
return clientX < upper && (opened || clientX > lower);
|
||||
}
|
||||
default:
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
calcIsSwipe(
|
||||
{ clientX: startX }: Coord,
|
||||
{ clientX: endX }: Coord,
|
||||
translateX: number,
|
||||
drawerWidth: number,
|
||||
_: number,
|
||||
): boolean {
|
||||
return endX !== startX || (translateX > 0 && translateX < drawerWidth);
|
||||
}
|
||||
|
||||
calcWillOpen(
|
||||
_: {},
|
||||
__: {},
|
||||
translateX: number,
|
||||
drawerWidth: number,
|
||||
velocity: number,
|
||||
): boolean {
|
||||
switch (this.side) {
|
||||
case "left": {
|
||||
if (velocity > VELOCITY_THRESHOLD) return true;
|
||||
else if (velocity < -VELOCITY_THRESHOLD) return false;
|
||||
else if (translateX >= drawerWidth / 2) return true;
|
||||
else return false;
|
||||
}
|
||||
case "right": {
|
||||
if (-velocity > VELOCITY_THRESHOLD) return true;
|
||||
else if (-velocity < -VELOCITY_THRESHOLD) return false;
|
||||
else if (translateX <= -drawerWidth / 2) return true;
|
||||
else return false;
|
||||
}
|
||||
default:
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
|
||||
calcTranslateX(
|
||||
{ clientX: moveX }: Coord,
|
||||
{ clientX: startX }: Coord,
|
||||
startTranslateX: number,
|
||||
drawerWidth: number,
|
||||
): number {
|
||||
switch (this.side) {
|
||||
case "left": {
|
||||
const deltaX = moveX - startX;
|
||||
const translateX = startTranslateX + deltaX;
|
||||
return max(0, min(drawerWidth, translateX));
|
||||
}
|
||||
case "right": {
|
||||
const deltaX = moveX - startX;
|
||||
const translateX = startTranslateX + deltaX;
|
||||
return min(0, max(-drawerWidth, translateX));
|
||||
}
|
||||
default:
|
||||
throw Error();
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/hydecorp/drawer/src/common.ts
Normal file
42
packages/hydecorp/drawer/src/common.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Observable, of } from "rxjs";
|
||||
import { map } from "rxjs/operators";
|
||||
import { createResizeObservable } from "@hydecorp/component";
|
||||
import { ComplexAttributeConverter } from "lit";
|
||||
|
||||
export {
|
||||
applyMixins,
|
||||
subscribeWhen,
|
||||
filterWhen,
|
||||
tween,
|
||||
} from "@hydecorp/component";
|
||||
|
||||
export function easeOutSine(t: number, b: number, c: number, d: number) {
|
||||
return c * Math.sin((t / d) * (Math.PI / 2)) + b;
|
||||
}
|
||||
|
||||
export function observeWidth(el: HTMLElement) {
|
||||
// This component should have at least basic support without `ResizeObserver` support,
|
||||
// so we pass a one-time measurement when it's missing. Obviously this won't update, so BYO polyfill.
|
||||
const resize$: Observable<{ contentRect: { width: number } }> =
|
||||
"ResizeObserver" in window
|
||||
? createResizeObservable(el)
|
||||
: of({ contentRect: { width: el.clientWidth } });
|
||||
return resize$.pipe(map((rect) => rect.contentRect.width));
|
||||
}
|
||||
|
||||
export const rangeConverter: ComplexAttributeConverter<number[]> = {
|
||||
fromAttribute(attr) {
|
||||
return (attr ?? "")
|
||||
.replace(/[\[\]]/g, "")
|
||||
.split(",")
|
||||
.map(Number);
|
||||
},
|
||||
|
||||
toAttribute(range) {
|
||||
return range.join(",");
|
||||
},
|
||||
};
|
||||
|
||||
export function rangeHasChanged(curr: number[], prev: number[] = []) {
|
||||
return curr.length !== prev.length || curr.some((v, i) => v !== prev[i]);
|
||||
}
|
||||
12
packages/hydecorp/drawer/src/constants.ts
Normal file
12
packages/hydecorp/drawer/src/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// The base duration of the fling animation.
|
||||
export const BASE_DURATION = 200;
|
||||
|
||||
// We adjust the duration of the animation using the width of the drawer.
|
||||
// There is no physics to this, but we know from testing that the animation starts to feel bad
|
||||
// when the drawer increases in size.
|
||||
// From testing we know that, if we increase the duration as a fraction of the drawer width,
|
||||
// the animation stays smooth across common display sizes.
|
||||
export const WIDTH_CONTRIBUTION = 0.15;
|
||||
|
||||
// Minimum velocity of the drawer (in px/ms) when releasing to make it fling to opened/closed state.
|
||||
export const VELOCITY_THRESHOLD = 0.15;
|
||||
485
packages/hydecorp/drawer/src/index.ts
Normal file
485
packages/hydecorp/drawer/src/index.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* Copyright (c) 2020 Florian Klampfer <https://qwtel.com/>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
* @license
|
||||
* @nocompile
|
||||
*/
|
||||
import { html, ReactiveElement } from "lit";
|
||||
import { property, customElement, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
import {
|
||||
Observable,
|
||||
Subject,
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
merge,
|
||||
NEVER,
|
||||
defer,
|
||||
fromEvent,
|
||||
} from "rxjs";
|
||||
import {
|
||||
startWith,
|
||||
takeUntil,
|
||||
map,
|
||||
share,
|
||||
withLatestFrom,
|
||||
tap,
|
||||
sample,
|
||||
timestamp,
|
||||
pairwise,
|
||||
filter,
|
||||
switchMap,
|
||||
skip,
|
||||
finalize,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import { RxLitElement, createResolvablePromise } from "@hydecorp/component";
|
||||
|
||||
import { BASE_DURATION, WIDTH_CONTRIBUTION } from "./constants";
|
||||
import {
|
||||
applyMixins,
|
||||
filterWhen,
|
||||
easeOutSine,
|
||||
observeWidth,
|
||||
rangeConverter,
|
||||
rangeHasChanged,
|
||||
tween,
|
||||
} from "./common";
|
||||
import { ObservablesMixin, Coord } from "./observables";
|
||||
import { CalcMixin } from "./calc";
|
||||
import { UpdateMixin, DOMUpdater } from "./update";
|
||||
import { styles } from "./styles";
|
||||
|
||||
@customElement("hy-drawer")
|
||||
export class HyDrawer
|
||||
extends applyMixins(RxLitElement, [ObservablesMixin, UpdateMixin, CalcMixin])
|
||||
implements ObservablesMixin, UpdateMixin, CalcMixin
|
||||
{
|
||||
static styles = styles;
|
||||
|
||||
@query(".scrim") scrimEl!: HTMLElement;
|
||||
@query(".wrapper") contentEl!: HTMLElement;
|
||||
@query(".peek") peekEl!: HTMLElement;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) opened: boolean = false;
|
||||
@property({ type: String, reflect: true }) side: "left" | "right" = "left";
|
||||
@property({ type: Boolean, reflect: true }) persistent: boolean = false;
|
||||
@property({ type: Number, reflect: true }) threshold: number = 10;
|
||||
@property({ type: Boolean, reflect: true }) noScroll: boolean = false;
|
||||
@property({ type: Boolean, reflect: true }) mouseEvents: boolean = false;
|
||||
// @property({ type: Boolean, reflect: true }) hashChange: boolean = false;
|
||||
@property({
|
||||
reflect: true,
|
||||
converter: rangeConverter,
|
||||
hasChanged: rangeHasChanged,
|
||||
})
|
||||
range: [number, number] = [0, 100];
|
||||
|
||||
// State
|
||||
@property() scrimClickable!: boolean;
|
||||
@property() grabbing!: boolean;
|
||||
@property() willChange: boolean = false;
|
||||
|
||||
#initialized = createResolvablePromise();
|
||||
get initialized() {
|
||||
return this.#initialized;
|
||||
}
|
||||
|
||||
// get histId() { return this.id || this.tagName; }
|
||||
// get hashId() { return `#${this.histId}--opened`; }
|
||||
|
||||
translateX!: number;
|
||||
opacity!: number;
|
||||
isSliding!: boolean;
|
||||
|
||||
$!: {
|
||||
opened: Subject<boolean>;
|
||||
side: Subject<"left" | "right">;
|
||||
persistent: Subject<boolean>;
|
||||
preventDefault: Subject<boolean>;
|
||||
mouseEvents: Subject<boolean>;
|
||||
// hashChange: Subject<boolean>;
|
||||
};
|
||||
|
||||
animateTo$!: Subject<boolean>;
|
||||
|
||||
// TODO: Prefer composition to mixins...
|
||||
// ObservablesMixin
|
||||
getStartObservable!: () => Observable<Coord>;
|
||||
getMoveObservable!: (
|
||||
start: Observable<Coord>,
|
||||
end: Observable<Coord>,
|
||||
) => Observable<Coord>;
|
||||
getEndObservable!: () => Observable<Coord>;
|
||||
getIsSlidingObservable!: (
|
||||
move: Observable<Coord>,
|
||||
start: Observable<Coord>,
|
||||
end: Observable<Coord>,
|
||||
) => Observable<boolean>;
|
||||
getIsSlidingObservableInner!: (
|
||||
move: Observable<Coord>,
|
||||
start: Observable<Coord>,
|
||||
) => Observable<boolean>;
|
||||
|
||||
// CalcMixin
|
||||
calcIsInRange!: (start: Coord, opened: boolean) => boolean;
|
||||
calcIsSwipe!: (
|
||||
start: Coord,
|
||||
end: Coord,
|
||||
translateX: number,
|
||||
drawerWidth: number,
|
||||
_: number,
|
||||
) => boolean;
|
||||
calcWillOpen!: (
|
||||
start: {},
|
||||
end: {},
|
||||
translateX: number,
|
||||
drawerWidth: number,
|
||||
velocity: number,
|
||||
) => boolean;
|
||||
calcTranslateX!: (
|
||||
move: Coord,
|
||||
start: Coord,
|
||||
startTranslateX: number,
|
||||
drawerWidth: number,
|
||||
) => number;
|
||||
|
||||
// UpdateMixin
|
||||
updateDOM!: (translateX: number, drawerWidth: number) => void;
|
||||
updater!: DOMUpdater;
|
||||
|
||||
// HyDrawer
|
||||
getDrawerWidth(): Observable<number> {
|
||||
const content$ = observeWidth(this.contentEl).pipe(
|
||||
tap((px) => this.fireEvent("content-width-change", { detail: px })),
|
||||
);
|
||||
const peek$ = observeWidth(this.peekEl).pipe(
|
||||
tap((px) => this.fireEvent("peek-width-change", { detail: px })),
|
||||
);
|
||||
|
||||
return combineLatest([content$, peek$]).pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
map(([contentWidth, peekWidth]) => contentWidth - peekWidth),
|
||||
share(),
|
||||
);
|
||||
}
|
||||
|
||||
// private consolidateState() {
|
||||
// const hashOpened = location.hash === this.hashId;
|
||||
// const isReload = history.state && history.state[this.histId];
|
||||
// if (isReload) {
|
||||
// if (hashOpened !== this.opened) {
|
||||
// this.opened = hashOpened;
|
||||
// }
|
||||
// } else {
|
||||
// const url = new URL(location.href);
|
||||
// const newState = { ...history.state, [this.histId]: { backable: false } };
|
||||
// if (hashOpened && !this.opened) {
|
||||
// url.hash = '';
|
||||
// history.replaceState(newState, document.title, url.href);
|
||||
|
||||
// url.hash = this.hashId;
|
||||
// history.pushState({ [this.histId]: { backable: true } }, document.title, url.href);
|
||||
|
||||
// this.opened = true;
|
||||
// }
|
||||
// else if (!hashOpened && this.opened) {
|
||||
// history.replaceState(newState, document.title, url.href);
|
||||
|
||||
// url.hash = this.hashId;
|
||||
// history.pushState({ [this.histId]: { backable: true } }, document.title, url.href);
|
||||
// }
|
||||
// else {
|
||||
// history.replaceState(newState, document.title, url.href);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// if (this.hashChange) this.consolidateState()
|
||||
|
||||
this.$ = {
|
||||
opened: new BehaviorSubject(this.opened),
|
||||
side: new BehaviorSubject(this.side),
|
||||
persistent: new BehaviorSubject(this.persistent),
|
||||
preventDefault: new BehaviorSubject(this.noScroll),
|
||||
mouseEvents: new BehaviorSubject(this.mouseEvents),
|
||||
// hashChange: new BehaviorSubject(this.hashChange),
|
||||
};
|
||||
|
||||
this.scrimClickable = this.opened;
|
||||
this.animateTo$ = new Subject<boolean>();
|
||||
this.updater = DOMUpdater.getUpdaterForPlatform(this);
|
||||
this.updateComplete.then(this.upgrade);
|
||||
}
|
||||
|
||||
#translateX$!: Observable<number>;
|
||||
#startTranslateX$!: Observable<number>;
|
||||
#tweenTranslateX$!: Observable<number>;
|
||||
|
||||
upgrade = () => {
|
||||
const drawerWidth$ = this.getDrawerWidth();
|
||||
const active$ = this.$.persistent.pipe(map((_) => !_));
|
||||
|
||||
const start$ = this.getStartObservable().pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
filterWhen(active$),
|
||||
share(),
|
||||
);
|
||||
|
||||
const isScrimVisible$ = defer(() => {
|
||||
return this.#translateX$.pipe(map((translateX) => translateX !== 0));
|
||||
});
|
||||
|
||||
const isInRange$ = start$.pipe(
|
||||
withLatestFrom(isScrimVisible$),
|
||||
map((args) => this.calcIsInRange(...args)),
|
||||
tap((inRange) => {
|
||||
if (inRange) {
|
||||
this.willChange = true;
|
||||
this.fireEvent("prepare");
|
||||
}
|
||||
}),
|
||||
share(),
|
||||
);
|
||||
|
||||
const end$ = this.getEndObservable().pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
filterWhen(active$, isInRange$),
|
||||
tap(() => {
|
||||
if (this.mouseEvents) this.grabbing = false;
|
||||
}),
|
||||
share(),
|
||||
);
|
||||
|
||||
const move$ = this.getMoveObservable(start$, end$).pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
filterWhen(active$, isInRange$),
|
||||
share(),
|
||||
);
|
||||
|
||||
const isSliding$ = this.getIsSlidingObservable(move$, start$, end$).pipe(
|
||||
tap((isSliding) => {
|
||||
this.isSliding = isSliding;
|
||||
if (isSliding && this.mouseEvents) this.grabbing = true;
|
||||
// if (isSliding) this.fireEvent('slidestart', { detail: this.opened });
|
||||
}),
|
||||
);
|
||||
|
||||
const translateX$ = (this.#translateX$ = defer(() => {
|
||||
const jumpTranslateX$ = combineLatest([
|
||||
this.$.opened,
|
||||
this.$.side,
|
||||
drawerWidth$,
|
||||
]).pipe(
|
||||
map(([opened, side, drawerWidth]) => {
|
||||
return !opened ? 0 : drawerWidth * (side === "left" ? 1 : -1);
|
||||
}),
|
||||
);
|
||||
|
||||
const moveTranslateX$ = move$.pipe(
|
||||
filterWhen(isSliding$),
|
||||
tap(() => (this.scrimClickable = false)),
|
||||
tap(({ event }) => event && this.noScroll && event.preventDefault()),
|
||||
withLatestFrom(start$, this.#startTranslateX$, drawerWidth$),
|
||||
map((args) => this.calcTranslateX(...args)),
|
||||
);
|
||||
|
||||
return merge(this.#tweenTranslateX$, jumpTranslateX$, moveTranslateX$);
|
||||
}).pipe(share()));
|
||||
|
||||
this.#startTranslateX$ = translateX$.pipe(sample(start$));
|
||||
|
||||
const velocity$ = translateX$.pipe(
|
||||
timestamp(),
|
||||
pairwise(),
|
||||
filter(
|
||||
([{ timestamp: prevTime }, { timestamp: time }]) => time - prevTime > 0,
|
||||
),
|
||||
map(
|
||||
([
|
||||
{ value: prevX, timestamp: prevTime },
|
||||
{ value: x, timestamp: time },
|
||||
]) => (x - prevX) / (time - prevTime),
|
||||
),
|
||||
startWith(0),
|
||||
);
|
||||
|
||||
const willOpen$ = end$.pipe(
|
||||
withLatestFrom(start$, translateX$, drawerWidth$, velocity$),
|
||||
filter((args) => this.calcIsSwipe(...args)),
|
||||
map((args) => this.calcWillOpen(...args)),
|
||||
// TODO: only fire `slideend` event when slidestart fired as well?
|
||||
// tap(willOpen => this.fireEvent('slideend', { detail: willOpen })),
|
||||
);
|
||||
|
||||
const animateTo$ = this.animateTo$.pipe(
|
||||
tap(() => {
|
||||
this.willChange = true;
|
||||
this.fireEvent("prepare");
|
||||
}),
|
||||
);
|
||||
|
||||
this.#tweenTranslateX$ = merge(willOpen$, animateTo$).pipe(
|
||||
withLatestFrom(translateX$, drawerWidth$),
|
||||
switchMap(([willOpen, translateX, drawerWidth]) => {
|
||||
const inv = this.side === "left" ? 1 : -1;
|
||||
const endTranslateX = willOpen ? drawerWidth * inv : 0;
|
||||
const diffTranslateX = endTranslateX - translateX;
|
||||
const duration = Math.ceil(
|
||||
BASE_DURATION + drawerWidth * WIDTH_CONTRIBUTION,
|
||||
);
|
||||
|
||||
return tween(easeOutSine, translateX, diffTranslateX, duration).pipe(
|
||||
finalize(() => {
|
||||
this.transitioned(willOpen);
|
||||
}),
|
||||
takeUntil(start$),
|
||||
takeUntil(this.$.side.pipe(skip(1))),
|
||||
share(),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
translateX$
|
||||
.pipe(
|
||||
withLatestFrom(drawerWidth$),
|
||||
tap((args) => {
|
||||
this.updateDOM(...args);
|
||||
const { translateX, opacity } = this;
|
||||
this.fireEvent("move", {
|
||||
detail: { translateX, opacity },
|
||||
bubbles: false,
|
||||
});
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
fromEvent(this.scrimEl, "click")
|
||||
.pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
tap(() => this.close()),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
active$
|
||||
.pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
tap((active) => {
|
||||
this.scrimEl.style.display = active ? "block" : "none";
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.$.mouseEvents
|
||||
.pipe(
|
||||
// takeUntil(this.subjects.disconnect),
|
||||
switchMap((mouseEvents) => {
|
||||
return mouseEvents ? start$.pipe(withLatestFrom(isInRange$)) : NEVER;
|
||||
}),
|
||||
filter(([coord, isInRange]) => isInRange && coord.event != null),
|
||||
tap(([{ event }]) => event && event.preventDefault()),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// fromEvent(window, 'hashchange').pipe(
|
||||
// // takeUntil(this.subjects.disconnect),
|
||||
// subscribeWhen(this.$.hashChange),
|
||||
// tap(() => {
|
||||
// const opened = location.hash === this.hashId;
|
||||
// if (!history.state && opened) {
|
||||
// history.replaceState({ [this.histId]: { backable: true } }, document.title)
|
||||
// }
|
||||
|
||||
// // If the state doesn't match open/close the drawer
|
||||
// if (opened !== this.opened) this.animateTo$.next(opened);
|
||||
// }),
|
||||
// ).subscribe();
|
||||
|
||||
this.fireEvent("init", { detail: this.opened });
|
||||
this.#initialized.resolve(this);
|
||||
};
|
||||
|
||||
private transitioned = (hasOpened: boolean) => {
|
||||
this.opened = this.scrimClickable = hasOpened;
|
||||
this.willChange = false;
|
||||
|
||||
// if (this.hashChange) this.transitionedHash(hasOpened)
|
||||
|
||||
this.fireEvent("transitioned", { detail: hasOpened });
|
||||
};
|
||||
|
||||
// private transitionedHash(hasOpened: boolean) {
|
||||
// const hasClosed = !hasOpened;
|
||||
// const { backable } = history.state && history.state[this.histId] || { backable: false }
|
||||
// if (hasClosed && backable) {
|
||||
// history.back()
|
||||
// }
|
||||
// if (hasOpened && location.hash !== this.hashId) {
|
||||
// location.hash = this.hashId;
|
||||
// }
|
||||
// }
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="peek full-height"></div>
|
||||
<div
|
||||
class="scrim"
|
||||
style=${styleMap({
|
||||
willChange: this.willChange ? "opacity" : "",
|
||||
pointerEvents: this.scrimClickable ? "all" : "",
|
||||
})}
|
||||
></div>
|
||||
${
|
||||
this.mouseEvents && this.grabbing && !this.scrimClickable
|
||||
? html`<div class="grabbing-screen full-screen"></div>`
|
||||
: null
|
||||
}
|
||||
<div
|
||||
class=${classMap({
|
||||
wrapper: true,
|
||||
"full-height": true,
|
||||
[this.side]: true,
|
||||
grab: this.mouseEvents,
|
||||
grabbing: this.mouseEvents && this.grabbing,
|
||||
})}
|
||||
style=${styleMap({
|
||||
willChange: this.willChange ? "transform" : "",
|
||||
})}
|
||||
>
|
||||
<div class="overflow">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
open() {
|
||||
this.animateTo$.next(true);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.animateTo$.next(false);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.animateTo$.next(!this.opened);
|
||||
}
|
||||
}
|
||||
161
packages/hydecorp/drawer/src/observables.ts
Normal file
161
packages/hydecorp/drawer/src/observables.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
merge,
|
||||
NEVER,
|
||||
ObservedValueOf,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
// tap,
|
||||
filter,
|
||||
map,
|
||||
mapTo,
|
||||
repeatWhen,
|
||||
skipWhile,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
withLatestFrom,
|
||||
} from "rxjs/operators";
|
||||
|
||||
import { subscribeWhen } from "./common";
|
||||
|
||||
const abs = Math.abs.bind(Math);
|
||||
|
||||
export type Coord = {
|
||||
readonly target: EventTarget;
|
||||
readonly clientX: number;
|
||||
readonly clientY: number;
|
||||
readonly pageX: number;
|
||||
readonly pageY: number;
|
||||
readonly screenX: number;
|
||||
readonly screenY: number;
|
||||
event?: Event;
|
||||
};
|
||||
|
||||
export class ObservablesMixin {
|
||||
$!: {
|
||||
mouseEvents: Observable<boolean>;
|
||||
preventDefault: Observable<boolean>;
|
||||
};
|
||||
|
||||
threshold!: number;
|
||||
noScroll!: boolean;
|
||||
|
||||
getStartObservable() {
|
||||
return combineLatest([this.$.mouseEvents]).pipe(
|
||||
switchMap(([mouseEvents]) => {
|
||||
const touchstart$ = (<Observable<TouchEvent>>(
|
||||
fromEvent(document, "touchstart", { passive: true })
|
||||
)).pipe(
|
||||
filter(({ touches }) => touches.length === 1),
|
||||
map(({ touches }) => touches[0] as Coord),
|
||||
);
|
||||
|
||||
const mousedown$ = !mouseEvents
|
||||
? NEVER
|
||||
: (<Observable<MouseEvent>>fromEvent(document, "mousedown")).pipe(
|
||||
map((e) => (((e as Coord).event = e), e as Coord)),
|
||||
);
|
||||
|
||||
return merge(touchstart$, mousedown$);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getMoveObservable(start$: Observable<Coord>, end$: Observable<Coord>) {
|
||||
return combineLatest([this.$.mouseEvents, this.$.preventDefault]).pipe(
|
||||
switchMap(([mouseEvents, preventDefault]) => {
|
||||
const touchmove$ = (<Observable<TouchEvent>>(
|
||||
fromEvent(document, "touchmove", { passive: !preventDefault })
|
||||
)).pipe(
|
||||
map(
|
||||
(e) => (((e.touches[0] as Coord).event = e), e.touches[0] as Coord),
|
||||
),
|
||||
);
|
||||
|
||||
const mousemove$ = !mouseEvents
|
||||
? NEVER
|
||||
: (<Observable<MouseEvent>>(
|
||||
fromEvent(document, "mousemove", { passive: !preventDefault })
|
||||
)).pipe(
|
||||
subscribeWhen(
|
||||
merge(start$.pipe(mapTo(true)), end$.pipe(mapTo(false))),
|
||||
),
|
||||
map((e) => (((e as Coord).event = e), e as Coord)),
|
||||
);
|
||||
|
||||
return merge(touchmove$, mousemove$);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getEndObservable() {
|
||||
return combineLatest([this.$.mouseEvents]).pipe(
|
||||
switchMap(([mouseEvents]) => {
|
||||
const touchend$ = (<Observable<TouchEvent>>(
|
||||
fromEvent(document, "touchend", { passive: true })
|
||||
)).pipe(
|
||||
filter(({ touches }) => touches.length === 0),
|
||||
map((event) => event.changedTouches[0] as Coord),
|
||||
);
|
||||
|
||||
const mouseup$ = !mouseEvents
|
||||
? NEVER
|
||||
: (<Observable<MouseEvent>>(
|
||||
fromEvent(document, "mouseup", { passive: true })
|
||||
)).pipe(map((e) => (((e as Coord).event = e), e as Coord)));
|
||||
|
||||
return merge(touchend$, mouseup$);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getIsSlidingObservable(
|
||||
move$: Observable<Coord>,
|
||||
start$: Observable<Coord>,
|
||||
end$: Observable<Coord>,
|
||||
) {
|
||||
return this.getIsSlidingObservableInner(move$, start$).pipe(
|
||||
take(1),
|
||||
startWith(undefined),
|
||||
repeatWhen(() => end$),
|
||||
);
|
||||
}
|
||||
|
||||
getIsSlidingObservableInner(
|
||||
move$: Observable<Coord>,
|
||||
start$: Observable<Coord>,
|
||||
) {
|
||||
if (this.threshold) {
|
||||
return move$.pipe(
|
||||
withLatestFrom(start$),
|
||||
skipWhile(
|
||||
([{ clientX, clientY }, { clientX: startX, clientY: startY }]) =>
|
||||
abs(startY - clientY) < this.threshold &&
|
||||
abs(startX - clientX) < this.threshold,
|
||||
),
|
||||
map(
|
||||
([{ clientX, clientY }, { clientX: startX, clientY: startY }]) =>
|
||||
abs(startX - clientX) >= abs(startY - clientY),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return move$.pipe(
|
||||
withLatestFrom(start$),
|
||||
map(
|
||||
([
|
||||
{ clientX, clientY, event },
|
||||
{ clientX: startX, clientY: startY },
|
||||
]) => {
|
||||
const isSliding = abs(startX - clientX) >= abs(startY - clientY);
|
||||
if (this.noScroll && isSliding && event) event.preventDefault();
|
||||
return isSliding;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
104
packages/hydecorp/drawer/src/styles.ts
Normal file
104
packages/hydecorp/drawer/src/styles.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { css } from "lit";
|
||||
|
||||
export const styles = css`
|
||||
@media screen {
|
||||
:host {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
.full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.peek {
|
||||
left: 0;
|
||||
width: var(--hy-drawer-peek-width, 0px);
|
||||
visibility: hidden;
|
||||
z-index: calc(var(--hy-drawer-z-index, 100) - 1);
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 10vh;
|
||||
width: 10vw;
|
||||
transform: scale(10);
|
||||
transform-origin: top left;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
background: var(--hy-drawer-scrim-background, rgba(0, 0, 0, 0.5));
|
||||
z-index: var(--hy-drawer-z-index, 100);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.range {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
z-index: calc(var(--hy-drawer-z-index, 100) + 1);
|
||||
}
|
||||
|
||||
.grabbing-screen {
|
||||
cursor: grabbing;
|
||||
z-index: calc(var(--hy-drawer-z-index, 100) + 2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: var(--hy-drawer-width, 300px);
|
||||
background: var(--hy-drawer-background, inherit);
|
||||
box-shadow: var(--hy-drawer-box-shadow, 0 0 15px rgba(0, 0, 0, 0.25));
|
||||
z-index: calc(var(--hy-drawer-z-index, 100) + 3);
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.wrapper.left {
|
||||
left: calc(-1 * var(--hy-drawer-width, 300px) + var(--hy-drawer-peek-width, 0px));
|
||||
}
|
||||
|
||||
.wrapper.right {
|
||||
right: calc(-1 * var(--hy-drawer-width, 300px) + var(--hy-drawer-peek-width, 0px));
|
||||
}
|
||||
|
||||
.wrapper > .overflow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.grab {
|
||||
cursor: move;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.scrim {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
79
packages/hydecorp/drawer/src/update.ts
Normal file
79
packages/hydecorp/drawer/src/update.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
export type CallbackValue = { translateX: number; opacity: number };
|
||||
|
||||
export class UpdateMixin {
|
||||
contentEl!: HTMLElement;
|
||||
scrimEl!: HTMLElement;
|
||||
|
||||
translateX!: number;
|
||||
side!: string;
|
||||
opacity!: number;
|
||||
|
||||
updater!: DOMUpdater;
|
||||
|
||||
updateDOM(translateX: number, drawerWidth: number) {
|
||||
const inv = this.side === "left" ? 1 : -1;
|
||||
const opacity = (translateX / drawerWidth) * inv || 0;
|
||||
|
||||
this.translateX = translateX;
|
||||
this.opacity = opacity;
|
||||
|
||||
this.updater.updateDOM(translateX, opacity);
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class DOMUpdater {
|
||||
static getUpdaterForPlatform(parent: UpdateMixin) {
|
||||
const hasCSSOM =
|
||||
"attributeStyleMap" in Element.prototype &&
|
||||
"CSS" in window &&
|
||||
"number" in CSS;
|
||||
return hasCSSOM
|
||||
? new AttributeStyleMapUpdater(parent)
|
||||
: new StyleUpdater(parent);
|
||||
}
|
||||
|
||||
private parent: UpdateMixin;
|
||||
constructor(parent: UpdateMixin) {
|
||||
this.parent = parent;
|
||||
}
|
||||
get contentEl() {
|
||||
return this.parent.contentEl;
|
||||
}
|
||||
get scrimEl() {
|
||||
return this.parent.scrimEl;
|
||||
}
|
||||
|
||||
abstract updateDOM(translateX: number, opacity: number): void;
|
||||
}
|
||||
|
||||
export class StyleUpdater extends DOMUpdater {
|
||||
constructor(parent: UpdateMixin) {
|
||||
super(parent);
|
||||
}
|
||||
|
||||
updateDOM(translateX: number, opacity: number) {
|
||||
this.contentEl.style.transform = `translate(${translateX}px, 0px)`;
|
||||
this.scrimEl.style.opacity = `${opacity}`;
|
||||
}
|
||||
}
|
||||
|
||||
export class AttributeStyleMapUpdater extends DOMUpdater {
|
||||
private tvalue: CSSTransformValue;
|
||||
private ovalue: CSSUnitValue;
|
||||
|
||||
constructor(parent: UpdateMixin) {
|
||||
super(parent);
|
||||
this.tvalue = new CSSTransformValue([
|
||||
new CSSTranslate(CSS.px(0), CSS.px(0)),
|
||||
]);
|
||||
this.ovalue = CSS.number(1);
|
||||
}
|
||||
|
||||
updateDOM(translateX: number, opacity: number) {
|
||||
((this.tvalue[0] as CSSTranslate).x as CSSUnitValue).value = translateX;
|
||||
this.ovalue.value = opacity;
|
||||
|
||||
this.contentEl.attributeStyleMap.set("transform", this.tvalue);
|
||||
this.scrimEl.attributeStyleMap.set("opacity", this.ovalue);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user