(core) Add viewport meta tag conditionally, and show a toggle for it on small devices.

Summary:
- Enable narrow-screen layout for home page
- Clean up margins/spacing on small-screen home page
- Use "<768" as small-screen condition rather than "<=768".
- Include meta-viewport tag conditionally, off by default.
- Include "Toggle Mobile Mode" option in AccountMenu to toggle it on.
- In a test, add an after() clause to restore window size even when test fails

Test Plan: Only tested manually on iPhone (Safari & FF).

Reviewers: paulfitz

Reviewed By: paulfitz

Subscribers: cyprien

Differential Revision: https://phab.getgrist.com/D2708
This commit is contained in:
Dmitry S 2021-01-21 14:12:24 -05:00
parent f4366a01b3
commit 586b6568af
9 changed files with 162 additions and 49 deletions

View File

@ -1,11 +1,58 @@
import {Observable} from 'grainjs'; 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<string, string>();
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. * Helper to create a boolean observable whose state is stored in localStorage.
*/ */
export function localStorageBoolObs(key: string): Observable<boolean> { export function localStorageBoolObs(key: string): Observable<boolean> {
const obs = Observable.create(null, Boolean(localStorage.getItem(key))); const store = getStorage();
obs.addListener((val) => val ? localStorage.setItem(key, 'true') : localStorage.removeItem(key)); const obs = Observable.create(null, Boolean(store.getItem(key)));
obs.addListener((val) => val ? store.setItem(key, 'true') : store.removeItem(key));
return obs; return obs;
} }
@ -13,7 +60,8 @@ export function localStorageBoolObs(key: string): Observable<boolean> {
* Helper to create a string observable whose state is stored in localStorage. * Helper to create a string observable whose state is stored in localStorage.
*/ */
export function localStorageObs(key: string): Observable<string|null> { export function localStorageObs(key: string): Observable<string|null> {
const obs = Observable.create<string|null>(null, localStorage.getItem(key)); const store = getStorage();
obs.addListener((val) => (val === null) ? localStorage.removeItem(key) : localStorage.setItem(key, val)); const obs = Observable.create<string|null>(null, store.getItem(key));
obs.addListener((val) => (val === null) ? store.removeItem(key) : store.setItem(key, val));
return obs; return obs;
} }

View File

@ -5,8 +5,9 @@ import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/clie
import {showDocSettingsModal} from 'app/client/ui/DocumentSettings'; import {showDocSettingsModal} from 'app/client/ui/DocumentSettings';
import {showProfileModal} from 'app/client/ui/ProfileDialog'; import {showProfileModal} from 'app/client/ui/ProfileDialog';
import {createUserImage} from 'app/client/ui/UserImage'; import {createUserImage} from 'app/client/ui/UserImage';
import * as viewport from 'app/client/ui/viewport';
import {primaryButtonLink} from 'app/client/ui2018/buttons'; 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 {icon} from 'app/client/ui2018/icons';
import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; import {menu, menuDivider, menuItem, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus';
import {commonUrls} from 'app/common/gristUrls'; import {commonUrls} from 'app/common/gristUrls';
@ -111,6 +112,13 @@ export class AccountWidget extends Disposable {
) : ) :
menuItemLink({href: commonUrls.plans}, 'Upgrade Plan'), 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. // TODO Add section ("Here right now") listing icons of other users currently on this doc.
// (See Invision "Panels" near the bottom.) // (See Invision "Panels" near the bottom.)
@ -222,3 +230,19 @@ const cssOrgCheckmark = styled(icon, `
display: block; 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;
}
}
`);

View File

@ -10,6 +10,7 @@ import * as koUtil from 'app/client/lib/koUtil';
import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel'; import {reportError, TopAppModel, TopAppModelImpl} from 'app/client/models/AppModel';
import {setUpErrorHandling} from 'app/client/models/errors'; import {setUpErrorHandling} from 'app/client/models/errors';
import {createAppUI} from 'app/client/ui/AppUI'; import {createAppUI} from 'app/client/ui/AppUI';
import {addViewportTag} from 'app/client/ui/viewport';
import {attachCssRootVars} from 'app/client/ui2018/cssVars'; import {attachCssRootVars} from 'app/client/ui2018/cssVars';
import {BaseAPI} from 'app/common/BaseAPI'; import {BaseAPI} from 'app/common/BaseAPI';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents'; 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. // Add the cssRootVars class to enable the variables in cssVars.
attachCssRootVars(this.topAppModel.productFlavor); attachCssRootVars(this.topAppModel.productFlavor);
addViewportTag();
this.autoDispose(createAppUI(this.topAppModel, this)); this.autoDispose(createAppUI(this.topAppModel, this));
} }

View File

@ -100,6 +100,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel) {
}, },
headerMain: createTopBarHome(appModel), headerMain: createTopBarHome(appModel),
contentMain: createDocMenu(pageModel), contentMain: createDocMenu(pageModel),
optimizeNarrowScreen: true,
}); });
} }

View File

@ -1,5 +1,5 @@
import {transientInput} from 'app/client/ui/transientInput'; 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 {icon} from 'app/client/ui2018/icons';
import {styled} from 'grainjs'; import {styled} from 'grainjs';
@ -15,6 +15,11 @@ export const docList = styled('div', `
display: block; display: block;
height: 64px; height: 64px;
} }
@media ${mediaSmall} {
& {
padding: 32px 24px 24px 24px;
}
}
`); `);
export const docListHeader = styled('div', ` export const docListHeader = styled('div', `
@ -216,6 +221,12 @@ export const prefSelectors = styled('div', `
right: 64px; right: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
@media ${mediaSmall} {
& {
right: 24px;
}
}
`); `);
export const sortSelector = styled('div', ` export const sortSelector = styled('div', `
@ -236,4 +247,9 @@ export const sortSelector = styled('div', `
box-shadow: none; box-shadow: none;
background-color: ${colors.mediumGrey}; background-color: ${colors.mediumGrey};
} }
@media ${mediaSmall} {
& {
margin-right: 0;
}
}
`); `);

View File

@ -3,7 +3,7 @@
*/ */
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle'; import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
import {transition} from 'app/client/ui/transitions'; 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 {icon} from 'app/client/ui2018/icons';
import {dom, DomArg, noTestId, Observable, styled, TestId} from "grainjs"; import {dom, DomArg, noTestId, Observable, styled, TestId} from "grainjs";
@ -39,7 +39,6 @@ export function pagePanels(page: PageContents) {
return [cssPageContainer( return [cssPageContainer(
cssPageContainer.cls('-optimizeNarrowScreen', optimizeNarrowScreen), cssPageContainer.cls('-optimizeNarrowScreen', optimizeNarrowScreen),
optimizeNarrowScreen ? dom.style('min-width', '240px') : null,
cssLeftPane( cssLeftPane(
testId('left-panel'), testId('left-panel'),
cssTopHeader(left.header), cssTopHeader(left.header),
@ -121,28 +120,25 @@ export function pagePanels(page: PageContents) {
left.panelOpen.set(false); left.panelOpen.set(false);
if (right) { right.panelOpen.set(false); } if (right) { right.panelOpen.set(false); }
}), }),
testId('overlay')) testId('overlay')
),
), ( ), (
!optimizeNarrowScreen ? null : !optimizeNarrowScreen ? null :
cssBottomFooter( cssBottomFooter(
testId('bottom-footer'), testId('bottom-footer'),
(left.hideOpener ? null :
cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreenBtn(
cssPanelOpenerNarrowScreen( cssPanelOpenerNarrowScreen(
'FieldTextbox', 'FieldTextbox',
dom.on('click', () => { dom.on('click', () => {
if (right) { right?.panelOpen.set(false);
right.panelOpen.set(false);
}
toggleObs(left.panelOpen); toggleObs(left.panelOpen);
}), }),
testId('left-opener-ns') testId('left-opener-ns')
), ),
cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen) cssPanelOpenerNarrowScreenBtn.cls('-open', left.panelOpen)
)
), ),
page.contentBottom, page.contentBottom,
(!right || right.hideOpener ? null : (!right ? null :
cssPanelOpenerNarrowScreenBtn( cssPanelOpenerNarrowScreenBtn(
cssPanelOpenerNarrowScreen( cssPanelOpenerNarrowScreen(
'Settings', 'Settings',
@ -178,15 +174,15 @@ const cssPageContainer = styled(cssHBox, `
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
min-width: 600px min-width: 600px;
background-color: ${colors.lightGrey}; background-color: ${colors.lightGrey};
@media (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaSmall} {
&-optimizeNarrowScreen { &-optimizeNarrowScreen {
bottom: 48px; bottom: 48px;
min-width: 240px;
} }
} }
`); `);
export const cssLeftPane = styled(cssVBox, ` export const cssLeftPane = styled(cssVBox, `
position: relative; position: relative;
@ -195,7 +191,7 @@ export const cssLeftPane = styled(cssVBox, `
margin-right: 0px; margin-right: 0px;
overflow: hidden; overflow: hidden;
transition: margin-right 0.4s; transition: margin-right 0.4s;
@media (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaSmall} {
.${cssPageContainer.className}-optimizeNarrowScreen & { .${cssPageContainer.className}-optimizeNarrowScreen & {
width: 0px; width: 0px;
position: absolute; position: absolute;
@ -204,7 +200,6 @@ export const cssLeftPane = styled(cssVBox, `
bottom: 0; bottom: 0;
left: 0; left: 0;
box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2); box-shadow: 10px 0 5px rgba(0, 0, 0, 0.2);
border-bottom: 1px solid ${colors.mediumGrey};
} }
} }
&-open { &-open {
@ -236,7 +231,7 @@ const cssRightPane = styled(cssVBox, `
overflow: hidden; overflow: hidden;
transition: margin-left 0.4s; transition: margin-left 0.4s;
z-index: 0; z-index: 0;
@media (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaSmall} {
.${cssPageContainer.className}-optimizeNarrowScreen & { .${cssPageContainer.className}-optimizeNarrowScreen & {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
@ -244,7 +239,6 @@ const cssRightPane = styled(cssVBox, `
bottom: 0; bottom: 0;
right: 0; right: 0;
box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2); box-shadow: -10px 0 5px rgba(0, 0, 0, 0.2);
border-bottom: 1px solid ${colors.mediumGrey};
} }
} }
&-open { &-open {
@ -292,7 +286,8 @@ const cssBottomFooter = styled ('div', `
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
@media not all and (max-width: ${maxNarrowScreenWidth}px) { border-top: 1px solid ${colors.mediumGrey};
@media ${mediaNotSmall} {
& { & {
display: none; display: none;
} }
@ -352,7 +347,7 @@ const cssContentOverlay = styled('div', `
opacity: 0.5; opacity: 0.5;
display: none; display: none;
z-index: 9; z-index: 9;
@media (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaSmall} {
.${cssPageContainer.className}-optimizeNarrowScreen & { .${cssPageContainer.className}-optimizeNarrowScreen & {
display: unset; display: unset;
} }

20
app/client/ui/viewport.ts Normal file
View File

@ -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"})
)
);
}

View File

@ -6,7 +6,7 @@
* Workspace is a clickable link and document and page names are editable labels. * Workspace is a clickable link and document and page names are editable labels.
*/ */
import { urlState } from 'app/client/models/gristUrlState'; 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 { editableLabel } from 'app/client/ui2018/editableLabel';
import { icon } from 'app/client/ui2018/icons'; import { icon } from 'app/client/ui2018/icons';
import { UserOverride } from 'app/common/DocListAPI'; import { UserOverride } from 'app/common/DocListAPI';
@ -55,7 +55,7 @@ const cssWorkspaceNarrowScreen = styled(icon, `
margin-right: 8px; margin-right: 8px;
background-color: ${colors.slate}; background-color: ${colors.slate};
cursor: pointer; cursor: pointer;
@media not all and (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaNotSmall} {
& { & {
display: none; display: none;
} }

View File

@ -154,15 +154,22 @@ export const cssRootVars = cssBodyVars.className;
// class ".test-{name}". Ideally, we'd use noTestId() instead in production. // class ".test-{name}". Ideally, we'd use noTestId() instead in production.
export const testId: TestId = makeTestId('test-'); export const testId: TestId = makeTestId('test-');
// Max width for narrow screen layout (in px). Note: 768px is bootstrap's definition of small screen // Min width for normal screen layout (in px). Note: <768px is bootstrap's definition of small
export const maxNarrowScreenWidth = 768; // 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() { export function isNarrowScreen() {
return window.innerWidth <= 768; return window.innerWidth <= 768;
} }
export const cssHideForNarrowScreen = styled('div', ` export const cssHideForNarrowScreen = styled('div', `
@media (max-width: ${maxNarrowScreenWidth}px) { @media ${mediaSmall} {
& { & {
display: none !important; display: none !important;
} }