From de1719ee08e15abfccf10ea69bf41b6ea7eb8cfc Mon Sep 17 00:00:00 2001 From: Dmitry S Date: Mon, 8 Feb 2021 14:00:02 -0500 Subject: [PATCH] (core) Make side panels responsive and start closed on small screens. Summary: - Add isNarrowScreenObs() observable. - Remove optimizeNarrowScreen flag (now assumed always true). - Added viewport support and mobile tweaks to Error/Billing/Welcome pages. - Fix responsiveness of panel transitions, and of side panel state. - Close left panel on navigation to another page or workspace. - Start panels collapsed in both doc and docmenu cases. Test Plan: Tested manually, and fixed tests to accept the new behavior. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2726 --- app/client/ui/AppUI.ts | 23 +++-------- app/client/ui/BillingPageCss.ts | 19 ++++++++- app/client/ui/PagePanels.ts | 71 ++++++++++++++++++++++----------- app/client/ui/WelcomePage.ts | 9 ++++- app/client/ui2018/cssVars.ts | 18 +++++++-- 5 files changed, 92 insertions(+), 48 deletions(-) diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 955ff873..2b90e816 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -15,7 +15,7 @@ import {pagePanels} from 'app/client/ui/PagePanels'; import {RightPanel} from 'app/client/ui/RightPanel'; import {createTopBarDoc, createTopBarHome} from 'app/client/ui/TopBar'; import {WelcomePage} from 'app/client/ui/WelcomePage'; -import {isNarrowScreen, testId} from 'app/client/ui2018/cssVars'; +import {testId} from 'app/client/ui2018/cssVars'; import {Computed, dom, IDisposable, IDisposableOwner, Observable, replaceContent, subscribe} from 'grainjs'; // When integrating into the old app, we might in theory switch between new-style and old-style @@ -100,28 +100,18 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) { }, headerMain: createTopBarHome(appModel), contentMain: createDocMenu(pageModel), - optimizeNarrowScreen: true, }); } -// Create session observable. But if device is a narrow screen create a regular observable. -function createPanelObs(owner: IDisposableOwner, key: string, _default: T, isValid: (val: any) => val is T) { - if (isNarrowScreen()) { - return Observable.create(owner, _default); - } - return createSessionObs(owner, key, _default, isValid); -} - function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) { const pageModel = DocPageModelImpl.create(owner, appObj, appModel); // To simplify manual inspection in the common case, keep the most recently created // DocPageModel available as a global variable. (window as any).gristDocPageModel = pageModel; - const leftPanelOpen = createPanelObs(owner, "leftPanelOpen", isNarrowScreen() ? false : true, - isBoolean); - const rightPanelOpen = createPanelObs(owner, "rightPanelOpen", false, isBoolean); - const leftPanelWidth = createPanelObs(owner, "leftPanelWidth", 240, isNumber); - const rightPanelWidth = createPanelObs(owner, "rightPanelWidth", 240, isNumber); + const leftPanelOpen = createSessionObs(owner, "leftPanelOpen", true, isBoolean); + const rightPanelOpen = createSessionObs(owner, "rightPanelOpen", false, isBoolean); + const leftPanelWidth = createSessionObs(owner, "leftPanelWidth", 240, isNumber); + const rightPanelWidth = createSessionObs(owner, "rightPanelWidth", 240, isNumber); // The RightPanel component gets created only when an instance of GristDoc is set in pageModel. // use.owner is a feature of grainjs to make the new RightPanel owned by the computed itself: @@ -146,7 +136,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) panelWidth: leftPanelWidth, panelOpen: leftPanelOpen, header: appHeader(appModel.currentOrgName || pageModel.currentOrgName, appModel.topAppModel.productFlavor), - content: pageModel.createLeftPane(isNarrowScreen() ? Observable.create(null, true) : leftPanelOpen), + content: pageModel.createLeftPane(leftPanelOpen), }, rightPanel: { panelWidth: rightPanelWidth, @@ -158,7 +148,6 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) contentMain: dom.maybe(pageModel.gristDoc, (gristDoc) => gristDoc.buildDom()), onResize, testId, - optimizeNarrowScreen: true, contentBottom: dom.create(createBottomBarDoc, pageModel, leftPanelOpen, rightPanelOpen) }); } diff --git a/app/client/ui/BillingPageCss.ts b/app/client/ui/BillingPageCss.ts index 667189e9..4374b422 100644 --- a/app/client/ui/BillingPageCss.ts +++ b/app/client/ui/BillingPageCss.ts @@ -1,5 +1,5 @@ import {bigBasicButton, bigPrimaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, vars} from 'app/client/ui2018/cssVars'; +import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {input, styled} from 'grainjs'; @@ -98,6 +98,12 @@ export const billingPage = styled('div', ` display: flex; max-width: 1000px; margin: auto; + + @media ${mediaSmall} { + & { + display: block; + } + } `); export const billingHeader = styled('div', ` @@ -188,6 +194,11 @@ export const focusText = styled('span', ` export const cardBlock = styled('div', ` flex: 1 1 60%; margin: 60px; + @media ${mediaSmall} { + & { + margin: 24px; + } + } `); export const summaryRow = styled('div', ` @@ -201,7 +212,11 @@ export const summaryHeader = styled(summaryRow, ` export const summaryBlock = styled('div', ` flex: 1 1 40%; margin: 60px; - float: left; + @media ${mediaSmall} { + & { + margin: 24px; + } + } `); export const flexSpace = styled('div', ` diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index 238c3dbf..677ff832 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -1,11 +1,13 @@ /** * 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, isNarrowScreen, mediaNotSmall, mediaSmall} from 'app/client/ui2018/cssVars'; +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, TestId} from "grainjs"; +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 @@ -26,7 +28,6 @@ export interface PageContents { onResize?: () => void; // Callback for when either pane is opened, closed, or resized. testId?: TestId; - optimizeNarrowScreen?: boolean; // If true, show an optimized layout when screen is narrow. contentBottom?: DomArg; } @@ -35,10 +36,32 @@ export function pagePanels(page: PageContents) { const left = page.leftPanel; const right = page.rightPanel; const onResize = page.onResize || (() => null); - const optimizeNarrowScreen = Boolean(page.optimizeNarrowScreen); - return [cssPageContainer( - cssPageContainer.cls('-optimizeNarrowScreen', optimizeNarrowScreen), + 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), @@ -48,7 +71,7 @@ export function pagePanels(page: PageContents) { // Opening/closing the left pane, with transitions. cssLeftPane.cls('-open', left.panelOpen), - optimizeNarrowScreen && isNarrowScreen() ? null : transition(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, @@ -61,12 +84,12 @@ export function pagePanels(page: PageContents) { {target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }}, testId('left-resizer'), dom.show(left.panelOpen), - cssHideForNarrowScreen.cls('', optimizeNarrowScreen)), + cssHideForNarrowScreen.cls('')), // Show plain border when the resize handle is hidden. cssResizeDisabledBorder( dom.hide(left.panelOpen), - cssHideForNarrowScreen.cls('', optimizeNarrowScreen)), + cssHideForNarrowScreen.cls('')), cssMainPane( cssTopHeader( @@ -75,7 +98,7 @@ export function pagePanels(page: PageContents) { cssPanelOpener('PanelRight', cssPanelOpener.cls('-open', left.panelOpen), testId('left-opener'), dom.on('click', () => toggleObs(left.panelOpen)), - cssHideForNarrowScreen.cls('', optimizeNarrowScreen)) + cssHideForNarrowScreen.cls('')) ), page.headerMain, @@ -84,7 +107,7 @@ export function pagePanels(page: PageContents) { cssPanelOpener('PanelLeft', cssPanelOpener.cls('-open', right.panelOpen), testId('right-opener'), dom.on('click', () => toggleObs(right.panelOpen)), - cssHideForNarrowScreen.cls('', optimizeNarrowScreen)) + cssHideForNarrowScreen.cls('')) ), ), page.contentMain, @@ -96,7 +119,7 @@ export function pagePanels(page: PageContents) { {target: 'right', onSave: (val) => { right.panelWidth.set(val); onResize(); }}, testId('right-resizer'), dom.show(right.panelOpen), - cssHideForNarrowScreen.cls('', optimizeNarrowScreen)), + cssHideForNarrowScreen.cls('')), cssRightPane( testId('right-panel'), @@ -107,7 +130,7 @@ export function pagePanels(page: PageContents) { // Opening/closing the right pane, with transitions. cssRightPane.cls('-open', right.panelOpen), - optimizeNarrowScreen && isNarrowScreen() ? null : transition(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, @@ -122,8 +145,7 @@ export function pagePanels(page: PageContents) { }), testId('overlay') ), - ), ( - !optimizeNarrowScreen ? null : + dom.maybe(isNarrowScreenObs(), () => cssBottomFooter( testId('bottom-footer'), cssPanelOpenerNarrowScreenBtn( @@ -152,7 +174,8 @@ export function pagePanels(page: PageContents) { ) ), ) - )]; + ), + ); } function toggleObs(boolObs: Observable) { @@ -178,12 +201,12 @@ const cssPageContainer = styled(cssHBox, ` background-color: ${colors.lightGrey}; @media ${mediaSmall} { - &-optimizeNarrowScreen { - bottom: 48px; + & { + padding-bottom: 48px; min-width: 240px; } .interface-light & { - bottom: 0; + padding-bottom: 0; } } `); @@ -195,7 +218,7 @@ export const cssLeftPane = styled(cssVBox, ` overflow: hidden; transition: margin-right 0.4s; @media ${mediaSmall} { - .${cssPageContainer.className}-optimizeNarrowScreen & { + & { width: 240px; position: fixed; z-index: 10; @@ -207,7 +230,7 @@ export const cssLeftPane = styled(cssVBox, ` transition: left 0.4s, visibility 0.4s; will-change: left; } - .${cssPageContainer.className}-optimizeNarrowScreen &-open { + &-open { left: 0; visibility: visible; } @@ -242,7 +265,7 @@ const cssRightPane = styled(cssVBox, ` transition: margin-left 0.4s; z-index: 0; @media ${mediaSmall} { - .${cssPageContainer.className}-optimizeNarrowScreen & { + & { width: 240px; position: fixed; z-index: 10; @@ -254,7 +277,7 @@ const cssRightPane = styled(cssVBox, ` transition: right 0.4s, visibility 0.4s; will-change: right; } - .${cssPageContainer.className}-optimizeNarrowScreen &-open { + &-open { right: 0; visibility: visible; } @@ -374,7 +397,7 @@ const cssContentOverlay = styled('div', ` display: none; z-index: 9; @media ${mediaSmall} { - .${cssPageContainer.className}-optimizeNarrowScreen & { + & { display: unset; } } diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index 7d4056d5..0761d3a2 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -9,7 +9,7 @@ import * as BillingPageCss from "app/client/ui/BillingPageCss"; import * as forms from "app/client/ui/forms"; import { pagePanels } from "app/client/ui/PagePanels"; import { bigBasicButton, bigPrimaryButton, bigPrimaryButtonLink, cssButton } from "app/client/ui2018/buttons"; -import { colors, testId, vars } from "app/client/ui2018/cssVars"; +import { colors, mediaSmall, testId, vars } from "app/client/ui2018/cssVars"; import { getOrgName, Organization } from "app/common/UserAPI"; async function _submitForm(form: HTMLFormElement, pending: Observable) { @@ -199,7 +199,7 @@ const cssScrollContainer = styled('div', ` `); const cssContainer = styled('div', ` - width: 450px; + max-width: 450px; align-self: center; margin: 60px; display: flex; @@ -208,6 +208,11 @@ const cssContainer = styled('div', ` content: ""; height: 8px; } + @media ${mediaSmall} { + & { + margin: 24px; + } + } `); const cssFlexSpace = styled('div', ` diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index c5d10097..a5c82784 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -8,7 +8,7 @@ */ import {urlState} from 'app/client/models/gristUrlState'; import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes'; -import {dom, makeTestId, styled, TestId} from 'grainjs'; +import {dom, makeTestId, Observable, styled, TestId} from 'grainjs'; import values = require('lodash/values'); const VAR_PREFIX = 'grist'; @@ -164,8 +164,20 @@ export const mediaNotSmall = `(min-width: ${mediumScreenWidth}px)`; export const mediaDeviceNotSmall = `(min-device-width: ${mediumScreenWidth}px)`; -export function isNarrowScreen() { - return window.innerWidth <= 768; +function isNarrowScreen() { + return window.innerWidth < mediumScreenWidth; +} + +let _isNarrowScreenObs: Observable|undefined; + +// Returns a singleton observable for whether the screen is a small one. +export function isNarrowScreenObs(): Observable { + if (!_isNarrowScreenObs) { + const obs = Observable.create(null, isNarrowScreen()); + window.addEventListener('resize', () => obs.set(isNarrowScreen())); + _isNarrowScreenObs = obs; + } + return _isNarrowScreenObs; } export const cssHideForNarrowScreen = styled('div', `