rewrite draggable drawers

This commit is contained in:
SrGooglo 2024-09-11 18:09:44 +00:00
parent ddbdcab831
commit d5c6a40208
3 changed files with 115 additions and 541 deletions

View File

@ -105,6 +105,7 @@
"rxjs": "^7.5.5", "rxjs": "^7.5.5",
"store": "^2.0.12", "store": "^2.0.12",
"ua-parser-js": "^1.0.36", "ua-parser-js": "^1.0.36",
"vaul": "^0.9.2",
"vite": "^5.2.11" "vite": "^5.2.11"
}, },
"devDependencies": { "devDependencies": {
@ -115,4 +116,4 @@
"express": "^4.17.1", "express": "^4.17.1",
"typescript": "^4.3.5" "typescript": "^4.3.5"
} }
} }

View File

@ -1,16 +1,9 @@
// © Jack Hanford https://github.com/hanford/react-drag-drawer import React from "react"
import React, { Component } from "react" import { Drawer } from "vaul"
import { createPortal } from "react-dom"
import { Motion, spring, presets } from "react-motion"
import classnames from "classnames"
import PropTypes from "prop-types"
import Observer from "react-intersection-observer"
import { css } from "@emotion/css"
import "./index.less" import "./index.less"
// TODO: Finish me pleassse export class DraggableDrawerController extends React.Component {
export class DraggableDrawerController extends Component {
constructor(props) { constructor(props) {
super(props) super(props)
@ -20,7 +13,7 @@ export class DraggableDrawerController extends Component {
} }
this.state = { this.state = {
drawers: [] drawers: [],
} }
} }
@ -28,38 +21,59 @@ export class DraggableDrawerController extends Component {
app.layout.draggable = this.interface app.layout.draggable = this.interface
} }
open = (id, render, options = {}) => { async handleDrawerOnClosed(drawer) {
this.setState({ if (!drawer) {
drawers: [ return false
...this.state.drawers, }
{
id: id, if (typeof drawer.options.onClosed === "function") {
locked: options.defaultLocked ?? false, await drawer.options.onClosed()
render: <DraggableDrawer }
onRequestClose={this.close.bind(this, id)}
close={this.close.bind(this, id)} this.destroy(drawer.id)
open={true}
destroyOnClose={true}
{...options.props ?? {}}
>
{React.createElement(render)}
</DraggableDrawer>,
}
],
})
} }
close = (id) => { open = (id, render, options = {}) => {
let drawerObj = {
id: id,
render: render,
options: options
}
const win = app.cores.window_mng.render(
id,
<DraggableDrawer
onClosed={() => this.handleDrawerOnClosed(drawerObj)}
>
{
React.createElement(render, {
...options.componentProps,
})
}
</DraggableDrawer>
)
drawerObj.winId = win.id
this.setState({
drawers: [...this.state.drawers, drawerObj],
})
return true
}
destroy = (id) => {
const drawerIndex = this.state.drawers.findIndex((drawer) => drawer.id === id) const drawerIndex = this.state.drawers.findIndex((drawer) => drawer.id === id)
if (drawerIndex === -1) { if (drawerIndex === -1) {
console.error("Drawer not found") console.error(`Drawer [${id}] not found`)
return false return false
} }
const drawer = this.state.drawers[drawerIndex] const drawer = this.state.drawers[drawerIndex]
if (drawer.locked === true){ if (drawer.locked === true) {
console.error(`Drawer [${drawer.id}] is locked`)
return false return false
} }
@ -68,397 +82,58 @@ export class DraggableDrawerController extends Component {
drawers.splice(drawerIndex, 1) drawers.splice(drawerIndex, 1)
this.setState({ drawers: drawers }) this.setState({ drawers: drawers })
app.cores.window_mng.close(drawer.winId)
} }
render() { render() {
return this.state.drawers.map((drawer) => drawer.render) return null
} }
} }
export default class DraggableDrawer extends Component { export const DraggableDrawer = (props) => {
static propTypes = { const [isOpen, setIsOpen] = React.useState(true)
open: PropTypes.bool.isRequired,
children: PropTypes.oneOfType([
PropTypes.object,
PropTypes.array,
PropTypes.element
]),
onRequestClose: PropTypes.func,
onDrag: PropTypes.func,
onOpen: PropTypes.func,
inViewportChange: PropTypes.func,
allowClose: PropTypes.bool,
notifyWillClose: PropTypes.func,
modalElementClass: PropTypes.oneOfType([
PropTypes.object,
PropTypes.string
]),
containerOpacity: PropTypes.number,
getContainerRef: PropTypes.func,
getModalRef: PropTypes.func
}
static defaultProps = { async function handleOnOpenChanged(to) {
notifyWillClose: () => { }, if (to === true) {
onOpen: () => { }, return to
onDrag: () => { },
inViewportChange: () => { },
onRequestClose: () => { },
getContainerRef: () => { },
getModalRef: () => { },
containerOpacity: 0.6,
parentElement: document.body,
allowClose: true,
dontApplyListeners: false,
modalElementClass: ""
}
state = {
ignore: false,
onRange: false,
open: this.props.open,
thumb: 0,
start: 0,
position: 0,
touching: false,
listenersAttached: false,
useBackgroundColorValues: null,
}
DESKTOP_MODE = false
ALLOW_DRAWER_TRANSFORM = true
MAX_NEGATIVE_SCROLL = -50
PX_TO_CLOSE_FROM_BOTTOM = 200
interface = {
setBackgroundColorValues: (values) => {
this.setState({ useBackgroundColorValues: values })
},
}
componentDidMount() {
app.currentDragger = this.interface
this.DESKTOP_MODE = !app.isMobile
}
componentWillUnmount() {
delete app.currentDragger
this.removeListeners()
}
componentDidUpdate(prevProps, nextState) {
// in the process of closing the drawer
if (!this.props.open && prevProps.open) {
this.removeListeners()
setTimeout(this.setState({ open: false }), 300)
} }
if (this.drawer) { setIsOpen(false)
this.setNegativeScroll(this.drawer)
}
// in the process of opening the drawer if (typeof props.onClosed === "function") {
if (this.props.open && !prevProps.open) {
this.props.onOpen()
this.setState({ open: true })
}
}
attachListeners = (drawer) => {
const { dontApplyListeners, getModalRef } = this.props
const { listenersAttached } = this.state
// only attach listeners once as this function gets called every re-render
if (!drawer || listenersAttached || dontApplyListeners) return
this.drawer = drawer
getModalRef(drawer)
this.drawer.addEventListener("touchend", this.release)
this.drawer.addEventListener("touchmove", this.drag)
this.drawer.addEventListener("touchstart", this.tap)
let position = 0
this.setState({ listenersAttached: true, position }, () => {
setTimeout(() => { setTimeout(() => {
// trigger reflow so webkit browsers calculate height properly 😔 props.onClosed()
// https://bugs.webkit.org/show_bug.cgi?id=184905 }, 350)
this.drawer.style.display = "none"
void this.drawer.offsetHeight
this.drawer.style.display = ""
}, 300)
})
}
isThumbInDraggerRange = (event) => {
return (event.touches[0].clientY - this.drawer.getBoundingClientRect().top)
}
removeListeners = () => {
if (!this.drawer) {
return false
} }
this.drawer.removeEventListener("touchend", this.release) return to
this.drawer.removeEventListener("touchmove", this.drag)
this.drawer.removeEventListener("touchstart", this.tap)
this.setState({ listenersAttached: false })
} }
tap = (event) => { return <Drawer.Root
const { pageY } = event.touches[0] open={isOpen}
onOpenChange={handleOnOpenChanged}
>
<Drawer.Portal>
<Drawer.Overlay
className="app-drawer-overlay"
/>
if (!this.isThumbInDraggerRange(event)) { <Drawer.Content
return false className="app-drawer-content"
} onInteractOutside={() => {
setIsOpen(false)
const inDraggerArea = !!event.target.closest("#dragger-area")
const start = pageY
// reset NEW_POSITION and MOVING_POSITION
this.NEW_POSITION = 0
this.MOVING_POSITION = 0
this.setState({
ignore: !inDraggerArea,
onRange: this.isThumbInDraggerRange(event),
thumb: start,
start: start,
touching: true
})
}
drag = (event) => {
if (this.state.ignore) {
return false
}
event.preventDefault()
const { thumb, position } = this.state
const { pageY } = event.touches[0]
const movingPosition = pageY
const delta = movingPosition - thumb
const newPosition = position + delta
if (this.ALLOW_DRAWER_TRANSFORM) {
// allow to drag negative scroll
if (newPosition < this.MAX_NEGATIVE_SCROLL) {
return false
}
this.props.onDrag({ newPosition })
this.MOVING_POSITION = movingPosition
this.NEW_POSITION = newPosition
if (this.shouldWeCloseDrawer()) {
this.props.notifyWillClose(true)
} else {
this.props.notifyWillClose(false)
}
this.setState({
thumb: movingPosition,
position: newPosition,
})
}
}
release = () => {
this.setState({ touching: false })
if (this.shouldWeCloseDrawer() && this.state.onRange) {
this.props.onRequestClose(this)
} else {
let newPosition = 0
this.setState({ position: newPosition })
}
}
setNegativeScroll = (element) => {
const size = this.getElementSize()
this.NEGATIVE_SCROLL = size - element.scrollHeight - this.MAX_NEGATIVE_SCROLL
}
hideDrawer = () => {
const { allowClose } = this.props
let defaultPosition = 0
if (allowClose === false) {
// if we aren't going to allow close, let's animate back to the default position
return this.setState({
position: defaultPosition,
thumb: 0,
touching: false
})
}
this.setState({
open: false,
position: defaultPosition,
touching: false
})
// cleanup
this.removeListeners()
}
shouldWeCloseDrawer = () => {
if (this.MOVING_POSITION === 0) {
return false
}
const containerHeight = this.getElementSize()
const closeThreshold = containerHeight - this.PX_TO_CLOSE_FROM_BOTTOM
return (
this.NEW_POSITION >= 0 &&
this.MOVING_POSITION >= closeThreshold
)
}
getDrawerTransform = (value) => {
return { transform: `translate3d(0, ${value}px, 0)` }
}
getElementSize = () => {
return window.innerHeight
}
getPosition() {
const { position } = this.state
return position
}
inViewportChange = (inView) => {
this.props.inViewportChange(inView)
this.ALLOW_DRAWER_TRANSFORM = inView
}
onClickOutside = (event) => {
if (!this.props.allowClose) {
return false
}
// check if is clicking outside main component
if (this.drawer && event.target?.className) {
if (event.target.className.includes("ant-cascader") || event.target.className.includes("ant-select")) {
return false
}
if (!this.drawer.contains(event.target)) {
this.props.onRequestClose(this)
}
}
}
preventDefault = (event) => event.preventDefault()
stopPropagation = (event) => event.stopPropagation()
render() {
const {
containerOpacity,
id,
getContainerRef,
} = this.props
const open = this.state.open && this.props.open
const { touching } = this.state
const springPreset = { damping: 20, stiffness: 300 }
const animationSpring = touching ? springPreset : presets.stiff
const hiddenPosition = this.getElementSize()
const position = this.getPosition(hiddenPosition)
let containerStyle = {
backgroundColor: `rgba(55, 56, 56, ${open ? containerOpacity : 0})`,
"--body-background": this.state.useBackgroundColorValues ? `rgba(${this.state.useBackgroundColorValues}, 1)` : "var(--background-color-primary)",
}
return createPortal(
<Motion
style={{
translate: spring(open ? position : hiddenPosition, animationSpring)
}}
defaultStyle={{
translate: hiddenPosition
}} }}
> >
{({ translate }) => { <Drawer.Handle
return ( className="app-drawer-handle"
<div />
id={id} {
style={containerStyle} React.cloneElement(props.children, {
onMouseDown={this.onClickOutside} close: () => setIsOpen(false),
ref={getContainerRef} })
className={classnames( }
"draggable-drawer", </Drawer.Content>
{ </Drawer.Portal>
["fill-end"]: this.props.fillEnd </Drawer.Root>
} }
)}
>
<Observer
className={HaveWeScrolled}
onChange={this.inViewportChange}
/>
<div
className="draggable-drawer_body"
onClick={this.stopPropagation}
style={{
...this.props.bodyStyle,
...this.getDrawerTransform(translate),
}}
ref={this.attachListeners}
>
<div
className="dragger-area"
id="dragger-area"
dragger
>
<div
className="dragger-indicator"
/>
</div>
<div
className="draggable-drawer_body_background"
/>
<div className="draggable-drawer_content">
{this.props.children}
</div>
</div>
</div>
)
}}
</Motion>,
this.props.parentElement
)
}
}
const HaveWeScrolled = css`
position: absolute;
top: 0;
height: 1px;
width: 100%;
`

View File

@ -1,146 +1,44 @@
@body_border_radius: 24px; .app-drawer-overlay {
position: fixed;
.draggable-drawer { top: 0;
position: fixed; right: 0;
bottom: 0;
left: 0;
top: 0; z-index: 450;
left: 0;
right: 0;
bottom: 0;
display: flex; background-color: rgba(var(--bg_color_1), 0.4);
justify-content: center; backdrop-filter: blur(1px);
}
align-items: center; .app-drawer-content {
position: fixed;
z-index: 50; bottom: 0;
left: 0;
right: 0;
transition: background-color 0.2s linear; z-index: 550;
overflow-y: hidden; display: flex;
overscroll-behavior: none; flex-direction: column;
box-sizing: border-box; height: fit-content;
max-height: 90%;
min-height: 300px;
.draggable-drawer_body_background { max-width: 550px;
box-sizing: border-box;
position: absolute; padding: 20px 10px 10px 10px;
z-index: 50; gap: 10px;
top: 0; border-radius: 24px 24px 0 0;
left: 0;
width: 100%; background-color: var(--background-color-accent);
height: 100%; }
border-top-left-radius: @body_border_radius; .app-drawer-handle {
border-top-right-radius: @body_border_radius; background-color: var(--background-color-contrast);
transition: all 150ms ease-in-out;
background-color: var(--body-background);
opacity: 0.4;
&::after {
content: "";
display: block;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 120px;
z-index: 45;
background-color: var(--body-background);
transform: translateY(95%);
}
}
.draggable-drawer_body {
box-sizing: border-box;
position: absolute;
z-index: 55;
bottom: 0px;
width: 100%;
max-width: 700px;
height: fit-content;
max-height: 90%;
background-color: var(--background-color-primary);
padding: 30px 10px 10px 10px;
border-top-left-radius: @body_border_radius;
border-top-right-radius: @body_border_radius;
.dragger-area {
width: 100%;
height: 50px;
position: absolute;
display: flex;
align-items: flex-start;
justify-content: center;
top: 10px;
left: 0;
z-index: 55;
.dragger-indicator {
background-color: var(--background-color-contrast);
width: 100px;
height: 8px;
border-radius: 8px;
}
}
.draggable-drawer_content {
position: relative;
z-index: 100;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
&::after {
content: "";
display: block;
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 120px;
z-index: 45;
background-color: var(--background-color-primary);
transform: translateY(95%);
}
}
} }