Skip to content

Commit

Permalink
chore(ui): Dialog UI for upcoming settings menu
Browse files Browse the repository at this point in the history
  • Loading branch information
agg23 committed Dec 17, 2024
1 parent aabbcbf commit f0a0831
Show file tree
Hide file tree
Showing 4 changed files with 218 additions and 28 deletions.
20 changes: 11 additions & 9 deletions packages/trace-viewer/src/ui/settingsView.css
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,27 @@
*/

.settings-view {
display: flex;
flex: none;
margin-top: 4px;
padding: 4px 0px;
row-gap: 8px;
user-select: none;
}

.settings-view .setting label {
.settings-view .setting {
display: flex;
flex-direction: row;
align-items: center;
margin: 4px 2px;
}

.settings-view .setting:first-of-type label {
margin-top: 2px;
}
.settings-view .setting label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;

.settings-view .setting:last-of-type label {
margin-bottom: 2px;
cursor: pointer;
}

.settings-view .setting input {
margin-right: 5px;
flex-shrink: 0;
}
56 changes: 41 additions & 15 deletions packages/trace-viewer/src/ui/settingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,48 @@ import * as React from 'react';
import './settingsView.css';

export type Setting<T> = {
value: T,
set: (value: T) => void,
title: string
/**
* The current value of the setting
*/
value: T;
/**
* A setter method to strongly control the setting value
* @param value The new value to set
*/
set: (value: T) => void;
/**
* The user friendly name
*/
name: string;
/**
* If provided, the HTML title attribute (hover text). If not provided, the `name` will be reused
*/
title?: string;
};

/**
* A list of settings controlled by a checkbox
*/
export const SettingsView: React.FunctionComponent<{
settings: Setting<boolean>[],
settings: Setting<boolean>[];
}> = ({ settings }) => {
return <div className='vbox settings-view'>
{settings.map(({ value, set, title }) => {
return <div key={title} className='setting'>
<label>
<input type='checkbox' checked={value} onChange={() => set(!value)}/>
{title}
</label>
</div>;
})}
</div>;
};
return (
<div className='vbox settings-view'>
{settings.map(({ value, set, name, title }) => {
const labelId = `setting-${name}`;

return (
<div key={name} className='setting' title={title ?? name}>
<input
type='checkbox'
id={labelId}
checked={value}
onChange={() => set(!value)}
/>
<label htmlFor={labelId}>{name}</label>
</div>
);
})}
</div>
);
};
162 changes: 162 additions & 0 deletions packages/trace-viewer/src/ui/shared/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import * as React from 'react';

export interface DialogProps {
className?: string;

open: boolean;
width: number;

verticalOffset?: number;

requestClose?: () => void;

hostingElement?: React.RefObject<HTMLElement>;
}

/**
* A dropdown dialog, positioned below the `hostingElement`. Uses a HTML `dialog` element
*/
export const Dialog: React.FC<React.PropsWithChildren<DialogProps>> = ({
className,
open,
width,
verticalOffset,
requestClose,
hostingElement,
children,
}) => {
const dialogRef = React.useRef<HTMLDialogElement>(null);

// Allow window dimension changes to force a rerender
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, setRecalculateDimensionsCount] = React.useState(0);

let style: React.CSSProperties | undefined = undefined;

if (hostingElement?.current) {
// For now, always place dialog below hosting element
const bounds = hostingElement.current.getBoundingClientRect();

style = {
// Override default `<dialog>` positioning
margin: 0,
top: bounds.bottom + (verticalOffset ?? 0),
left: buildTopLeftCoord(bounds, width),
width,
// For some reason the dialog is placed behind the timeline, but there's a stacking context that allows the dialog to be placed above
zIndex: 1,
};
}

React.useEffect(() => {
const onClick = (event: MouseEvent) => {
if (!dialogRef.current || !(event.target instanceof Node))
return;

if (!dialogRef.current.contains(event.target)) {
// Click outside of dialog bounds
requestClose?.();
}
};

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
requestClose?.();
};

if (open) {
document.addEventListener('mousedown', onClick);
document.addEventListener('keydown', onKeyDown);

return () => {
document.removeEventListener('mousedown', onClick);
document.removeEventListener('keydown', onKeyDown);
};
}

return () => {};
}, [open, requestClose]);

React.useEffect(() => {
const onResize = () => setRecalculateDimensionsCount(count => count + 1);

window.addEventListener('resize', onResize);

return () => {
window.removeEventListener('resize', onResize);
};
}, []);

return (
open && (
<dialog ref={dialogRef} style={style} className={className} open>
{children}
</dialog>
)
);
};

const buildTopLeftCoord = (bounds: DOMRect, width: number): number => {
// Default to left aligned
const leftAlignCoord = buildTopLeftCoordWithAlignment(bounds, width, 'left');

if (leftAlignCoord.inBounds)
return leftAlignCoord.value;

const rightAlignCoord = buildTopLeftCoordWithAlignment(
bounds,
width,
'right'
);

if (rightAlignCoord.inBounds)
return rightAlignCoord.value;

// Fallback to left align, even if it will go off screen
return leftAlignCoord.value;
};

const buildTopLeftCoordWithAlignment = (
bounds: DOMRect,
width: number,
alignment: 'left' | 'right'
): {
value: number;
inBounds: boolean;
} => {
const maxLeft = document.documentElement.clientWidth;

if (alignment === 'left') {
const value = bounds.left;

return {
value,
// Would extend off of right side of screen
inBounds: value + width <= maxLeft,
};
} else {
const value = bounds.right - width;

return {
value,
// Would extend off of left side of screen
inBounds: bounds.right - width >= 0,
};
}
};
8 changes: 4 additions & 4 deletions packages/trace-viewer/src/ui/uiModeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,9 @@ export const UIModeView: React.FC<{}> = ({
<div className='section-title'>Testing Options</div>
</Toolbar>
{testingOptionsVisible && <SettingsView settings={[
{ value: singleWorker, set: setSingleWorker, title: 'Single worker' },
{ value: showBrowser, set: setShowBrowser, title: 'Show browser' },
{ value: updateSnapshots, set: setUpdateSnapshots, title: 'Update snapshots' },
{ value: singleWorker, set: setSingleWorker, name: 'Single worker' },
{ value: showBrowser, set: setShowBrowser, name: 'Show browser' },
{ value: updateSnapshots, set: setUpdateSnapshots, name: 'Update snapshots' },
]} />}
</>}
<Toolbar noShadow={true} noMinHeight={true} className='settings-toolbar' onClick={() => setSettingsVisible(!settingsVisible)}>
Expand All @@ -522,7 +522,7 @@ export const UIModeView: React.FC<{}> = ({
<div className='section-title'>Settings</div>
</Toolbar>
{settingsVisible && <SettingsView settings={[
{ value: darkMode, set: setDarkMode, title: 'Dark mode' },
{ value: darkMode, set: setDarkMode, name: 'Dark mode' },
]} />}
</div>
}
Expand Down

0 comments on commit f0a0831

Please sign in to comment.