mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Allow left pane to auto-expand on mouse over
Summary: Tweak PagePanels to let the left pane automatically expand on mouse over. This is to make pages more accessible when the panel is collapsed. In this context, when expanding, the left panel overlap the main content, reducing visual clutter. Test Plan: updated Reviewers: jarek Reviewed By: jarek Subscribers: anaisconce, jarek Differential Revision: https://phab.getgrist.com/D3516
This commit is contained in:
parent
c54dde3dba
commit
80f31bffc2
@ -186,7 +186,7 @@ export class FocusLayer extends Disposable implements FocusLayerOptions {
|
|||||||
* Because elements getting removed from the DOM don't always trigger 'blur' event, this also
|
* Because elements getting removed from the DOM don't always trigger 'blur' event, this also
|
||||||
* uses MutationObserver to watch for the element to get removed from DOM.
|
* uses MutationObserver to watch for the element to get removed from DOM.
|
||||||
*/
|
*/
|
||||||
function watchElementForBlur(elem: Element, callback: () => void) {
|
export function watchElementForBlur(elem: Element, callback: () => void) {
|
||||||
const maybeDone = () => {
|
const maybeDone = () => {
|
||||||
if (document.activeElement !== elem) {
|
if (document.activeElement !== elem) {
|
||||||
lis.dispose();
|
lis.dispose();
|
||||||
|
@ -5,6 +5,10 @@
|
|||||||
import {safeJsonParse} from 'app/common/gutil';
|
import {safeJsonParse} from 'app/common/gutil';
|
||||||
import {IDisposableOwner, Observable} from 'grainjs';
|
import {IDisposableOwner, Observable} from 'grainjs';
|
||||||
|
|
||||||
|
export interface SessionObs<T> extends Observable<T> {
|
||||||
|
pauseSaving(yesNo: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates and returns an Observable tied to sessionStorage, to make its value stick across
|
* Creates and returns an Observable tied to sessionStorage, to make its value stick across
|
||||||
* reloads and navigation, but differ across browser tabs. E.g. whether a side pane is open.
|
* reloads and navigation, but differ across browser tabs. E.g. whether a side pane is open.
|
||||||
@ -20,13 +24,19 @@ import {IDisposableOwner, Observable} from 'grainjs';
|
|||||||
* import {StringUnion} from 'app/common/StringUnion';
|
* import {StringUnion} from 'app/common/StringUnion';
|
||||||
* const SomeTab = StringUnion("foo", "bar", "baz");
|
* const SomeTab = StringUnion("foo", "bar", "baz");
|
||||||
* tab = createSessionObs(owner, "tab", "baz", SomeTab.guard); // Type Observable<"foo"|"bar"|"baz">
|
* tab = createSessionObs(owner, "tab", "baz", SomeTab.guard); // Type Observable<"foo"|"bar"|"baz">
|
||||||
|
*
|
||||||
|
* You can disable saving to sessionStorage:
|
||||||
|
* panelWidth.pauseSaving(true);
|
||||||
|
* doStuff();
|
||||||
|
* panelWidth.pauseSaving(false);
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
export function createSessionObs<T>(
|
export function createSessionObs<T>(
|
||||||
owner: IDisposableOwner|null,
|
owner: IDisposableOwner|null,
|
||||||
key: string,
|
key: string,
|
||||||
_default: T,
|
_default: T,
|
||||||
isValid: (val: any) => val is T,
|
isValid: (val: any) => val is T,
|
||||||
) {
|
): SessionObs<T> {
|
||||||
function fromString(value: string|null): T {
|
function fromString(value: string|null): T {
|
||||||
const parsed = value == null ? null : safeJsonParse(value, null);
|
const parsed = value == null ? null : safeJsonParse(value, null);
|
||||||
return isValid(parsed) ? parsed : _default;
|
return isValid(parsed) ? parsed : _default;
|
||||||
@ -34,9 +44,10 @@ export function createSessionObs<T>(
|
|||||||
function toString(value: T): string|null {
|
function toString(value: T): string|null {
|
||||||
return value === _default || !isValid(value) ? null : JSON.stringify(value);
|
return value === _default || !isValid(value) ? null : JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
let _pauseSaving = false;
|
||||||
const obs = Observable.create<T>(owner, fromString(window.sessionStorage.getItem(key)));
|
const obs = Observable.create<T>(owner, fromString(window.sessionStorage.getItem(key)));
|
||||||
obs.addListener((value: T) => {
|
obs.addListener((value: T) => {
|
||||||
|
if (_pauseSaving) { return; }
|
||||||
const stored = toString(value);
|
const stored = toString(value);
|
||||||
if (stored == null) {
|
if (stored == null) {
|
||||||
window.sessionStorage.removeItem(key);
|
window.sessionStorage.removeItem(key);
|
||||||
@ -44,7 +55,7 @@ export function createSessionObs<T>(
|
|||||||
window.sessionStorage.setItem(key, stored);
|
window.sessionStorage.setItem(key, stored);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return obs;
|
return Object.assign(obs, {pauseSaving(yesNo: boolean) { _pauseSaving = yesNo; }});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Helper functions to check simple types, useful for the `isValid` argument to createSessionObs. */
|
/** Helper functions to check simple types, useful for the `isValid` argument to createSessionObs. */
|
||||||
|
@ -138,7 +138,7 @@ const cssDropdownIcon = styled(icon, `
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const cssOrg = styled('div', `
|
const cssOrg = styled('div', `
|
||||||
display: flex;
|
display: none;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
max-width: calc(100% - 48px);
|
max-width: calc(100% - 48px);
|
||||||
@ -149,6 +149,10 @@ const cssOrg = styled('div', `
|
|||||||
&:hover {
|
&:hover {
|
||||||
background-color: ${colors.mediumGrey};
|
background-color: ${colors.mediumGrey};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.${cssLeftPane.className}-open & {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const cssOrgName = styled('div', `
|
const cssOrgName = styled('div', `
|
||||||
|
@ -2,13 +2,23 @@
|
|||||||
* Note that it assumes the presence of cssVars.cssRootVars on <body>.
|
* Note that it assumes the presence of cssVars.cssRootVars on <body>.
|
||||||
*/
|
*/
|
||||||
import * as commands from 'app/client/components/commands';
|
import * as commands from 'app/client/components/commands';
|
||||||
|
import {watchElementForBlur} from 'app/client/lib/FocusLayer';
|
||||||
import {urlState} from "app/client/models/gristUrlState";
|
import {urlState} from "app/client/models/gristUrlState";
|
||||||
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
|
import {resizeFlexVHandle} from 'app/client/ui/resizeHandle';
|
||||||
import {transition, TransitionWatcher} from 'app/client/ui/transitions';
|
import {transition, TransitionWatcher} from 'app/client/ui/transitions';
|
||||||
import {colors, cssHideForNarrowScreen, 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 {isNarrowScreenObs} 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, subscribe, TestId} from "grainjs";
|
import {dom, DomArg, MultiHolder, noTestId, Observable, styled, subscribe, TestId} from "grainjs";
|
||||||
|
import noop from 'lodash/noop';
|
||||||
|
import once from 'lodash/once';
|
||||||
|
import {SessionObs} from 'app/client/lib/sessionObs';
|
||||||
|
import debounce from 'lodash/debounce';
|
||||||
|
|
||||||
|
const AUTO_EXPAND_TIMEOUT_MS = 400;
|
||||||
|
|
||||||
|
// delay must be greater than the time needed for transientInput to update focus (ie: 10ms);
|
||||||
|
const DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS = 12;
|
||||||
|
|
||||||
export interface PageSidePanel {
|
export interface PageSidePanel {
|
||||||
// Note that widths need to start out with a correct default in JS (having them in CSS is not
|
// Note that widths need to start out with a correct default in JS (having them in CSS is not
|
||||||
@ -38,10 +48,13 @@ export function pagePanels(page: PageContents) {
|
|||||||
const left = page.leftPanel;
|
const left = page.leftPanel;
|
||||||
const right = page.rightPanel;
|
const right = page.rightPanel;
|
||||||
const onResize = page.onResize || (() => null);
|
const onResize = page.onResize || (() => null);
|
||||||
|
const leftOverlap = Observable.create(null, false);
|
||||||
|
const dragResizer = Observable.create(null, false);
|
||||||
|
|
||||||
let lastLeftOpen = left.panelOpen.get();
|
let lastLeftOpen = left.panelOpen.get();
|
||||||
let lastRightOpen = right?.panelOpen.get() || false;
|
let lastRightOpen = right?.panelOpen.get() || false;
|
||||||
let leftPaneDom: HTMLElement;
|
let leftPaneDom: HTMLElement;
|
||||||
|
let onLeftTransitionFinish = noop;
|
||||||
|
|
||||||
// When switching to mobile mode, close panels; when switching to desktop, restore the
|
// When switching to mobile mode, close panels; when switching to desktop, restore the
|
||||||
// last desktop state.
|
// last desktop state.
|
||||||
@ -62,6 +75,10 @@ export function pagePanels(page: PageContents) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pauseSavingLeft = (yesNo: boolean) => {
|
||||||
|
(left.panelOpen as SessionObs<boolean>)?.pauseSaving?.(yesNo);
|
||||||
|
};
|
||||||
|
|
||||||
const commandsGroup = commands.createGroup({
|
const commandsGroup = commands.createGroup({
|
||||||
leftPanelOpen: () => new Promise((resolve) => {
|
leftPanelOpen: () => new Promise((resolve) => {
|
||||||
const watcher = new TransitionWatcher(leftPaneDom);
|
const watcher = new TransitionWatcher(leftPaneDom);
|
||||||
@ -69,40 +86,125 @@ export function pagePanels(page: PageContents) {
|
|||||||
left.panelOpen.set(true);
|
left.panelOpen.set(true);
|
||||||
}),
|
}),
|
||||||
}, null, true);
|
}, null, true);
|
||||||
|
let contentWrapper: HTMLElement;
|
||||||
return cssPageContainer(
|
return cssPageContainer(
|
||||||
dom.autoDispose(sub1),
|
dom.autoDispose(sub1),
|
||||||
dom.autoDispose(sub2),
|
dom.autoDispose(sub2),
|
||||||
dom.autoDispose(commandsGroup),
|
dom.autoDispose(commandsGroup),
|
||||||
|
dom.autoDispose(leftOverlap),
|
||||||
page.contentTop,
|
page.contentTop,
|
||||||
cssContentMain(
|
cssContentMain(
|
||||||
leftPaneDom = cssLeftPane(
|
leftPaneDom = cssLeftPane(
|
||||||
testId('left-panel'),
|
testId('left-panel'),
|
||||||
cssTopHeader(left.header),
|
cssOverflowContainer(
|
||||||
left.content,
|
contentWrapper = cssLeftPanelContainer(
|
||||||
|
cssTopHeader(left.header),
|
||||||
|
left.content,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show plain border when the resize handle is hidden.
|
||||||
|
cssResizeDisabledBorder(
|
||||||
|
dom.hide((use) => use(left.panelOpen) && !use(leftOverlap)),
|
||||||
|
cssHideForNarrowScreen.cls(''),
|
||||||
|
testId('left-disabled-resizer'),
|
||||||
|
),
|
||||||
|
|
||||||
dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''),
|
dom.style('width', (use) => use(left.panelOpen) ? use(left.panelWidth) + 'px' : ''),
|
||||||
|
|
||||||
// Opening/closing the left pane, with transitions.
|
// Opening/closing the left pane, with transitions.
|
||||||
cssLeftPane.cls('-open', left.panelOpen),
|
cssLeftPane.cls('-open', left.panelOpen),
|
||||||
transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), {
|
transition(use => (use(isNarrowScreenObs()) ? false : use(left.panelOpen)), {
|
||||||
prepare(elem, open) { elem.style.marginRight = (open ? -1 : 1) * (left.panelWidth.get() - 48) + 'px'; },
|
prepare(elem, open) {
|
||||||
run(elem, open) { elem.style.marginRight = ''; },
|
elem.style.width = (open ? 48 : left.panelWidth.get()) + 'px';
|
||||||
finish: onResize,
|
},
|
||||||
|
run(elem, open) {
|
||||||
|
elem.style.width = contentWrapper.style.width = (open ? left.panelWidth.get() : 48) + 'px';
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
onResize();
|
||||||
|
contentWrapper.style.width = '';
|
||||||
|
onLeftTransitionFinish();
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// opening left panel on over
|
||||||
|
dom.on('mouseenter', (_ev, elem) => {
|
||||||
|
if (left.panelOpen.get()) { return; }
|
||||||
|
|
||||||
|
let isMouseInsideLeftPane = true;
|
||||||
|
let isFocusInsideLeftPane = false;
|
||||||
|
let isMouseDragging = false;
|
||||||
|
|
||||||
|
const owner = new MultiHolder();
|
||||||
|
const startExpansion = () => {
|
||||||
|
leftOverlap.set(true);
|
||||||
|
pauseSavingLeft(true); // prevents from updating state in the window storage
|
||||||
|
left.panelOpen.set(true);
|
||||||
|
onLeftTransitionFinish = noop;
|
||||||
|
watchBlur();
|
||||||
|
};
|
||||||
|
const startCollapse = () => {
|
||||||
|
left.panelOpen.set(false);
|
||||||
|
pauseSavingLeft(false);
|
||||||
|
// turns overlap off only when the transition finishes
|
||||||
|
onLeftTransitionFinish = once(() => leftOverlap.set(false));
|
||||||
|
clear();
|
||||||
|
};
|
||||||
|
const clear = () => {
|
||||||
|
if (owner.isDisposed()) { return; }
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
owner.dispose();
|
||||||
|
};
|
||||||
|
dom.onDisposeElem(elem, clear);
|
||||||
|
|
||||||
|
// updates isFocusInsideLeftPane and starts watch for blur on activeElement.
|
||||||
|
const watchBlur = debounce(() => {
|
||||||
|
if (owner.isDisposed()) { return; }
|
||||||
|
// console.warn('watchBlur', document.activeElement);
|
||||||
|
isFocusInsideLeftPane = Boolean(leftPaneDom.contains(document.activeElement) ||
|
||||||
|
document.activeElement?.closest('.grist-floating-menu'));
|
||||||
|
maybeStartCollapse();
|
||||||
|
if (document.activeElement) {
|
||||||
|
maybePatchDomAndChangeFocus(); // This is to support projects test environment
|
||||||
|
watchElementForBlur(document.activeElement, watchBlur);
|
||||||
|
}
|
||||||
|
}, DELAY_BEFORE_TESTING_FOCUS_CHANGE_MS);
|
||||||
|
|
||||||
|
// starts collapsed only if neither mouse nor focus are inside the left pane. Return true
|
||||||
|
// if started collapsed, false otherwise.
|
||||||
|
const maybeStartCollapse = () => {
|
||||||
|
if (!isMouseInsideLeftPane && !isFocusInsideLeftPane && !isMouseDragging) {
|
||||||
|
startCollapse();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// mouse events
|
||||||
|
const onMouseEvt = (evt: MouseEvent) => {
|
||||||
|
const rect = leftPaneDom.getBoundingClientRect();
|
||||||
|
isMouseInsideLeftPane = evt.clientX <= rect.right;
|
||||||
|
isMouseDragging = evt.buttons !== 0;
|
||||||
|
maybeStartCollapse();
|
||||||
|
};
|
||||||
|
owner.autoDispose(dom.onElem(document, 'mousemove', onMouseEvt));
|
||||||
|
owner.autoDispose(dom.onElem(document, 'mouseup', onMouseEvt));
|
||||||
|
|
||||||
|
// schedule start of expansion
|
||||||
|
const timeoutId = setTimeout(startExpansion, AUTO_EXPAND_TIMEOUT_MS);
|
||||||
|
}),
|
||||||
|
cssLeftPane.cls('-overlap', leftOverlap),
|
||||||
|
cssLeftPane.cls('-dragging', dragResizer),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Resizer for the left pane.
|
// Resizer for the left pane.
|
||||||
// TODO: resizing to small size should collapse. possibly should allow expanding too
|
// TODO: resizing to small size should collapse. possibly should allow expanding too
|
||||||
cssResizeFlexVHandle(
|
cssResizeFlexVHandle(
|
||||||
{target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize(); }},
|
{target: 'left', onSave: (val) => { left.panelWidth.set(val); onResize();
|
||||||
|
leftPaneDom.style['width'] = val + 'px';
|
||||||
|
setTimeout(() => dragResizer.set(false), 0); },
|
||||||
|
onDrag: (val) => { dragResizer.set(true); }},
|
||||||
testId('left-resizer'),
|
testId('left-resizer'),
|
||||||
dom.show(left.panelOpen),
|
dom.show((use) => use(left.panelOpen) && !use(leftOverlap)),
|
||||||
cssHideForNarrowScreen.cls('')),
|
|
||||||
|
|
||||||
// Show plain border when the resize handle is hidden.
|
|
||||||
cssResizeDisabledBorder(
|
|
||||||
dom.hide(left.panelOpen),
|
|
||||||
cssHideForNarrowScreen.cls('')),
|
cssHideForNarrowScreen.cls('')),
|
||||||
|
|
||||||
cssMainPane(
|
cssMainPane(
|
||||||
@ -126,6 +228,7 @@ export function pagePanels(page: PageContents) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
page.contentMain,
|
page.contentMain,
|
||||||
|
cssMainPane.cls('-left-overlap', leftOverlap),
|
||||||
testId('main-pane'),
|
testId('main-pane'),
|
||||||
),
|
),
|
||||||
(right ? [
|
(right ? [
|
||||||
@ -236,8 +339,8 @@ export const cssLeftPane = styled(cssVBox, `
|
|||||||
background-color: ${colors.lightGrey};
|
background-color: ${colors.lightGrey};
|
||||||
width: 48px;
|
width: 48px;
|
||||||
margin-right: 0px;
|
margin-right: 0px;
|
||||||
overflow: hidden;
|
transition: width 0.4s;
|
||||||
transition: margin-right 0.4s;
|
will-change: width;
|
||||||
@media ${mediaSmall} {
|
@media ${mediaSmall} {
|
||||||
& {
|
& {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
@ -258,8 +361,6 @@ export const cssLeftPane = styled(cssVBox, `
|
|||||||
}
|
}
|
||||||
&-open {
|
&-open {
|
||||||
width: 240px;
|
width: 240px;
|
||||||
min-width: 160px;
|
|
||||||
max-width: 320px;
|
|
||||||
}
|
}
|
||||||
@media print {
|
@media print {
|
||||||
& {
|
& {
|
||||||
@ -269,6 +370,23 @@ export const cssLeftPane = styled(cssVBox, `
|
|||||||
.interface-light & {
|
.interface-light & {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
&-overlap {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
&-dragging {
|
||||||
|
transition: unset;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
const cssOverflowContainer = styled(cssVBox, `
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 0px;
|
||||||
`);
|
`);
|
||||||
const cssMainPane = styled(cssVBox, `
|
const cssMainPane = styled(cssVBox, `
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -276,6 +394,9 @@ const cssMainPane = styled(cssVBox, `
|
|||||||
min-width: 0px;
|
min-width: 0px;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
&-left-overlap {
|
||||||
|
margin-left: 48px;
|
||||||
|
}
|
||||||
`);
|
`);
|
||||||
const cssRightPane = styled(cssVBox, `
|
const cssRightPane = styled(cssVBox, `
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -378,6 +499,11 @@ const cssResizeDisabledBorder = styled('div', `
|
|||||||
width: 1px;
|
width: 1px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: ${colors.mediumGrey};
|
background-color: ${colors.mediumGrey};
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: -1px;
|
||||||
|
z-index: 2;
|
||||||
`);
|
`);
|
||||||
const cssPanelOpener = styled(icon, `
|
const cssPanelOpener = styled(icon, `
|
||||||
flex: none;
|
flex: none;
|
||||||
@ -423,3 +549,28 @@ const cssContentOverlay = styled('div', `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`);
|
`);
|
||||||
|
const cssLeftPanelContainer = styled('div', `
|
||||||
|
flex: 1 1 0px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`);
|
||||||
|
const cssHiddenInput = styled('input', `
|
||||||
|
position: absolute;
|
||||||
|
top: -100px;
|
||||||
|
left: 0;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
font-size: 1;
|
||||||
|
z-index: -1;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// watchElementForBlur does not work if focus is on body. Which never happens when running in Grist
|
||||||
|
// because focus is constantly given to the copypasteField. But it does happen when running inside a
|
||||||
|
// projects test. For that latter case we had a hidden <input> field to the dom and give it focus.
|
||||||
|
function maybePatchDomAndChangeFocus() {
|
||||||
|
if (document.activeElement?.matches('body')) {
|
||||||
|
const hiddenInput = cssHiddenInput();
|
||||||
|
document.body.appendChild(hiddenInput);
|
||||||
|
hiddenInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user