/** * Note that it assumes the presence of cssVars.cssRootVars on . */ import {urlState} from "app/client/models/gristUrlState"; import {resizeFlexVHandle} from 'app/client/ui/resizeHandle'; import {transition} from 'app/client/ui/transitions'; import {colors, cssHideForNarrowScreen, mediaNotSmall, mediaSmall} from 'app/client/ui2018/cssVars'; import {isNarrowScreenObs} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {dom, DomArg, noTestId, Observable, styled, subscribe, TestId} from "grainjs"; export interface PageSidePanel { // Note that widths need to start out with a correct default in JS (having them in CSS is not // enough), needed for open/close transitions. panelWidth: Observable; panelOpen: Observable; hideOpener?: boolean; // If true, don't show the opener handle. header: DomArg; content: DomArg; } export interface PageContents { leftPanel: PageSidePanel; rightPanel?: PageSidePanel; // If omitted, the right panel isn't shown at all. headerMain: DomArg; contentMain: DomArg; onResize?: () => void; // Callback for when either pane is opened, closed, or resized. testId?: TestId; contentBottom?: DomArg; } export function pagePanels(page: PageContents) { const testId = page.testId || noTestId; const left = page.leftPanel; const right = page.rightPanel; const onResize = page.onResize || (() => null); let lastLeftOpen = left.panelOpen.get(); let lastRightOpen = right?.panelOpen.get() || false; // When switching to mobile mode, close panels; when switching to desktop, restore the // last desktop state. const sub1 = subscribe(isNarrowScreenObs(), (use, narrow) => { if (narrow) { lastLeftOpen = left.panelOpen.get(); lastRightOpen = right?.panelOpen.get() || false; } left.panelOpen.set(narrow ? false : lastLeftOpen); right?.panelOpen.set(narrow ? false : lastRightOpen); }); // When url changes, we must have navigated; close the left panel since if it were open, it was // the likely cause of the navigation (e.g. switch to another page or workspace). const sub2 = subscribe(isNarrowScreenObs(), urlState().state, (use, narrow, state) => { if (narrow) { left.panelOpen.set(false); } }); return cssPageContainer( dom.autoDispose(sub1), dom.autoDispose(sub2), cssLeftPane( testId('left-panel'), cssTopHeader(left.header), left.content, dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''), // Opening/closing the left pane, with transitions. cssLeftPane.cls('-open', left.panelOpen), transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), { prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; }, run(elem, open) { elem.style.marginRight = ''; }, finish: onResize, }), ), // Resizer for the left pane. // TODO: resizing to small size should collapse. possibly should allow expanding too cssResizeFlexVHandle( {target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }}, testId('left-resizer'), dom.show(left.panelOpen), cssHideForNarrowScreen.cls('')), // Show plain border when the resize handle is hidden. cssResizeDisabledBorder( dom.hide(left.panelOpen), cssHideForNarrowScreen.cls('')), cssMainPane( cssTopHeader( testId('top-header'), (left.hideOpener ? null : cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), testId('left-opener'), dom.on('click', () => toggleObs(left.panelOpen)), cssHideForNarrowScreen.cls('')) ), page.headerMain, (!right || right.hideOpener ? null : cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen), testId('right-opener'), dom.cls('tour-creator-panel'), dom.on('click', () => toggleObs(right.panelOpen)), cssHideForNarrowScreen.cls('')) ), ), page.contentMain, testId('main-pane'), ), (right ? [ // Resizer for the right pane. cssResizeFlexVHandle( {target: 'right', onSave: (val) => { right.panelWidth.set(val); onResize(); }}, testId('right-resizer'), dom.show(right.panelOpen), cssHideForNarrowScreen.cls('')), cssRightPane( testId('right-panel'), cssTopHeader(right.header), right.content, dom.style('width', (use) => use(right.panelOpen) ? use(right.panelWidth) + 'px' : ''), // Opening/closing the right pane, with transitions. cssRightPane.cls('-open', right.panelOpen), transition(use => (use(isNarrowScreenObs()) ? false : use(right.panelOpen)), { prepare(elem, open) { elem.style.marginLeft = (open ? -1 : 1) * right.panelWidth.get() + 'px'; }, run(elem, open) { elem.style.marginLeft = ''; }, finish: onResize, }), )] : null ), cssContentOverlay( dom.show((use) => use(left.panelOpen) || Boolean(right && use(right.panelOpen))), dom.on('click', () => { left.panelOpen.set(false); if (right) { right.panelOpen.set(false); } }), testId('overlay') ), dom.maybe(isNarrowScreenObs(), () => cssBottomFooter( testId('bottom-footer'), cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreen( 'FieldTextbox', dom.on('click', () => { right?.panelOpen.set(false); toggleObs(left.panelOpen); }), testId('left-opener-ns') ), cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen) ), page.contentBottom, (!right ? null : cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreen( 'Settings', dom.on('click', () => { left.panelOpen.set(false); toggleObs(right.panelOpen); }), testId('right-opener-ns') ), cssPanelOpenerNarrowScreenBtn.cls('-open', right.panelOpen), ) ), ) ), ); } function toggleObs(boolObs: Observable) { boolObs.set(!boolObs.get()); } const cssVBox = styled('div', ` display: flex; flex-direction: column; `); const cssHBox = styled('div', ` display: flex; `); const cssPageContainer = styled(cssHBox, ` position: absolute; isolation: isolate; /* Create a new stacking context */ z-index: 0; /* As of March 2019, isolation does not have Edge support, so force one with z-index */ overflow: hidden; top: 0; left: 0; right: 0; bottom: 0; min-width: 600px; background-color: ${colors.lightGrey}; @media ${mediaSmall} { & { padding-bottom: 48px; min-width: 240px; } .interface-light & { padding-bottom: 0; } } `); export const cssLeftPane = styled(cssVBox, ` position: relative; background-color: ${colors.lightGrey}; width: 48px; margin-right: 0px; overflow: hidden; transition: margin-right 0.4s; @media ${mediaSmall} { & { width: 240px; position: fixed; z-index: 10; top: 0; bottom: 0; left: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */ visibility: hidden; box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2); transition: left 0.4s, visibility 0.4s; will-change: left; } &-open { left: 0; visibility: visible; } } &-open { width: 240px; min-width: 160px; max-width: 320px; } @media print { & { display: none; } } .interface-light & { display: none; } `); const cssMainPane = styled(cssVBox, ` position: relative; flex: 1 1 0px; min-width: 0px; background-color: white; z-index: 1; `); const cssRightPane = styled(cssVBox, ` position: relative; background-color: ${colors.lightGrey}; width: 0px; margin-left: 0px; overflow: hidden; transition: margin-left 0.4s; z-index: 0; @media ${mediaSmall} { & { width: 240px; position: fixed; z-index: 10; top: 0; bottom: 0; right: -${240 + 15}px; /* adds an extra 15 pixels to also hide the box shadow */ box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2); visibility: hidden; transition: right 0.4s, visibility 0.4s; will-change: right; } &-open { right: 0; visibility: visible; } } &-open { width: 240px; min-width: 240px; max-width: 320px; } @media print { & { display: none; } } .interface-light & { display: none; } `); const cssTopHeader = styled('div', ` height: 48px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid ${colors.mediumGrey}; @media print { & { display: none; } } .interface-light & { display: none; } `); const cssBottomFooter = styled ('div', ` height: 48px; background-color: white; z-index: 20; display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 8px 16px; position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid ${colors.mediumGrey}; @media ${mediaNotSmall} { & { display: none; } } @media print { & { display: none; } } .interface-light & { display: none; } `); const cssResizeFlexVHandle = styled(resizeFlexVHandle, ` --resize-handle-color: ${colors.mediumGrey}; --resize-handle-highlight: ${colors.lightGreen}; @media print { & { display: none; } } `); const cssResizeDisabledBorder = styled('div', ` flex: none; width: 1px; height: 100%; background-color: ${colors.mediumGrey}; `); const cssPanelOpener = styled(icon, ` flex: none; width: 32px; height: 32px; padding: 8px 8px; cursor: pointer; -webkit-mask-size: 16px 16px; background-color: ${colors.lightGreen}; transition: transform 0.4s; &:hover { background-color: ${colors.darkGreen}; } &-open { transform: rotateY(180deg); } `); const cssPanelOpenerNarrowScreenBtn = styled('div', ` width: 32px; height: 32px; --icon-color: ${colors.slate}; cursor: pointer; border-radius: 4px; &-open { background-color: ${colors.lightGreen}; --icon-color: white; } `); const cssPanelOpenerNarrowScreen = styled(icon, ` width: 24px; height: 24px; margin: 4px; `); const cssContentOverlay = styled('div', ` position: absolute; top: 0; left: 0; bottom: 0; right: 0; background-color: grey; opacity: 0.5; display: none; z-index: 9; @media ${mediaSmall} { & { display: unset; } } `);