Merge pull request #13375 from Budibase/nested-nav-links

Nested nav links
This commit is contained in:
Andrew Kingston 2024-04-10 16:24:30 +01:00 committed by GitHub
commit fcd1113494
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 955 additions and 501 deletions

View File

@ -4,6 +4,7 @@
import "@spectrum-css/menu/dist/index-vars.css" import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside" import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte"
export let value = null export let value = null
export let id = null export let id = null
@ -15,8 +16,10 @@
export let getOptionValue = option => option export let getOptionValue = option => option
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
let focus = false let focus = false
let anchor
const selectOption = value => { const selectOption = value => {
dispatch("change", value) dispatch("change", value)
@ -35,11 +38,11 @@
} }
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
class="spectrum-InputGroup" class="spectrum-InputGroup"
class:is-focused={open || focus} class:is-focused={open || focus}
class:is-disabled={disabled} class:is-disabled={disabled}
bind:this={anchor}
> >
<div <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
@ -67,7 +70,7 @@
tabindex="-1" tabindex="-1"
aria-haspopup="true" aria-haspopup="true"
{disabled} {disabled}
on:click={() => (open = true)} on:click={() => (open = !open)}
> >
<svg <svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon" class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon"
@ -77,13 +80,17 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
{#if open} </div>
<div
class="spectrum-Popover spectrum-Popover--bottom is-open" <!-- svelte-ignore a11y-click-events-have-key-events -->
use:clickOutside={() => { <Popover
open = false {anchor}
}} {open}
align="left"
on:close={() => (open = false)}
useAnchorWidth
> >
<div class="popover-content" use:clickOutside={() => (open = false)}>
<ul class="spectrum-Menu" role="listbox"> <ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)} {#if options && Array.isArray(options)}
{#each options as option} {#each options as option}
@ -95,8 +102,7 @@
tabindex="0" tabindex="0"
on:click={() => onPick(getOptionValue(option))} on:click={() => onPick(getOptionValue(option))}
> >
<span class="spectrum-Menu-itemLabel" <span class="spectrum-Menu-itemLabel">{getOptionLabel(option)}</span
>{getOptionLabel(option)}</span
> >
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
@ -110,8 +116,7 @@
{/if} {/if}
</ul> </ul>
</div> </div>
{/if} </Popover>
</div>
<style> <style>
.spectrum-InputGroup { .spectrum-InputGroup {
@ -124,10 +129,13 @@
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }
.spectrum-Popover {
max-height: 240px; /* Popover */
width: 100%; .popover-content {
z-index: 999; display: contents;
top: 100%; }
.popover-content:not(.auto-width) .spectrum-Menu-itemLabel {
width: 0;
flex: 1 1 auto;
} }
</style> </style>

View File

@ -45,7 +45,6 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let button let button
let popover
let component let component
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort) $: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
@ -146,11 +145,11 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover <Popover
anchor={customAnchor ? customAnchor : button} anchor={customAnchor ? customAnchor : button}
align={align || "left"} align={align || "left"}
bind:this={popover}
{open} {open}
on:close={() => (open = false)} on:close={() => (open = false)}
useAnchorWidth={!autoWidth} useAnchorWidth={!autoWidth}
@ -266,16 +265,6 @@
width: 100%; width: 100%;
box-shadow: none; box-shadow: none;
} }
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
.spectrum-Picker-label.auto-width { .spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
} }
@ -356,11 +345,9 @@
.option-extra.icon.field-icon { .option-extra.icon.field-icon {
display: flex; display: flex;
} }
.option-tag { .option-tag {
margin: 0 var(--spacing-m) 0 var(--spacing-m); margin: 0 var(--spacing-m) 0 var(--spacing-m);
} }
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) { .option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px; margin-top: 2px;
} }
@ -374,4 +361,13 @@
.loading--withAutocomplete { .loading--withAutocomplete {
top: calc(34px + var(--spacing-m)); top: calc(34px + var(--spacing-m));
} }
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
</style> </style>

View File

@ -99,10 +99,10 @@
on:keydown={handleEscape} on:keydown={handleEscape}
class="spectrum-Popover is-open" class="spectrum-Popover is-open"
class:customZindex class:customZindex
class:hide-popover={open && !showPopover} class:hidden={!showPopover}
role="presentation" role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};" style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }} transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }}
on:mouseenter on:mouseenter
on:mouseleave on:mouseleave
> >
@ -112,16 +112,17 @@
{/if} {/if}
<style> <style>
.hide-popover {
display: contents;
}
.spectrum-Popover { .spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000); min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300); border-color: var(--spectrum-global-color-gray-300);
overflow: auto; overflow: auto;
transition: opacity 260ms ease-out, transform 260ms ease-out;
}
.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-20px);
} }
.customZindex { .customZindex {
z-index: var(--customZindex) !important; z-index: var(--customZindex) !important;
} }

View File

@ -131,7 +131,7 @@
if (bindings?.length) { if (bindings?.length) {
options.push(SidePanels.Bindings) options.push(SidePanels.Bindings)
} }
if (context) { if (context && Object.keys(context).length > 0) {
options.push(SidePanels.Evaluation) options.push(SidePanels.Evaluation)
} }
if (useSnippets && mode === Modes.JavaScript) { if (useSnippets && mode === Modes.JavaScript) {

View File

@ -78,7 +78,12 @@
{/if} {/if}
</div> </div>
<Drawer bind:this={bindingDrawer} title={title ?? placeholder ?? "Bindings"}> <Drawer
bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"}
on:drawerHide
on:drawerShow
>
<Button cta slot="buttons" on:click={handleClose}>Save</Button> <Button cta slot="buttons" on:click={handleClose}>Save</Button>
<svelte:component <svelte:component
this={panel} this={panel}

View File

@ -14,7 +14,6 @@
export let key export let key
export let nested export let nested
export let max export let max
export let context
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()

View File

@ -126,7 +126,7 @@
<div class="right-content"> <div class="right-content">
<svelte:component <svelte:component
this={listType} this={listType}
anchor={anchors[draggableItem.item._id]} anchor={anchors[draggableItem.id]}
item={draggableItem.item} item={draggableItem.item}
{...listTypeProps} {...listTypeProps}
on:change={onItemChanged} on:change={onItemChanged}

View File

@ -0,0 +1,141 @@
<script>
import { Icon, Popover, RadioGroup } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import SubLinksDrawer from "./SubLinksDrawer.svelte"
import { screenStore } from "stores/builder"
export let anchor
export let navItem
export let bindings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
const typeOptions = [
{ label: "Inline link", value: "link" },
{ label: "Open sub links", value: "sublinks" },
]
let popover
let open = false
let drawerCount = 0
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
// Auto hide the component when another item is selected
$: if (open && $draggable.selected !== navItem.id) {
popover.hide()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === navItem.id && popover) {
popover.show()
open = true
}
const update = setting => async value => {
dispatch("change", {
...navItem,
[setting]: value,
})
}
</script>
<Icon name={navItem.type === "sublinks" ? "Dropdown" : "Link"} size="S" />
<Popover
bind:this={popover}
on:open={() => {
open = true
$draggable.actions.select(navItem.id)
}}
on:close={() => {
open = false
if ($draggable.selected === navItem.id) {
$draggable.actions.select()
}
}}
{anchor}
align="left-outside"
showPopover={drawerCount === 0}
clickOutsideOverride={drawerCount > 0}
maxHeight={600}
offset={18}
>
<div class="settings">
<PropertyControl
label="Nav item"
control={RadioGroup}
value={navItem.type}
onChange={update("type")}
props={{
options: typeOptions,
}}
/>
<PropertyControl
label="Label"
control={DrawerBindableInput}
value={navItem.text}
onChange={update("text")}
{bindings}
props={{
updateOnChange: false,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{#if navItem.type === "sublinks"}
<PropertyControl
label="Sub links"
control={SubLinksDrawer}
value={navItem.subLinks}
onChange={update("subLinks")}
{bindings}
props={{
navItem,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{:else}
<PropertyControl
label="Link"
control={DrawerBindableCombobox}
value={navItem.url}
onChange={update("url")}
{bindings}
props={{
options: urlOptions,
appendBindingsAsOptions: false,
placeholder: null,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{/if}
<PropertyControl
label="Access"
control={RoleSelect}
value={navItem.roleId}
onChange={update("roleId")}
/>
</div>
</Popover>
<style>
.settings {
background: var(--spectrum-alias-background-color-primary);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 8px;
padding: var(--spacing-xl);
}
</style>

View File

@ -1,131 +0,0 @@
<script>
import {
Button,
Icon,
DrawerContent,
Layout,
Input,
Combobox,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
export let links = []
const flipDurationMs = 150
let dragDisabled = true
$: links.forEach(link => {
if (!link.id) {
link.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
const addLink = () => {
links = [...links, {}]
}
const removeLink = id => {
links = links.filter(link => link.id !== id)
}
const updateLinks = e => {
links = e.detail.items
}
const handleFinalize = e => {
updateLinks(e)
dragDisabled = true
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<DrawerContent>
<div class="container">
<Layout noPadding gap="S">
{#if links?.length}
<div
class="links"
use:dndzone={{
items: links,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled,
}}
on:finalize={handleFinalize}
on:consider={updateLinks}
>
{#each links as link (link.id)}
<div class="link" animate:flip={{ duration: flipDurationMs }}>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Input bind:value={link.text} placeholder="Text" />
<Combobox
bind:value={link.url}
placeholder="URL"
options={urlOptions}
/>
<RoleSelect bind:value={link.roleId} placeholder="Minimum role" />
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeLink(link.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<Button secondary icon="Add" on:click={addLink}>Add Link</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.links {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.link {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.link:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.link > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
.handle {
display: grid;
place-items: center;
}
</style>

View File

@ -1,30 +0,0 @@
<script>
import { Button, Drawer } from "@budibase/bbui"
import NavigationLinksDrawer from "./LinksDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { navigationStore } from "stores/builder"
let drawer
let links
const openDrawer = () => {
links = cloneDeep($navigationStore.links || [])
drawer.show()
}
const save = async () => {
let navigation = $navigationStore
navigation.links = links
await navigationStore.save(navigation)
drawer.hide()
}
</script>
<Button cta on:click={openDrawer}>Configure Links</Button>
<Drawer bind:this={drawer} title={"Navigation Links"}>
<svelte:fragment slot="description">
Configure the links in your navigation bar.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<NavigationLinksDrawer slot="body" bind:links />
</Drawer>

View File

@ -0,0 +1,55 @@
<script>
import { runtimeToReadableBinding } from "dataBinding"
import EditNavItemPopover from "./EditNavItemPopover.svelte"
import { Icon } from "@budibase/bbui"
export let item
export let removeNavItem
export let anchor
export let bindings
$: text = runtimeToReadableBinding(bindings, item.text)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditNavItemPopover {anchor} {bindings} navItem={item} on:change />
<div class="field-label">{text}</div>
</div>
<div class="list-item-right">
<Icon
size="S"
name="Close"
hoverable
on:click={e => {
e.stopPropagation()
removeNavItem(item.id)
}}
/>
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-body {
margin-top: 8px;
margin-bottom: 8px;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,118 @@
<script>
import { navigationStore } from "stores/builder"
import DraggableList from "components/design/settings/controls/DraggableList/DraggableList.svelte"
import NavItem from "./NavItem.svelte"
import { generate } from "shortid"
import { getSequentialName } from "helpers/duplicate"
import { Constants } from "@budibase/frontend-core"
export let bindings
$: navItems = enrichNavItems($navigationStore.links)
$: navItemProps = {
removeNavItem,
bindings,
}
const enrichNavItems = links => {
return (links || []).map(link => ({
...link,
id: link.id || generate(),
}))
}
const save = async links => {
await navigationStore.save({ ...$navigationStore, links })
}
const handleNavItemUpdate = async e => {
const newNavItem = e.detail
const newLinks = [...navItems]
const idx = newLinks.findIndex(link => {
return link.id === newNavItem?.id
})
if (idx === -1) {
newLinks.push(newNavItem)
} else {
newLinks[idx] = newNavItem
}
await save(newLinks)
}
const handleListUpdate = async e => {
await save([...e.detail])
}
const addNavItem = async () => {
await save([
...navItems,
{
id: generate(),
text: getSequentialName(navItems, "Nav Item ", x => x.text),
url: "",
roleId: Constants.Roles.BASIC,
type: "link",
},
])
}
const removeNavItem = async id => {
await save(navItems.filter(navItem => navItem.id !== id))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="link-configuration">
{#if navItems.length}
<DraggableList
on:change={handleListUpdate}
on:itemChange={handleNavItemUpdate}
items={navItems}
listItemKey="id"
listType={NavItem}
listTypeProps={navItemProps}
draggable={navItems.length > 1}
/>
{/if}
<div class="list-footer" on:click={addNavItem} class:empty={!navItems.length}>
<div class="add-button">Add nav item</div>
</div>
</div>
<style>
.link-configuration :global(.list-wrap > li:last-child),
.link-configuration :global(.list-wrap) {
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
border-bottom: none;
}
.list-footer {
width: 100%;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
justify-content: center;
border: 1px solid var(--spectrum-alias-border-color-mid);
cursor: pointer;
}
.list-footer.empty {
border-radius: 4px;
}
.list-footer:hover {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
.add-button {
margin: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,142 @@
<script>
import {
ActionButton,
Button,
Icon,
DrawerContent,
Layout,
Input,
Drawer,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let value = []
export let onChange
export let navItem
export let bindings
const flipDurationMs = 150
let drawer
let subLinks = value?.slice() || []
$: count = value?.length ?? 0
$: buttonText = `${count || "No"} sub link${count === 1 ? "" : "s"}`
$: drawerTitle = navItem.text ? `${navItem.text} sub links` : "Sub links"
$: subLinks.forEach(subLink => {
if (!subLink.id) {
subLink.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
const addSubLink = () => {
subLinks = [...subLinks, {}]
}
const removeSubLink = id => {
subLinks = subLinks.filter(link => link.id !== id)
}
const saveSubLinks = () => {
onChange(subLinks)
drawer.hide()
}
const updateSubLinks = e => {
subLinks = e.detail.items
}
</script>
<Drawer bind:this={drawer} title={drawerTitle} on:drawerShow on:drawerHide>
<Button cta slot="buttons" on:click={saveSubLinks}>Save</Button>
<DrawerContent slot="body">
<div class="container">
<Layout noPadding gap="S">
{#if subLinks?.length}
<div
class="subLinks"
use:dndzone={{
items: subLinks,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={updateSubLinks}
on:finalize={updateSubLinks}
>
{#each subLinks as subLink (subLink.id)}
<div class="subLink" animate:flip={{ duration: flipDurationMs }}>
<Icon name="DragHandle" size="XL" />
<Input bind:value={subLink.text} placeholder="Text" />
<DrawerBindableCombobox
value={subLink.url}
on:change={e => (subLink.url = e.detail)}
placeholder="Link"
options={urlOptions}
{bindings}
appendBindingsAsOptions={false}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeSubLink(subLink.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<ActionButton quiet icon="Add" on:click={addSubLink}>
Add link
</ActionButton>
</div>
</Layout>
</div>
</DrawerContent>
</Drawer>
<div class="button">
<ActionButton on:click={drawer.show}>{buttonText}</ActionButton>
</div>
<style>
.button :global(.spectrum-ActionButton) {
width: 100%;
}
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.subLinks {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.subLink {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.subLink:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.subLink > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
</style>

View File

@ -1,62 +1,61 @@
<script> <script>
import LinksEditor from "./LinksEditor.svelte" import NavItemConfiguration from "./NavItemConfiguration.svelte"
import { get } from "svelte/store" import { get } from "svelte/store"
import Panel from "components/design/Panel.svelte" import Panel from "components/design/Panel.svelte"
import { import {
Detail,
Toggle, Toggle,
Body, DetailSummary,
Icon,
ColorPicker,
Input,
Label,
ActionGroup,
ActionButton,
Checkbox, Checkbox,
notifications, notifications,
Select, Select,
Combobox,
} from "@budibase/bbui" } from "@budibase/bbui"
import { import {
themeStore, themeStore,
selectedScreen, selectedScreen,
screenStore, screenStore,
navigationStore, componentStore,
navigationStore as nav,
} from "stores/builder" } from "stores/builder"
import { DefaultAppTheme } from "constants" import { DefaultAppTheme } from "constants"
import BarButtonList from "/src/components/design/settings/controls/BarButtonList.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import BarButtonList from "components/design/settings/controls/BarButtonList.svelte"
import ColorPicker from "components/design/settings/controls/ColorPicker.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import { getBindableProperties } from "dataBinding"
$: alignmentOptions = [ const positionOptions = [
{ value: "Top", barIcon: "PaddingTop" },
{ value: "Left", barIcon: "PaddingLeft" },
]
const alignmentOptions = [
{ value: "Left", barIcon: "TextAlignLeft" }, { value: "Left", barIcon: "TextAlignLeft" },
{ value: "Center", barIcon: "TextAlignCenter" }, { value: "Center", barIcon: "TextAlignCenter" },
{ value: "Right", barIcon: "TextAlignRight" }, { value: "Right", barIcon: "TextAlignRight" },
] ]
const widthOptions = ["Max", "Large", "Medium", "Small"]
$: bindings = getBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
$: screenRouteOptions = $screenStore.screens $: screenRouteOptions = $screenStore.screens
.map(screen => screen.routing?.route) .map(screen => screen.routing?.route)
.filter(x => x != null) .filter(x => x != null)
const updateShowNavigation = async e => { const updateShowNavigation = async show => {
await screenStore.updateSetting( await screenStore.updateSetting(get(selectedScreen), "showNavigation", show)
get(selectedScreen),
"showNavigation",
e.detail
)
} }
const update = async (key, value) => { const update = async (key, value) => {
try { try {
let navigation = $navigationStore let navigation = $nav
navigation[key] = value navigation[key] = value
await navigationStore.save(navigation) await nav.save(navigation)
} catch (error) { } catch (error) {
notifications.error("Error updating navigation settings") notifications.error("Error updating navigation settings")
} }
} }
const updateTextAlign = textAlignValue => {
navigationStore.syncAppNavigation({ textAlign: textAlignValue })
}
</script> </script>
<Panel <Panel
@ -65,215 +64,142 @@
borderLeft borderLeft
wide wide
> >
<div class="generalSection"> <DetailSummary name="General" initiallyShow collapsible={false}>
<div class="subheading"> <PropertyControl
<Detail>General</Detail> control={Toggle}
</div> props={{ text: "Show nav on this screen" }}
<div class="toggle"> onChange={updateShowNavigation}
<Toggle
on:change={updateShowNavigation}
value={$selectedScreen?.showNavigation} value={$selectedScreen?.showNavigation}
/> />
<Body size="S">Show nav on this screen</Body> </DetailSummary>
</div>
</div>
{#if $selectedScreen?.showNavigation} {#if $selectedScreen?.showNavigation}
<div class="divider" /> <DetailSummary name="Customize" initiallyShow collapsible={false}>
<div class="customizeSection"> <NavItemConfiguration {bindings} />
<div class="subheading"> <div class="settings">
<Detail>Customize</Detail> <PropertyControl
</div> label="Position"
<div class="info"> control={BarButtonList}
<Icon name="InfoOutline" size="S" /> onChange={position => update("navigation", position)}
<Body size="S">These settings apply to all screens</Body> value={$nav.navigation}
</div> props={{
<div class="configureLinks"> options: positionOptions,
<LinksEditor /> }}
</div>
<div class="controls">
<div class="label">
<Label size="M">Position</Label>
</div>
<ActionGroup quiet>
<ActionButton
selected={$navigationStore.navigation === "Top"}
quiet={$navigationStore.navigation !== "Top"}
icon="PaddingTop"
on:click={() => update("navigation", "Top")}
/> />
<ActionButton {#if $nav.navigation === "Top"}
selected={$navigationStore.navigation === "Left"} <PropertyControl
quiet={$navigationStore.navigation !== "Left"} label="Sticky header"
icon="PaddingLeft" control={Checkbox}
on:click={() => update("navigation", "Left")} value={$nav.sticky}
onChange={sticky => update("sticky", sticky)}
/> />
</ActionGroup> <PropertyControl
label="Width"
{#if $navigationStore.navigation === "Top"} control={Select}
<div class="label"> onChange={position => update("navWidth", position)}
<Label size="M">Sticky header</Label> value={$nav.navWidth}
</div> props={{
<Checkbox placeholder: null,
value={$navigationStore.sticky} options: widthOptions,
on:change={e => update("sticky", e.detail)} }}
/>
<div class="label">
<Label size="M">Width</Label>
</div>
<Select
options={["Max", "Large", "Medium", "Small"]}
plaveholder={null}
value={$navigationStore.navWidth}
on:change={e => update("navWidth", e.detail)}
/> />
{/if} {/if}
<div class="label"> <PropertyControl
<Label size="M">Show title</Label> label="Show title"
</div> control={Checkbox}
<Checkbox value={!$nav.hideTitle}
value={!$navigationStore.hideTitle} onChange={show => update("hideTitle", !show)}
on:change={e => update("hideTitle", !e.detail)}
/> />
{#if !$navigationStore.hideTitle} {#if !$nav.hideTitle}
<div class="label"> <PropertyControl
<Label size="M">Title</Label> label="Title"
</div> control={DrawerBindableInput}
<Input value={$nav.title}
value={$navigationStore.title} onChange={title => update("title", title)}
on:change={e => update("title", e.detail)} {bindings}
updateOnChange={false} props={{
updateOnChange: false,
}}
/> />
<PropertyControl
<div class="label"> label="Text align"
<Label size="M">Text align</Label> control={BarButtonList}
</div> onChange={align => nav.syncAppNavigation({ textAlign: align })}
<BarButtonList value={$nav.textAlign}
options={alignmentOptions} props={{
value={$navigationStore.textAlign} options: alignmentOptions,
onChange={updateTextAlign} }}
/> />
{/if} {/if}
<div class="label"> <PropertyControl
<Label>Background</Label> label="Background"
</div> control={ColorPicker}
<ColorPicker onChange={color => update("navBackground", color)}
spectrumTheme={$themeStore.theme} value={$nav.navBackground || DefaultAppTheme.navBackground}
value={$navigationStore.navBackground || props={{
DefaultAppTheme.navBackground} spectrumTheme: $themeStore.theme,
on:change={e => update("navBackground", e.detail)} }}
/> />
<div class="label"> <PropertyControl
<Label>Text</Label> label="Text"
</div> control={ColorPicker}
<ColorPicker onChange={color => update("navTextColor", color)}
spectrumTheme={$themeStore.theme} value={$nav.navTextColor || DefaultAppTheme.navTextColor}
value={$navigationStore.navTextColor || DefaultAppTheme.navTextColor} props={{
on:change={e => update("navTextColor", e.detail)} spectrumTheme: $themeStore.theme,
}}
/> />
</div> </div>
</div> </DetailSummary>
<div class="divider" /> <DetailSummary name="Logo" initiallyShow collapsible={false}>
<div class="customizeSection"> <div class="settings">
<div class="subheading"> <PropertyControl
<Detail>Logo</Detail> label="Show logo"
</div> control={Checkbox}
<div class="controls"> value={!$nav.hideLogo}
<div class="label"> onChange={show => update("hideLogo", !show)}
<Label size="M">Show logo</Label>
</div>
<Checkbox
value={!$navigationStore.hideLogo}
on:change={e => update("hideLogo", !e.detail)}
/> />
{#if !$navigationStore.hideLogo} {#if !$nav.hideLogo}
<div class="label"> <PropertyControl
<Label size="M">Logo image URL</Label> label="Logo image URL"
</div> control={DrawerBindableInput}
<Input value={$nav.logoUrl}
value={$navigationStore.logoUrl} onChange={url => update("logoUrl", url)}
on:change={e => update("logoUrl", e.detail)} {bindings}
updateOnChange={false} props={{
updateOnChange: false,
}}
/> />
<div class="label"> <PropertyControl
<Label size="M">Logo link URL</Label> label="Logo link URL"
</div> control={DrawerBindableCombobox}
<Combobox value={$nav.logoLinkUrl}
value={$navigationStore.logoLinkUrl} onChange={url => update("logoLinkUrl", url)}
on:change={e => update("logoLinkUrl", e.detail)} {bindings}
options={screenRouteOptions} props={{
appendBindingsAsOptions: false,
options: screenRouteOptions,
}}
/> />
<div class="label"> <PropertyControl
<Label size="M">New tab</Label> label="New tab"
</div> control={Checkbox}
<Checkbox value={$nav.openLogoLinkInNewTab}
value={!!$navigationStore.openLogoLinkInNewTab} onChange={show => update("openLogoLinkInNewTab", show)}
on:change={e => update("openLogoLinkInNewTab", !!e.detail)}
/> />
{/if} {/if}
</div> </div>
</div> </DetailSummary>
{/if} {/if}
</Panel> </Panel>
<style> <style>
.generalSection { .settings {
padding: 13px 13px 25px;
}
.customizeSection {
padding: 13px 13px 25px;
}
.subheading {
margin-bottom: 10px;
}
.subheading :global(p) {
color: var(--grey-6);
}
.toggle {
display: flex; display: flex;
align-items: center; flex-direction: column;
} justify-content: flex-start;
align-items: stretch;
.divider { gap: 8px;
border-top: 1px solid var(--grey-3);
}
.controls {
position: relative;
display: grid;
grid-template-columns: 90px 1fr;
align-items: start;
transition: background 130ms ease-out, border-color 130ms ease-out;
border-left: 4px solid transparent;
margin: 0 calc(-1 * var(--spacing-xl));
padding: 0 var(--spacing-xl) 0 calc(var(--spacing-xl) - 4px);
gap: 12px;
}
.label {
margin-top: 16px;
transform: translateY(-50%);
}
.info {
background-color: var(--background-alt);
padding: 12px;
display: flex;
border-radius: 4px;
gap: 4px;
margin-bottom: 16px;
}
.info :global(svg) {
margin-right: 5px;
color: var(--spectrum-global-color-gray-600);
}
.configureLinks :global(button) {
margin-bottom: 20px;
width: 100%;
} }
</style> </style>

View File

@ -79,7 +79,8 @@
// for autoscreens, so it's always safe to do this. // for autoscreens, so it's always safe to do this.
await navigationStore.saveLink( await navigationStore.saveLink(
screen.routing.route, screen.routing.route,
capitalise(screen.routing.route.split("/")[1]) capitalise(screen.routing.route.split("/")[1]),
screenAccessRole
) )
} }

View File

@ -42,7 +42,7 @@ export class NavigationStore extends BudiStore {
this.syncAppNavigation(app.navigation) this.syncAppNavigation(app.navigation)
} }
async saveLink(url, title) { async saveLink(url, title, roleId) {
const navigation = get(this.store) const navigation = get(this.store)
let links = [...(navigation?.links ?? [])] let links = [...(navigation?.links ?? [])]
@ -54,6 +54,8 @@ export class NavigationStore extends BudiStore {
links.push({ links.push({
text: title, text: title,
url, url,
type: "link",
roleId,
}) })
await this.save({ await this.save({
...navigation, ...navigation,
@ -67,11 +69,20 @@ export class NavigationStore extends BudiStore {
if (!links?.length) { if (!links?.length) {
return return
} }
// Filter out the URLs to delete
urls = Array.isArray(urls) ? urls : [urls] urls = Array.isArray(urls) ? urls : [urls]
// Filter out top level links pointing to these URLs
links = links.filter(link => !urls.includes(link.url)) links = links.filter(link => !urls.includes(link.url))
// Filter out nested links pointing to these URLs
links.forEach(link => {
if (link.type === "sublinks" && link.subLinks?.length) {
link.subLinks = link.subLinks.filter(
subLink => !urls.includes(subLink.url)
)
}
})
await this.save({ await this.save({
...navigation, ...navigation,
links, links,

View File

@ -50,10 +50,18 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
{ {
url: "/test", url: "/test",
text: "Test", text: "Test",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
}, },
] ]
@ -66,7 +74,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save") .spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {}) .mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/test-url", "Testing") await ctx.test.navigationStore.saveLink("/test-url", "Testing", "BASIC")
expect(saveSpy).toBeCalledWith({ expect(saveSpy).toBeCalledWith({
...INITIAL_NAVIGATION_STATE, ...INITIAL_NAVIGATION_STATE,
@ -75,6 +83,8 @@ describe("Navigation store", () => {
{ {
url: "/test-url", url: "/test-url",
text: "Testing", text: "Testing",
type: "link",
roleId: "BASIC",
}, },
], ],
}) })
@ -87,6 +97,7 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
], ],
})) }))
@ -94,7 +105,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save") .spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {}) .mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/home", "Home") await ctx.test.navigationStore.saveLink("/home", "Home", "BASIC")
expect(saveSpy).not.toHaveBeenCalled() expect(saveSpy).not.toHaveBeenCalled()
}) })
@ -106,14 +117,23 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
{ {
url: "/test", url: "/test",
text: "Test", text: "Test",
type: "link",
}, },
{ {
url: "/last", url: "/last",
text: "Last Link", text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/home",
},
],
}, },
], ],
})) }))
@ -130,6 +150,8 @@ describe("Navigation store", () => {
{ {
text: "Last Link", text: "Last Link",
url: "/last", url: "/last",
type: "sublinks",
subLinks: [],
}, },
], ],
}) })
@ -140,14 +162,17 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
{ {
url: "/test", url: "/test",
text: "Test", text: "Test",
type: "link",
}, },
{ {
url: "/last", url: "/last",
text: "Last Link", text: "Last Link",
type: "link",
}, },
] ]
@ -168,10 +193,12 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
{ {
url: "/last", url: "/last",
text: "Last Link", text: "Last Link",
type: "link",
}, },
], ],
}) })
@ -180,10 +207,7 @@ describe("Navigation store", () => {
it("Should ignore a request to delete if there are no links", async ctx => { it("Should ignore a request to delete if there are no links", async ctx => {
const saveSpy = vi.spyOn(ctx.test.navigationStore, "save") const saveSpy = vi.spyOn(ctx.test.navigationStore, "save")
await ctx.test.navigationStore.deleteLink({ await ctx.test.navigationStore.deleteLink("/some-link")
url: "/some-link",
text: "Some Link",
})
expect(saveSpy).not.toBeCalled() expect(saveSpy).not.toBeCalled()
}) })
@ -201,10 +225,18 @@ describe("Navigation store", () => {
{ {
url: "/home", url: "/home",
text: "Home", text: "Home",
type: "link",
}, },
{ {
url: "/last", url: "/last",
text: "Last Link", text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
}, },
], ],
})) }))
@ -217,6 +249,7 @@ describe("Navigation store", () => {
{ {
url: "/new-link", url: "/new-link",
text: "New Link", text: "New Link",
type: "link",
}, },
], ],
} }

View File

@ -2,9 +2,8 @@
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { Heading, Icon, clickOutside } from "@budibase/bbui" import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants"
import { Constants } from "@budibase/frontend-core" import { Constants } from "@budibase/frontend-core"
import active from "svelte-spa-router/active" import NavItem from "./NavItem.svelte"
const sdk = getContext("sdk") const sdk = getContext("sdk")
const { const {
@ -16,6 +15,7 @@
appStore, appStore,
} = sdk } = sdk
const context = getContext("context") const context = getContext("context")
const navStateStore = writable({})
// Legacy props which must remain unchanged for backwards compatibility // Legacy props which must remain unchanged for backwards compatibility
export let title export let title
@ -63,7 +63,7 @@
}) })
setContext("layout", store) setContext("layout", store)
$: validLinks = getValidLinks(links, $roleStore) $: enrichedNavItems = enrichNavItems(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None $: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large $: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large $: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
@ -101,26 +101,55 @@
} }
} }
const getValidLinks = (allLinks, userRoleHierarchy) => { const enrichNavItem = navItem => {
// Strip links missing required info const internalLink = isInternal(navItem.url)
let validLinks = (allLinks || []).filter(link => link.text && link.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 // Filter to only links allowed by the current role
return validLinks.filter(link => { const role = navItem.roleId || Constants.Roles.BASIC
const role = link.roleId || Constants.Roles.BASIC
return userRoleHierarchy?.find(roleId => roleId === role) 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 => { const isInternal = url => {
return url.startsWith("/") return url?.startsWith("/")
} }
const ensureExternal = url => { const ensureExternal = url => {
return !url.startsWith("http") ? `http://${url}` : url if (!url?.length) {
return url
} }
return !url.startsWith("http") ? `http://${url}` : url
const close = () => {
mobileOpen = false
} }
const navigateToPortal = () => { const navigateToPortal = () => {
@ -194,7 +223,7 @@
> >
<div class="nav nav--{typeClass} size--{navWidthClass}"> <div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header"> <div class="nav-header">
{#if validLinks?.length} {#if enrichedNavItems.length}
<div class="burger"> <div class="burger">
<Icon <Icon
hoverable hoverable
@ -243,28 +272,20 @@
class:visible={mobileOpen} class:visible={mobileOpen}
on:click={() => (mobileOpen = false)} on:click={() => (mobileOpen = false)}
/> />
{#if validLinks?.length} {#if enrichedNavItems.length}
<div class="links" class:visible={mobileOpen}> <div class="links" class:visible={mobileOpen}>
{#each validLinks as { text, url }} {#each enrichedNavItems as navItem}
{#if isInternal(url)} <NavItem
<a type={navItem.type}
class={FieldTypes.LINK} text={navItem.text}
href={url} url={navItem.url}
use:linkable subLinks={navItem.subLinks}
on:click={close} internalLink={navItem.internalLink}
use:active={url} on:clickLink={() => (mobileOpen = false)}
> leftNav={navigation === "Left"}
{text} {mobile}
</a> {navStateStore}
{:else} />
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{/each} {/each}
<div class="close"> <div class="close">
<Icon <Icon
@ -509,21 +530,6 @@
gap: var(--spacing-xl); gap: var(--spacing-xl);
margin-top: 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 { .close {
display: none; display: none;
position: absolute; position: absolute;

View File

@ -0,0 +1,173 @@
<script>
import { createEventDispatcher, getContext } from "svelte"
import active from "svelte-spa-router/active"
import { Icon } from "@budibase/bbui"
export let type
export let url
export let text
export let subLinks
export let internalLink
export let leftNav = false
export let mobile = false
export let navStateStore
const dispatch = createEventDispatcher()
const sdk = getContext("sdk")
const { linkable } = sdk
let renderKey
$: expanded = !!$navStateStore[text]
$: renderLeftNav = leftNav || mobile
$: icon = !renderLeftNav || expanded ? "ChevronDown" : "ChevronRight"
const onClickLink = () => {
dispatch("clickLink")
renderKey = Math.random()
}
const onClickDropdown = () => {
if (!renderLeftNav) {
return
}
navStateStore.update(state => ({
...state,
[text]: !state[text],
}))
}
</script>
{#if !type || type === "link"}
{#if internalLink}
<!--
It's stupid that we have to add class:active={false} here, but if we don't
then svelte will strip out the CSS selector and active links won't be
styled
-->
<a
href={url}
on:click={onClickLink}
use:active={url}
use:linkable
class:active={false}
>
{text}
</a>
{:else}
<a href={url} on:click={onClickLink}>
{text}
</a>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#key renderKey}
<div class="dropdown" class:left={renderLeftNav} class:expanded>
<div class="text" on:click={onClickDropdown}>
<span>{text}</span>
<Icon name={icon} />
</div>
<div class="sublinks-wrapper">
<div class="sublinks">
{#each subLinks || [] as subLink}
{#if subLink.internalLink}
<a
href={subLink.url}
on:click={onClickLink}
use:active={subLink.url}
use:linkable
>
{subLink.text}
</a>
{:else}
<a href={subLink.url} on:click={onClickLink}>
{subLink.text}
</a>
{/if}
{/each}
</div>
</div>
</div>
{/key}
{/if}
<style>
/* Generic styles */
a,
.text span {
opacity: 0.75;
color: var(--navTextColor);
font-size: var(--spectrum-global-dimension-font-size-200);
transition: opacity 130ms ease-out;
font-weight: 600;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
}
a.active {
opacity: 1;
}
a:hover,
.dropdown:not(.left.expanded):hover .text,
.text:hover {
cursor: pointer;
opacity: 1;
}
/* Top dropdowns */
.dropdown {
position: relative;
}
.text {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
}
.sublinks-wrapper {
position: absolute;
top: 100%;
display: none;
padding-top: var(--spacing-s);
}
.dropdown:hover .sublinks-wrapper {
display: block;
}
.sublinks {
display: flex;
flex-direction: column;
justify-content: stretch;
align-items: flex-start;
background: var(--spectrum-global-color-gray-50);
border-radius: 6px;
border: 1px solid var(--spectrum-global-color-gray-300);
min-width: 150px;
max-width: 250px;
padding: 10px 0;
overflow: hidden;
}
.sublinks a {
padding: 6px var(--spacing-l);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
/* Left dropdowns */
.dropdown.left .sublinks-wrapper {
display: none;
}
.dropdown.left,
.dropdown.left.expanded .sublinks-wrapper,
.dropdown.dropdown.left.expanded .sublinks {
display: contents;
}
.dropdown.left a {
padding-top: 0;
padding-bottom: 0;
}
</style>