Add modal drawer support with transitions

This commit is contained in:
Andrew Kingston 2024-02-24 13:26:34 +00:00
parent 7484f087bc
commit 395942d8b6
4 changed files with 134 additions and 29 deletions

View File

@ -1,7 +1,52 @@
<script context="module"> <script context="module">
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
// Observe this class name if possible in order to know how to size the
// drawer. If this doesn't exist we'll use a fixed size.
const drawerContainer = "drawer-container"
// Context level stores to keep drawers in sync
const openDrawers = writable([]) const openDrawers = writable([])
const modal = writable(false)
const drawerLeft = writable(null)
const drawerWidth = writable(null)
// Resize observer to keep track of size changes
let observer
// Starts observing the target node to watching to size changes.
// Invoked when the first drawer of a chain is rendered.
const observe = () => {
const target = document.getElementsByClassName(drawerContainer)[0]
if (observer || !target) {
return
}
observer = new ResizeObserver(entries => {
if (!entries?.[0]) {
return
}
const bounds = entries[0].target.getBoundingClientRect()
drawerLeft.set(bounds.left)
drawerWidth.set(bounds.width)
})
observer.observe(target)
// Manually measure once to ensure that we have dimensions for the initial
// paint
const bounds = target.getBoundingClientRect()
drawerLeft.set(bounds.left)
drawerWidth.set(bounds.width)
}
// Stops observing the target node.
// Invoked when the last drawer of a chain is removed.
const unobserve = () => {
if (get(openDrawers).length) {
return
}
observer.disconnect()
observer = null
}
</script> </script>
<script> <script>
@ -9,21 +54,45 @@
import Button from "../Button/Button.svelte" import Button from "../Button/Button.svelte"
import { setContext, createEventDispatcher, onDestroy } from "svelte" import { setContext, createEventDispatcher, onDestroy } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { fade } from "svelte/transition"
export let title export let title
export let headless = false export let headless = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const spacing = 10
let visible = false let visible = false
let drawerId = generate() let drawerId = generate()
$: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1 $: depth = $openDrawers.length - $openDrawers.indexOf(drawerId) - 1
$: style = getStyle(depth, $drawerLeft, $drawerWidth, $modal)
const getStyle = (depth, left, width, modal) => {
let style = `
--scale-factor: ${getScaleFactor(depth)};
--spacing: ${spacing}px;
`
// Most modal styles are handled by class names
if (modal) {
return style
}
// Normal drawers need a few additional styles
left = left ? `${left + width / 2}px` : "20vw"
width = width ? `${width - 2 * spacing}px` : "60vw"
return `
${style}
left: ${left};
width: ${width};
`
}
export function show() { export function show() {
if (visible) { if (visible) {
return return
} }
observe()
visible = true visible = true
dispatch("drawerShow", drawerId) dispatch("drawerShow", drawerId)
openDrawers.update(state => [...state, drawerId]) openDrawers.update(state => [...state, drawerId])
@ -36,12 +105,16 @@
visible = false visible = false
dispatch("drawerHide", drawerId) dispatch("drawerHide", drawerId)
openDrawers.update(state => state.filter(id => id !== drawerId)) openDrawers.update(state => state.filter(id => id !== drawerId))
if (!$openDrawers.length) {
modal.set(false)
}
unobserve()
} }
setContext("drawer-actions", { setContext("drawer", {
hide, hide,
show, show,
headless, modal,
}) })
const easeInOutQuad = x => { const easeInOutQuad = x => {
@ -52,10 +125,14 @@
// transition has a horrible overshoot // transition has a horrible overshoot
const slide = () => { const slide = () => {
return { return {
duration: 360, duration: 260,
css: t => { css: t => {
const translation = 100 - Math.round(easeInOutQuad(t) * 100) const f = easeInOutQuad(t)
return `transform: translateY(${translation}%);` const yOffset = (1 - f) * 200
return `
transform: translateX(-50%) translateY(calc(${yOffset}px + 50% - 1200px * (1 - var(--scale-factor))));
opacity:${f};
`
}, },
} }
} }
@ -75,13 +152,16 @@
</script> </script>
{#if visible} {#if visible}
<Portal target=".drawer-container"> <Portal target=".modal-container">
{#if $modal}
<div transition:fade={{ duration: 260 }} class="underlay" />
{/if}
<div <div
class="drawer" class="drawer"
class:headless class:headless
class:modal={$modal}
transition:slide|local transition:slide|local
style="--scale-factor:{getScaleFactor(depth)}" {style}
class:stacked={depth > 0}
> >
{#if !headless} {#if !headless}
<header> <header>
@ -93,41 +173,55 @@
</header> </header>
{/if} {/if}
<slot name="body" /> <slot name="body" />
{#if !$modal && depth > 0}
<div class="overlay" transition:fade|local={{ duration: 260 }} />
{/if}
</div> </div>
</Portal> </Portal>
{/if} {/if}
<style> <style>
.drawer { .drawer {
--drawer-spacing: 10px;
position: absolute; position: absolute;
left: var(--drawer-spacing); transform: translateX(-50%) scale(var(--scale-factor))
bottom: var(--drawer-spacing); translateY(calc(50% - 800px * (1 - var(--scale-factor))));
transform: translateY(calc(-210% * (1 - var(--scale-factor))))
scale(var(--scale-factor));
width: calc(100% - 2 * var(--drawer-spacing));
background: var(--background); background: var(--background);
border: var(--border-light); border: var(--border-light);
z-index: 3; z-index: 3;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
transition: transform 360ms ease-out; transition: transform 260ms ease-out, bottom 260ms ease-out,
left 260ms ease-out, width 260ms ease-out, height 260ms ease-out;
height: 420px;
bottom: calc(var(--spacing) + 210px);
max-width: calc(100vw - 200px);
max-height: calc(100vh - 200px);
} }
.drawer::after { .drawer.modal {
content: ""; left: 50vw;
position: absolute; bottom: 50vh;
width: 1600px;
height: 800px;
}
.overlay,
.underlay {
top: 0; top: 0;
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: var(--background);
pointer-events: none;
transition: opacity 360ms ease-out; transition: opacity 360ms ease-out;
opacity: calc(10 * (1 - var(--scale-factor))); z-index: 3;
opacity: 0.5;
} }
.drawer.stacked::after { .overlay {
pointer-events: all; position: absolute;
background: var(--background);
}
.underlay {
position: fixed;
background: var(--modal-background, rgba(0, 0, 0, 0.75));
} }
header { header {
@ -138,7 +232,6 @@
padding: var(--spacing-m) var(--spacing-xl); padding: var(--spacing-m) var(--spacing-xl);
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
.text { .text {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -146,7 +239,6 @@
align-items: flex-start; align-items: flex-start;
gap: var(--spacing-xs); gap: var(--spacing-xs);
} }
.buttons { .buttons {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -17,7 +17,7 @@
<style> <style>
.drawer-contents { .drawer-contents {
height: 400px; height: 100%;
overflow-y: auto; overflow-y: auto;
} }
.container { .container {

View File

@ -1,6 +1,6 @@
<script> <script>
import { DrawerContent, ActionButton, Icon } from "@budibase/bbui" import { DrawerContent, ActionButton, Icon } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, getContext, onMount } from "svelte"
import { import {
isValid, isValid,
decodeJSBinding, decodeJSBinding,
@ -37,6 +37,7 @@
export let context = null export let context = null
export let autofocusEditor = false export let autofocusEditor = false
const drawerContext = getContext("drawer")
const Modes = { const Modes = {
Text: "Text", Text: "Text",
JavaScript: "JavaScript", JavaScript: "JavaScript",
@ -49,14 +50,15 @@
let initialValueJS = value?.startsWith?.("{{ js ") let initialValueJS = value?.startsWith?.("{{ js ")
let mode = initialValueJS ? Modes.JavaScript : Modes.Text let mode = initialValueJS ? Modes.JavaScript : Modes.Text
let sidePanel = null let sidePanel = null
let getCaretPosition let getCaretPosition
let insertAtPos let insertAtPos
let jsValue = initialValueJS ? value : null let jsValue = initialValueJS ? value : null
let hbsValue = initialValueJS ? null : value let hbsValue = initialValueJS ? null : value
let targetMode = null let targetMode = null
let expressionResult let expressionResult
let drawerIsModal
$: drawerContext?.modal.subscribe(val => (drawerIsModal = val))
$: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text] $: editorTabs = allowJS ? [Modes.Text, Modes.JavaScript] : [Modes.Text]
$: sideTabs = [SidePanels.Evaluation, SidePanels.Bindings] $: sideTabs = [SidePanels.Evaluation, SidePanels.Bindings]
$: enrichedBindings = enrichBindings(bindings, context) $: enrichedBindings = enrichBindings(bindings, context)
@ -187,6 +189,16 @@
<Icon name={tab} size="S" /> <Icon name={tab} size="S" />
</ActionButton> </ActionButton>
{/each} {/each}
{#if drawerContext}
<ActionButton
size="M"
quiet
selected={drawerIsModal}
on:click={() => drawerContext.modal.set(!drawerIsModal)}
>
<Icon name={drawerIsModal ? "Minimize" : "Maximize"} size="S" />
</ActionButton>
{/if}
</div> </div>
</div> </div>
<div class="editor"> <div class="editor">

View File

@ -35,6 +35,7 @@
align-items: stretch; align-items: stretch;
padding: 9px var(--spacing-m); padding: 9px var(--spacing-m);
position: relative; position: relative;
transition: width 360ms ease-out;
} }
.drawer-container { .drawer-container {
position: absolute; position: absolute;