budibase/packages/client/src/components/app/Layout.svelte

669 lines
16 KiB
Svelte

<script>
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { Constants } from "@budibase/frontend-core"
import NavItem from "./NavItem.svelte"
const sdk = getContext("sdk")
const {
routeStore,
roleStore,
linkable,
builderStore,
sidePanelStore,
appStore,
} = sdk
const context = getContext("context")
const navStateStore = writable({})
// Legacy props which must remain unchanged for backwards compatibility
export let title
export let hideTitle = false
export let logoUrl
export let hideLogo = false
export let navigation = "Top"
export let sticky = false
export let links
export let width = "Large"
// New props from new design UI
export let navBackground
export let navTextColor
export let navWidth
export let pageWidth
export let logoLinkUrl
export let openLogoLinkInNewTab
export let textAlign
export let embedded = false
const NavigationClasses = {
Top: "top",
Left: "left",
None: "none",
}
const WidthClasses = {
Max: "max",
Large: "l",
Medium: "m",
Small: "s",
"Extra small": "xs",
}
let mobileOpen = false
// Set some layout context. This isn't used in bindings but can be used
// determine things about the current app layout.
$: mobile = $context.device.mobile
const store = writable({ headerHeight: 0 })
$: store.set({
screenXOffset: getScreenXOffset(navigation, mobile),
screenYOffset: getScreenYOffset(navigation, mobile),
})
setContext("layout", store)
$: enrichedNavItems = enrichNavItems(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
$: navStyle = getNavStyle(
navBackground,
navTextColor,
$context.device.width,
$context.device.height
)
$: autoCloseSidePanel =
!$builderStore.inBuilder &&
$sidePanelStore.open &&
$sidePanelStore.clickOutsideToClose
$: screenId = $builderStore.inBuilder
? `${$builderStore.screen?._id}-screen`
: "screen"
$: navigationId = $builderStore.inBuilder
? `${$builderStore.screen?._id}-navigation`
: "navigation"
// Scroll navigation into view if selected.
// Memoize into a primitive to avoid spamming this whenever builder store
// changes.
$: selected =
$builderStore.inBuilder &&
$builderStore.selectedComponentId?.endsWith("-navigation")
$: {
if (selected) {
const node = document.getElementsByClassName("nav-wrapper")?.[0]
if (node) {
node.style.scrollMargin = "100px"
node.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "start",
})
}
}
}
const enrichNavItem = navItem => {
const internalLink = isInternal(navItem.url)
return {
...navItem,
internalLink,
url: internalLink ? navItem.url : ensureExternal(navItem.url),
}
}
const enrichNavItems = (navItems, userRoleHierarchy) => {
if (!navItems?.length) {
return []
}
return navItems
.filter(navItem => {
// Strip nav items without text
if (!navItem.text) {
return false
}
// Strip out links without URLs
if (navItem.type !== "sublinks" && !navItem.url) {
return false
}
// Filter to only links allowed by the current role
const role = navItem.roleId || Constants.Roles.BASIC
return userRoleHierarchy?.find(roleId => roleId === role)
})
.map(navItem => {
const enrichedNavItem = enrichNavItem(navItem)
if (navItem.type === "sublinks" && navItem.subLinks?.length) {
enrichedNavItem.subLinks = navItem.subLinks
.filter(subLink => subLink.text && subLink.url)
.map(enrichNavItem)
}
return enrichedNavItem
})
}
const isInternal = url => {
return url?.startsWith("/")
}
const ensureExternal = url => {
if (!url?.length) {
return url
}
return !url.startsWith("http") ? `http://${url}` : url
}
const navigateToPortal = () => {
if ($builderStore.inBuilder) return
window.location.href = "/builder/apps"
}
const getScreenXOffset = (navigation, mobile) => {
if (navigation !== "Left") {
return 0
}
return mobile ? "0px" : "250px"
}
const getScreenYOffset = (navigation, mobile) => {
if (mobile) {
return !navigation || navigation === "None" ? 0 : "61px"
} else {
return navigation === "Top" ? "137px" : "0px"
}
}
const getNavStyle = (backgroundColor, textColor, width, height) => {
let style = `--width:${width}px; --height:${height}px;`
if (backgroundColor) {
style += `--navBackground:${backgroundColor};`
}
if (textColor) {
style += `--navTextColor:${textColor};`
}
return style
}
const getSanitizedUrl = (url, openInNewTab) => {
if (!isInternal(url)) {
return ensureExternal(url)
}
if (openInNewTab) {
return `#${url}`
}
return url
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="component layout layout--{typeClass}"
class:desktop={!mobile}
class:mobile={!!mobile}
data-id={screenId}
data-name="Screen"
data-icon="WebPage"
>
<div class="screen-wrapper layout-body">
{#if typeClass !== "none"}
<div
class="interactive component {navigationId}"
data-id={navigationId}
data-name="Navigation"
data-icon="Visibility"
>
<div
class="nav-wrapper {navigationId}-dom"
class:sticky
class:hidden={$routeStore.queryParams?.peek}
class:clickable={$builderStore.inBuilder}
on:click={$builderStore.inBuilder
? builderStore.actions.selectComponent(navigationId)
: null}
style={navStyle}
>
<div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header">
{#if enrichedNavItems.length}
<div class="burger">
<Icon
hoverable
name="ShowMenu"
on:click={() => (mobileOpen = !mobileOpen)}
/>
</div>
{/if}
<div class="logo">
{#if !hideLogo}
{#if logoLinkUrl && isInternal(logoLinkUrl) && !openLogoLinkInNewTab}
<a
href={getSanitizedUrl(logoLinkUrl, openLogoLinkInNewTab)}
use:linkable
>
<img src={logoUrl || "/builder/bblogo.png"} alt={title} />
</a>
{:else if logoLinkUrl}
<a
target={openLogoLinkInNewTab ? "_blank" : "_self"}
href={getSanitizedUrl(logoLinkUrl, openLogoLinkInNewTab)}
>
<img src={logoUrl || "/builder/bblogo.png"} alt={title} />
</a>
{:else}
<img src={logoUrl || "/builder/bblogo.png"} alt={title} />
{/if}
{/if}
{#if !hideTitle && title}
<Heading size="S" {textAlign}>{title}</Heading>
{/if}
</div>
{#if !embedded}
<div class="portal">
<Icon
hoverable
name="Apps"
on:click={navigateToPortal}
disabled={$appStore.isDevApp}
/>
</div>
{/if}
</div>
<div
class="mobile-click-handler"
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if enrichedNavItems.length}
<div class="links" class:visible={mobileOpen}>
{#each enrichedNavItems as navItem}
<NavItem
type={navItem.type}
text={navItem.text}
url={navItem.url}
subLinks={navItem.subLinks}
internalLink={navItem.internalLink}
on:clickLink={() => (mobileOpen = false)}
leftNav={navigation === "Left"}
{mobile}
{navStateStore}
/>
{/each}
<div class="close">
<Icon
hoverable
name="Close"
on:click={() => (mobileOpen = false)}
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<div
class="main-wrapper"
on:click={() => {
if ($builderStore.inBuilder) {
builderStore.actions.selectComponent(screenId)
}
}}
>
<div class="main size--{pageWidthClass}">
<slot />
</div>
</div>
</div>
<div
id="side-panel-container"
class:open={$sidePanelStore.open}
use:clickOutside={{
callback: autoCloseSidePanel ? sidePanelStore.actions.close : null,
allowedType: "mousedown",
}}
class:builder={$builderStore.inBuilder}
>
<div class="side-panel-header">
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={sidePanelStore.actions.close}
/>
</div>
</div>
</div>
<style>
/* Main components */
.layout {
height: 100%;
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
z-index: 1;
overflow: hidden;
position: relative;
}
.component {
display: contents;
}
.layout-body {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
position: relative;
background: var(--spectrum-alias-background-color-secondary);
}
.nav-wrapper {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
background: var(--navBackground);
z-index: 2;
}
.nav-wrapper.clickable {
cursor: pointer;
}
.nav-wrapper.clickable .nav {
pointer-events: none;
}
.nav-wrapper.hidden {
display: none;
}
.layout--top .nav-wrapper.sticky {
position: sticky;
top: 0;
left: 0;
}
.layout--top .nav-wrapper {
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
}
.layout--left .nav-wrapper {
border-right: 1px solid var(--spectrum-global-color-gray-300);
}
.nav {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 24px 32px 20px 32px;
max-width: 100%;
gap: var(--spacing-xl);
}
.nav :global(.spectrum-Icon) {
color: var(--navTextColor);
opacity: 0.75;
}
.nav :global(.spectrum-Icon:hover) {
color: var(--navTextColor);
opacity: 1;
}
.nav :global(h1) {
color: var(--navTextColor);
}
.nav-header {
flex: 0 0 auto;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: var(--spacing-xl);
}
#side-panel-container {
max-width: calc(100vw - 40px);
background: var(--spectrum-global-color-gray-50);
z-index: 3;
padding: var(--spacing-xl);
display: flex;
flex-direction: column;
gap: 30px;
overflow-y: auto;
overflow-x: hidden;
transition: transform 130ms ease-out;
position: absolute;
width: 400px;
right: 0;
transform: translateX(100%);
height: 100%;
}
#side-panel-container.builder {
transform: translateX(0);
opacity: 0;
pointer-events: none;
}
#side-panel-container.open {
transform: translateX(0);
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
}
#side-panel-container.builder.open {
opacity: 1;
pointer-events: all;
}
.side-panel-header {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.main-wrapper {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
flex: 1 1 auto;
z-index: 1;
}
.main {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
max-width: 100%;
position: relative;
padding: 32px;
}
.main.size--max {
padding: 0;
}
.layout--none .main {
padding: 0;
}
.size--xs {
width: 400px;
}
.size--s {
width: 800px;
}
.size--m {
width: 1100px;
}
.size--l {
width: 1400px;
}
.size--max {
width: 100%;
}
/* Nav components */
.burger {
display: none;
}
.logo {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
flex: 1 1 auto;
}
.logo img {
height: 36px;
}
.logo :global(h1) {
font-weight: 600;
flex: 1 1 auto;
width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal {
display: grid;
place-items: center;
}
.links {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
.close {
display: none;
position: absolute;
top: var(--spacing-xl);
right: var(--spacing-xl);
}
.mobile-click-handler {
display: none;
}
/* Desktop nav overrides */
.desktop.layout--left .layout-body {
flex-direction: row;
overflow: hidden;
}
.desktop.layout--left .nav-wrapper {
border-bottom: none;
}
.desktop.layout--left .main-wrapper {
height: 100%;
overflow: auto;
}
.desktop.layout--left .links {
overflow-y: auto;
}
.desktop .nav--left {
width: 250px;
padding: var(--spacing-xl);
}
.desktop .nav--left .links {
margin-top: var(--spacing-m);
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.desktop .nav--left .link {
font-size: var(--spectrum-global-dimension-font-size-150);
}
/* Mobile nav overrides */
.mobile .nav-wrapper {
position: sticky;
top: 0;
left: 0;
box-shadow: 0 0 8px -1px rgba(0, 0, 0, 0.075);
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
border-right: none;
}
/* Show close button in drawer */
.mobile .close {
display: block;
}
/* Force standard top bar */
.mobile .nav {
padding: var(--spacing-m) 16px;
}
.mobile .burger {
display: grid;
place-items: center;
}
.mobile .logo {
flex: 0 0 auto;
}
.mobile .logo :global(h1) {
display: none;
}
/* Reduce padding */
.mobile:not(.layout--none) .main {
padding: 16px;
}
.mobile .main.size--max {
padding: 0;
}
/* Transform links into drawer */
.mobile .links {
margin-top: 0;
position: absolute;
top: 0;
left: -250px;
transform: translateX(0);
width: 250px;
transition: transform 0.26s ease-in-out, opacity 0.26s ease-in-out;
height: var(--height);
opacity: 0;
background: var(--navBackground);
z-index: 999;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: var(--spacing-xl);
overflow-y: auto;
}
.mobile .link {
width: calc(100% - 30px);
font-size: 120%;
}
.mobile .links.visible {
opacity: 1;
transform: translateX(250px);
box-shadow: 0 0 80px 20px rgba(0, 0, 0, 0.3);
}
.mobile .mobile-click-handler.visible {
position: absolute;
display: block;
top: 0;
left: 0;
width: var(--width);
height: var(--height);
z-index: 998;
}
/* Print styles */
@media print {
.layout,
.main-wrapper {
overflow: visible !important;
}
.nav-wrapper {
display: none !important;
}
.layout {
flex-direction: column !important;
justify-content: flex-start !important;
align-items: stretch !important;
}
}
</style>