diff --git a/app/client/lib/localStorageObs.ts b/app/client/lib/localStorageObs.ts index b9312f29..1ee28ae1 100644 --- a/app/client/lib/localStorageObs.ts +++ b/app/client/lib/localStorageObs.ts @@ -1,11 +1,58 @@ import {Observable} from 'grainjs'; +/** + * Returns true if storage is functional. In some cass (e.g. when embedded), localStorage may + * throw errors. If so, we return false. This implementation is the approach taken by store.js. + */ +function testStorage(storage: Storage) { + try { + const testStr = '__localStorage_test'; + storage.setItem(testStr, testStr); + const ok = (storage.getItem(testStr) === testStr); + storage.removeItem(testStr); + return ok; + } catch (e) { + return false; + } +} + +/** + * Returns localStorage if functional, or sessionStorage, or an in-memory storage. The fallbacks + * help with tests, and may help when Grist is embedded. + */ +export function getStorage(): Storage { + return _storage || (_storage = createStorage()); +} + +let _storage: Storage|undefined; + +function createStorage(): Storage { + if (typeof localStorage !== 'undefined' && testStorage(localStorage)) { + return localStorage; + } + if (typeof sessionStorage !== 'undefined' && testStorage(sessionStorage)) { + return sessionStorage; + } + + // Fall back to a Map-based implementation of (non-persistent) localStorage. + const values = new Map(); + return { + setItem(key: string, val: string) { values.set(key, val); }, + getItem(key: string) { return values.get(key) ?? null; }, + removeItem(key: string) { values.delete(key); }, + clear() { values.clear(); }, + get length() { return values.size; }, + key(index: number): string|null { throw new Error('Not implemented'); }, + }; +} + /** * Helper to create a boolean observable whose state is stored in localStorage. */ export function localStorageBoolObs(key: string): Observable { - const obs = Observable.create(null, Boolean(localStorage.getItem(key))); - obs.addListener((val) => val ? localStorage.setItem(key, 'true') : localStorage.removeItem(key)); + const store = getStorage(); + const obs = Observable.create(null, Boolean(store.getItem(key))); + obs.addListener((val) => val ? store.setItem(key, 'true') : store.removeItem(key)); return obs; } @@ -13,7 +60,8 @@ export function localStorageBoolObs(key: string): Observable { * Helper to create a string observable whose state is stored in localStorage. */ export function localStorageObs(key: string): Observable { - const obs = Observable.create(null, localStorage.getItem(key)); - obs.addListener((val) => (val === null) ? localStorage.removeItem(key) : localStorage.setItem(key, val)); + const store = getStorage(); + const obs = Observable.create(null, store.getItem(key)); + obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, val)); return obs; } diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index 8a79c6b4..5bd9bf30 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -5,8 +5,9 @@ import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/clie import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; import {showProfileModal} from 'app/client/ui/ProfileDialog'; import {createUserImage} from 'app/client/ui/UserImage'; +import * as viewport from 'app/client/ui/viewport'; import {primaryButtonLink} from 'app/client/ui2018/buttons'; -import {colors, testId, vars} from 'app/client/ui2018/cssVars'; +import {colors, mediaDeviceNotSmall, testId, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {commonUrls} from 'app/common/gristUrls'; @@ -111,6 +112,13 @@ export class AccountWidget extends Disposable { ) : menuItemLink({href: commonUrls.plans}, 'Upgrade Plan'), + menuItem(viewport.toggleViewport, + cssSmallDeviceOnly.cls(''), // Only show this toggle on small devices. + 'Toggle Mobile Mode', + cssCheckmark('Tick', dom.show(viewport.viewportEnabled)), + testId('usermenu-toggle-mobile'), + ), + // TODO Add section ("Here right now") listing icons of other users currently on this doc. // (See Invision "Panels" near the bottom.) @@ -222,3 +230,19 @@ const cssOrgCheckmark = styled(icon, ` display: block; } `); + +const cssCheckmark = styled(icon, ` + flex: none; + margin-left: 16px; + --icon-color: ${colors.lightGreen}; +`); + +// Note that this css class hides the item when the device width is small (not based on viewport +// width, which may be larger). This only appropriate for when to enable the "mobile mode" toggle. +const cssSmallDeviceOnly = styled(menuItem, ` + @media ${mediaDeviceNotSmall} { + & { + display: none; + } + } +`); diff --git a/app/client/ui/App.ts b/app/client/ui/App.ts index 4f050214..477bf1d9 100644 --- a/app/client/ui/App.ts +++ b/app/client/ui/App.ts @@ -10,6 +10,7 @@ import * as koUtil from 'app/client/lib/koUtil'; import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel'; import {setUpErrorHandling} from 'app/client/models/errors'; import {createAppUI} from 'app/client/ui/AppUI'; +import {addViewportTag} from 'app/client/ui/viewport'; import {attachCssRootVars} from 'app/client/ui2018/cssVars'; import {BaseAPI} from 'app/common/BaseAPI'; import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; @@ -154,6 +155,7 @@ export class App extends DisposableWithEvents { // Add the cssRootVars class to enable the variables in cssVars. attachCssRootVars(this.topAppModel.productFlavor); + addViewportTag(); this.autoDispose(createAppUI(this.topAppModel, this)); } diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index 6fac8efa..dc5101a5 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -100,6 +100,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) { }, headerMain: createTopBarHome(appModel), contentMain: createDocMenu(pageModel), + optimizeNarrowScreen: true, }); } diff --git a/app/client/ui/DocMenuCss.ts b/app/client/ui/DocMenuCss.ts index be6df02c..73ccac3b 100644 --- a/app/client/ui/DocMenuCss.ts +++ b/app/client/ui/DocMenuCss.ts @@ -1,5 +1,5 @@ import {transientInput} from 'app/client/ui/transientInput'; -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 {styled} from 'grainjs'; @@ -15,6 +15,11 @@ export const docList = styled('div', ` display: block; height: 64px; } + @media ${mediaSmall} { + & { + padding: 32px 24px 24px 24px; + } + } `); export const docListHeader = styled('div', ` @@ -216,6 +221,12 @@ export const prefSelectors = styled('div', ` right: 64px; display: flex; align-items: center; + + @media ${mediaSmall} { + & { + right: 24px; + } + } `); export const sortSelector = styled('div', ` @@ -236,4 +247,9 @@ export const sortSelector = styled('div', ` box-shadow: none; background-color: ${colors.mediumGrey}; } + @media ${mediaSmall} { + & { + margin-right: 0; + } + } `); diff --git a/app/client/ui/PagePanels.ts b/app/client/ui/PagePanels.ts index dc1bda58..62061538 100644 --- a/app/client/ui/PagePanels.ts +++ b/app/client/ui/PagePanels.ts @@ -3,7 +3,7 @@ */ import {resizeFlexVHandle} from 'app/client/ui/resizeHandle'; import {transition} from 'app/client/ui/transitions'; -import {colors, cssHideForNarrowScreen, maxNarrowScreenWidth} from 'app/client/ui2018/cssVars'; +import {colors, cssHideForNarrowScreen, mediaNotSmall, mediaSmall} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {dom, DomArg, noTestId, Observable, styled, TestId} from "grainjs"; @@ -39,7 +39,6 @@ export function pagePanels(page: PageContents) { return [cssPageContainer( cssPageContainer.cls('-optimizeNarrowScreen', optimizeNarrowScreen), - optimizeNarrowScreen ? dom.style('min-width', '240px') : null, cssLeftPane( testId('left-panel'), cssTopHeader(left.header), @@ -121,39 +120,36 @@ export function pagePanels(page: PageContents) { left.panelOpen.set(false); if (right) { right.panelOpen.set(false); } }), - testId('overlay')) + testId('overlay') + ), ), ( !optimizeNarrowScreen ? null : cssBottomFooter( testId('bottom-footer'), - (left.hideOpener ? null : - cssPanelOpenerNarrowScreenBtn( - cssPanelOpenerNarrowScreen( - 'FieldTextbox', - dom.on('click', () => { - if (right) { - right.panelOpen.set(false); - } - toggleObs(left.panelOpen); - }), - testId('left-opener-ns') - ), - cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen) - ) + 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 || right.hideOpener ? null : - cssPanelOpenerNarrowScreenBtn( - cssPanelOpenerNarrowScreen( - 'Settings', - dom.on('click', () => { - left.panelOpen.set(false); - toggleObs(right.panelOpen); - }), - testId('right-opener-ns') - ), - cssPanelOpenerNarrowScreenBtn.cls('-open', right.panelOpen), - ) + (!right ? null : + cssPanelOpenerNarrowScreenBtn( + cssPanelOpenerNarrowScreen( + 'Settings', + dom.on('click', () => { + left.panelOpen.set(false); + toggleObs(right.panelOpen); + }), + testId('right-opener-ns') + ), + cssPanelOpenerNarrowScreenBtn.cls('-open', right.panelOpen), + ) ), ) )]; @@ -178,15 +174,15 @@ const cssPageContainer = styled(cssHBox, ` left: 0; right: 0; bottom: 0; - min-width: 600px + min-width: 600px; background-color: ${colors.lightGrey}; - @media (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaSmall} { &-optimizeNarrowScreen { bottom: 48px; + min-width: 240px; } } - `); export const cssLeftPane = styled(cssVBox, ` position: relative; @@ -195,7 +191,7 @@ export const cssLeftPane = styled(cssVBox, ` margin-right: 0px; overflow: hidden; transition: margin-right 0.4s; - @media (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaSmall} { .${cssPageContainer.className}-optimizeNarrowScreen & { width: 0px; position: absolute; @@ -204,7 +200,6 @@ export const cssLeftPane = styled(cssVBox, ` bottom: 0; left: 0; box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2); - border-bottom: 1px solid ${colors.mediumGrey}; } } &-open { @@ -236,7 +231,7 @@ const cssRightPane = styled(cssVBox, ` overflow: hidden; transition: margin-left 0.4s; z-index: 0; - @media (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaSmall} { .${cssPageContainer.className}-optimizeNarrowScreen & { position: absolute; z-index: 10; @@ -244,7 +239,6 @@ const cssRightPane = styled(cssVBox, ` bottom: 0; right: 0; box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2); - border-bottom: 1px solid ${colors.mediumGrey}; } } &-open { @@ -292,7 +286,8 @@ const cssBottomFooter = styled ('div', ` bottom: 0; left: 0; right: 0; - @media not all and (max-width: ${maxNarrowScreenWidth}px) { + border-top: 1px solid ${colors.mediumGrey}; + @media ${mediaNotSmall} { & { display: none; } @@ -352,7 +347,7 @@ const cssContentOverlay = styled('div', ` opacity: 0.5; display: none; z-index: 9; - @media (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaSmall} { .${cssPageContainer.className}-optimizeNarrowScreen & { display: unset; } diff --git a/app/client/ui/viewport.ts b/app/client/ui/viewport.ts new file mode 100644 index 00000000..208f7aa6 --- /dev/null +++ b/app/client/ui/viewport.ts @@ -0,0 +1,20 @@ +import {localStorageBoolObs} from 'app/client/lib/localStorageObs'; +import {dom} from 'grainjs'; + +export const viewportEnabled = localStorageBoolObs('viewportEnabled'); + +export function toggleViewport() { + viewportEnabled.set(!viewportEnabled.get()); + if (!viewportEnabled.get()) { + // Removing the meta tag doesn't cause mobile browsers to reload automatically. + location.reload(); + } +} + +export function addViewportTag() { + dom.update(document.head, + dom.maybe(viewportEnabled, () => + dom('meta', {name: "viewport", content: "width=device-width,initial-scale=1.0"}) + ) + ); +} diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index bd352868..07a8e0cb 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -6,7 +6,7 @@ * Workspace is a clickable link and document and page names are editable labels. */ import { urlState } from 'app/client/models/gristUrlState'; -import { colors, cssHideForNarrowScreen, maxNarrowScreenWidth, testId } from 'app/client/ui2018/cssVars'; +import { colors, cssHideForNarrowScreen, mediaNotSmall, testId } from 'app/client/ui2018/cssVars'; import { editableLabel } from 'app/client/ui2018/editableLabel'; import { icon } from 'app/client/ui2018/icons'; import { UserOverride } from 'app/common/DocListAPI'; @@ -55,7 +55,7 @@ const cssWorkspaceNarrowScreen = styled(icon, ` margin-right: 8px; background-color: ${colors.slate}; cursor: pointer; - @media not all and (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaNotSmall} { & { display: none; } diff --git a/app/client/ui2018/cssVars.ts b/app/client/ui2018/cssVars.ts index ec72d398..c5d10097 100644 --- a/app/client/ui2018/cssVars.ts +++ b/app/client/ui2018/cssVars.ts @@ -154,15 +154,22 @@ export const cssRootVars = cssBodyVars.className; // class ".test-{name}". Ideally, we'd use noTestId() instead in production. export const testId: TestId = makeTestId('test-'); -// Max width for narrow screen layout (in px). Note: 768px is bootstrap's definition of small screen -export const maxNarrowScreenWidth = 768; +// Min width for normal screen layout (in px). Note: <768px is bootstrap's definition of small +// screen (covers phones, including landscape, but not tablets). +const mediumScreenWidth = 768; + +// Fractional width for max-query follows https://getbootstrap.com/docs/4.0/layout/overview/#responsive-breakpoints +export const mediaSmall = `(max-width: ${mediumScreenWidth - 0.02}px)`; +export const mediaNotSmall = `(min-width: ${mediumScreenWidth}px)`; + +export const mediaDeviceNotSmall = `(min-device-width: ${mediumScreenWidth}px)`; export function isNarrowScreen() { return window.innerWidth <= 768; } export const cssHideForNarrowScreen = styled('div', ` - @media (max-width: ${maxNarrowScreenWidth}px) { + @media ${mediaSmall} { & { display: none !important; }