mirror of
https://github.com/rio-labs/rio.git
synced 2026-01-04 12:19:50 -06:00
336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
import { pixelsPerRem } from '../app';
|
|
import { commitCss } from '../utils';
|
|
import { componentsById } from '../componentManagement';
|
|
import { ComponentBase, ComponentState } from './componentBase';
|
|
import { ColorSet, ComponentId } from '../dataModels';
|
|
import { applySwitcheroo } from '../designApplication';
|
|
|
|
export type DrawerState = ComponentState & {
|
|
_type_: 'Drawer-builtin';
|
|
anchor?: ComponentId;
|
|
content?: ComponentId;
|
|
side?: 'left' | 'right' | 'top' | 'bottom';
|
|
is_modal?: boolean;
|
|
is_open?: boolean;
|
|
is_user_openable?: boolean;
|
|
color?: ColorSet;
|
|
};
|
|
|
|
export class DrawerComponent extends ComponentBase {
|
|
state: Required<DrawerState>;
|
|
|
|
private anchorContainer: HTMLElement;
|
|
private contentOuterContainer: HTMLElement;
|
|
private contentInnerContainer: HTMLElement;
|
|
private shadeElement: HTMLElement;
|
|
|
|
private dragStartedAt: number = 0;
|
|
private openFractionAtDragStart: number = 0;
|
|
private openFraction: number = 1;
|
|
|
|
private isFirstUpdate: boolean = true;
|
|
|
|
createElement(): HTMLElement {
|
|
// Create the HTML
|
|
let element = document.createElement('div');
|
|
element.classList.add('rio-drawer');
|
|
|
|
element.innerHTML = `
|
|
<div class="rio-drawer-anchor"></div>
|
|
<div class="rio-drawer-shade"></div>
|
|
<div class="rio-drawer-content-outer">
|
|
<div class="rio-drawer-content-inner"></div>
|
|
<div class="rio-drawer-knob"></div>
|
|
</div>
|
|
`;
|
|
|
|
this.anchorContainer = element.querySelector(
|
|
'.rio-drawer-anchor'
|
|
) as HTMLElement;
|
|
|
|
this.shadeElement = element.querySelector(
|
|
'.rio-drawer-shade'
|
|
) as HTMLElement;
|
|
|
|
this.contentOuterContainer = element.querySelector(
|
|
'.rio-drawer-content-outer'
|
|
) as HTMLElement;
|
|
|
|
this.contentInnerContainer = element.querySelector(
|
|
'.rio-drawer-content-inner'
|
|
) as HTMLElement;
|
|
|
|
// Connect to events
|
|
this.addDragHandler({
|
|
element: element,
|
|
onStart: this.beginDrag.bind(this),
|
|
onMove: this.dragMove.bind(this),
|
|
onEnd: this.endDrag.bind(this),
|
|
// Let things like Buttons and TextInputs consume mouse events
|
|
capturing: false,
|
|
});
|
|
|
|
return element;
|
|
}
|
|
|
|
updateElement(
|
|
deltaState: DrawerState,
|
|
latentComponents: Set<ComponentBase>
|
|
): void {
|
|
super.updateElement(deltaState, latentComponents);
|
|
|
|
// Update the children
|
|
this.replaceOnlyChild(
|
|
latentComponents,
|
|
deltaState.anchor,
|
|
this.anchorContainer
|
|
);
|
|
this.replaceOnlyChild(
|
|
latentComponents,
|
|
deltaState.content,
|
|
this.contentInnerContainer
|
|
);
|
|
|
|
// Assign the correct class for the side
|
|
if (deltaState.side !== undefined) {
|
|
this.element.classList.remove(
|
|
'rio-drawer-left',
|
|
'rio-drawer-right',
|
|
'rio-drawer-top',
|
|
'rio-drawer-bottom'
|
|
);
|
|
this.element.classList.add(`rio-drawer-${deltaState.side}`);
|
|
}
|
|
|
|
// Colorize
|
|
if (deltaState.color !== undefined) {
|
|
applySwitcheroo(this.contentOuterContainer, deltaState.color);
|
|
}
|
|
|
|
// Open?
|
|
//
|
|
// Assign the changes directly to the state. This is necessary, because
|
|
// the subsequent call to `_updateCss` uses the state to derive what the
|
|
// CSS should be. Having values stored in the delta state rather than
|
|
// actual state would ignore them.
|
|
if (deltaState.is_open === true) {
|
|
this.openFraction = 1;
|
|
this.state.is_open = true;
|
|
} else if (deltaState.is_open === false) {
|
|
this.openFraction = 0;
|
|
this.state.is_open = false;
|
|
}
|
|
|
|
// Make sure the CSS matches the state
|
|
if (this.isFirstUpdate) {
|
|
this._disableTransition();
|
|
} else {
|
|
this._enableTransition();
|
|
}
|
|
this._updateCss();
|
|
|
|
// Not the first update anymore
|
|
this.isFirstUpdate = false;
|
|
}
|
|
|
|
_updateCss() {
|
|
// Account for the side of the drawer
|
|
let axis =
|
|
this.state.side === 'left' || this.state.side === 'right'
|
|
? 'X'
|
|
: 'Y';
|
|
|
|
let negate =
|
|
this.state.side === 'right' || this.state.side === 'bottom'
|
|
? '+'
|
|
: '-';
|
|
|
|
// Move the drawer far enough to hide the shadow
|
|
let closedFraction = 1 - this.openFraction;
|
|
this.contentOuterContainer.style.transform = `translate${axis}(calc(0rem ${negate} ${
|
|
closedFraction * 100
|
|
}% ${negate} ${closedFraction}rem))`;
|
|
|
|
// Throw some shade, if modal
|
|
if (this.state.is_modal) {
|
|
let shadeFraction = this.openFraction * 0.5;
|
|
this.shadeElement.style.backgroundColor = `rgba(0, 0, 0, ${shadeFraction})`;
|
|
this.shadeElement.style.pointerEvents = this.state.is_open
|
|
? 'auto'
|
|
: 'none';
|
|
} else {
|
|
this.shadeElement.style.backgroundColor = 'rgba(0, 0, 0, 0)';
|
|
this.shadeElement.style.pointerEvents = 'none';
|
|
}
|
|
|
|
// Update the class
|
|
let element = this.element;
|
|
if (this.openFraction > 0.5) {
|
|
element.classList.add('rio-drawer-open');
|
|
} else {
|
|
element.classList.remove('rio-drawer-open');
|
|
}
|
|
}
|
|
|
|
_enableTransition(): void {
|
|
// Remove the class and flush the style changes
|
|
let element = this.element;
|
|
element.classList.remove('rio-drawer-no-transition');
|
|
commitCss(element);
|
|
}
|
|
|
|
_disableTransition(): void {
|
|
// Add the class and flush the style changes
|
|
let element = this.element;
|
|
element.classList.add('rio-drawer-no-transition');
|
|
commitCss(element);
|
|
}
|
|
|
|
openDrawer(): void {
|
|
this.openFraction = 1;
|
|
this._enableTransition();
|
|
this._updateCss();
|
|
|
|
// Notify the backend
|
|
if (!this.state.is_open) {
|
|
this.setStateAndNotifyBackend({
|
|
is_open: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
closeDrawer(): void {
|
|
this.openFraction = 0;
|
|
this._enableTransition();
|
|
this._updateCss();
|
|
|
|
// Notify the backend
|
|
if (this.state.is_open) {
|
|
this.setStateAndNotifyBackend({
|
|
is_open: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
beginDrag(event: MouseEvent): boolean {
|
|
// If the drawer isn't user-openable, ignore the click
|
|
if (!this.state.is_user_openable) {
|
|
return false;
|
|
}
|
|
|
|
// Only care about left clicks
|
|
if (event.button !== 0) {
|
|
return false;
|
|
}
|
|
|
|
// Find the location of the drawer
|
|
//
|
|
// If the click was outside of the anchor element, ignore it
|
|
let drawerRect = this.element.getBoundingClientRect();
|
|
|
|
// Account for the side of the drawer
|
|
const handleSizeIfClosed = 2.0 * pixelsPerRem;
|
|
let relevantClickCoordinate, thresholdMin, thresholdMax;
|
|
|
|
if (this.state.side === 'left') {
|
|
relevantClickCoordinate = event.clientX;
|
|
thresholdMin = drawerRect.left;
|
|
thresholdMax = Math.max(
|
|
drawerRect.left + handleSizeIfClosed,
|
|
drawerRect.left +
|
|
this.contentOuterContainer.scrollWidth * this.openFraction
|
|
);
|
|
} else if (this.state.side === 'right') {
|
|
relevantClickCoordinate = event.clientX;
|
|
thresholdMin = Math.min(
|
|
drawerRect.right - handleSizeIfClosed,
|
|
drawerRect.right -
|
|
this.contentOuterContainer.scrollWidth * this.openFraction
|
|
);
|
|
thresholdMax = drawerRect.right;
|
|
} else if (this.state.side === 'top') {
|
|
relevantClickCoordinate = event.clientY;
|
|
thresholdMin = drawerRect.top;
|
|
thresholdMax = Math.max(
|
|
drawerRect.top + handleSizeIfClosed,
|
|
drawerRect.top +
|
|
this.contentOuterContainer.scrollHeight * this.openFraction
|
|
);
|
|
} else if (this.state.side === 'bottom') {
|
|
relevantClickCoordinate = event.clientY;
|
|
thresholdMin = Math.min(
|
|
drawerRect.bottom - handleSizeIfClosed,
|
|
drawerRect.bottom -
|
|
this.contentOuterContainer.scrollHeight * this.openFraction
|
|
);
|
|
thresholdMax = drawerRect.bottom;
|
|
}
|
|
|
|
// The drawer was clicked. It is being dragged now
|
|
if (
|
|
thresholdMin < relevantClickCoordinate &&
|
|
relevantClickCoordinate < thresholdMax
|
|
) {
|
|
this.openFractionAtDragStart = this.openFraction;
|
|
this.dragStartedAt = relevantClickCoordinate;
|
|
event.stopPropagation();
|
|
return true;
|
|
}
|
|
|
|
// The anchor was clicked. Collapse the drawer if modal
|
|
else if (this.state.is_modal) {
|
|
this.closeDrawer();
|
|
event.stopPropagation();
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
dragMove(event: MouseEvent) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Account for the side of the drawer
|
|
let relevantCoordinate, drawerSize;
|
|
|
|
if (this.state.side === 'left' || this.state.side === 'right') {
|
|
relevantCoordinate = event.clientX;
|
|
drawerSize = this.contentOuterContainer.scrollWidth;
|
|
} else {
|
|
relevantCoordinate = event.clientY;
|
|
drawerSize = this.contentOuterContainer.scrollHeight;
|
|
}
|
|
|
|
let negate =
|
|
this.state.side === 'right' || this.state.side === 'bottom'
|
|
? -1
|
|
: 1;
|
|
|
|
// Calculate the fraction the drawer is open
|
|
this.openFraction =
|
|
this.openFractionAtDragStart +
|
|
((relevantCoordinate - this.dragStartedAt) / drawerSize) * negate;
|
|
|
|
this.openFraction = Math.max(0, Math.min(1, this.openFraction));
|
|
|
|
// Update the drawer
|
|
this._disableTransition();
|
|
this._updateCss();
|
|
}
|
|
|
|
endDrag(event: MouseEvent): void {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
// Snap to fully open or fully closed
|
|
let threshold = this.openFractionAtDragStart > 0.5 ? 0.75 : 0.25;
|
|
|
|
if (this.openFraction > threshold) {
|
|
this.openDrawer();
|
|
} else {
|
|
this.closeDrawer();
|
|
}
|
|
}
|
|
}
|