1
0
Fork 0
mirror of synced 2024-06-14 00:14:39 +12:00
budibase/packages/client/src/components/app/Layout.svelte

543 lines
12 KiB
Svelte

<script>
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { Heading, Icon } from "@budibase/bbui"
import { FieldTypes } from "constants"
import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core"
import FreeLogo from "../FreeLogo.svelte"
import licensing from "../../licensing"
const sdk = getContext("sdk")
const {
routeStore,
styleable,
linkable,
builderStore,
currentRole,
environmentStore,
} = sdk
const component = getContext("component")
const context = getContext("context")
// 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
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)
$: validLinks = getValidLinks(links, $currentRole)
$: 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
)
// Scroll navigation into view if selected
$: {
if (
$builderStore.inBuilder &&
$builderStore.selectedComponentId === "navigation"
) {
const node = document.getElementsByClassName("nav-wrapper")?.[0]
if (node) {
node.style.scrollMargin = "100px"
node.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "start",
})
}
}
}
const getValidLinks = (allLinks, role) => {
// Strip links missing required info
let validLinks = (allLinks || []).filter(link => link.text && link.url)
// Filter to only links allowed by the current role
const priority = RoleUtils.getRolePriority(role)
return validLinks.filter(link => {
return !link.roleId || RoleUtils.getRolePriority(link.roleId) <= priority
})
}
const isInternal = url => {
return url.startsWith("/")
}
const ensureExternal = url => {
return !url.startsWith("http") ? `http://${url}` : url
}
const close = () => {
mobileOpen = false
}
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
}
</script>
<div
class="layout layout--{typeClass}"
use:styleable={$component.styles}
class:desktop={!mobile}
class:mobile={!!mobile}
>
{#if typeClass !== "none"}
<div
class="interactive component navigation"
data-id="navigation"
data-name="Navigation"
data-icon="Link"
>
<div
class="nav-wrapper"
class:sticky
class:hidden={$routeStore.queryParams?.peek}
class:clickable={$builderStore.inBuilder}
on:click={$builderStore.inBuilder
? builderStore.actions.clickNav
: null}
style={navStyle}
>
<div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header">
{#if validLinks?.length}
<div class="burger">
<Icon
hoverable
name="ShowMenu"
on:click={() => (mobileOpen = !mobileOpen)}
/>
</div>
{/if}
<div class="logo">
{#if !hideLogo}
<img
src={logoUrl || "https://i.imgur.com/Xhdt1YP.png"}
alt={title}
/>
{/if}
{#if !hideTitle && title}
<Heading size="S">{title}</Heading>
{/if}
</div>
<div class="portal">
<Icon hoverable name="Apps" on:click={navigateToPortal} />
</div>
</div>
<div
class="mobile-click-handler"
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if validLinks?.length}
<div class="links" class:visible={mobileOpen}>
{#each validLinks as { text, url }}
{#if isInternal(url)}
<a
class={FieldTypes.LINK}
href={url}
use:linkable
on:click={close}
use:active={url}
>
{text}
</a>
{:else}
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{/each}
<div class="close">
<Icon
hoverable
name="Close"
on:click={() => (mobileOpen = false)}
/>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
{#if !$builderStore.inBuilder && licensing.logoEnabled() && $environmentStore.cloud}
<FreeLogo />
{/if}
<div class="main-wrapper">
<div class="main size--{pageWidthClass}">
<slot />
</div>
</div>
</div>
<style>
/* Main components */
.component {
display: contents;
}
.layout {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
height: 100%;
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;
}
.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);
}
.main-wrapper {
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
flex: 1 1 auto;
z-index: 1;
border-top: 1px solid var(--spectrum-global-color-gray-300);
}
.layout--none .main-wrapper {
border-top: none;
}
.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);
}
.link {
opacity: 0.75;
color: var(--navTextColor);
font-size: var(--spectrum-global-dimension-font-size-200);
font-weight: 600;
transition: color 130ms ease-out;
}
.link.active {
opacity: 1;
}
.link:hover {
opacity: 1;
text-decoration: underline;
text-underline-position: under;
}
.close {
display: none;
position: absolute;
top: var(--spacing-xl);
right: var(--spacing-xl);
}
.mobile-click-handler {
display: none;
}
/* Desktop nav overrides */
.desktop.layout--left {
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);
}
/* 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>