mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-22 15:39:11 +00:00
refactor(ui): polish navigation menu and view params editor
This commit is contained in:
committed by
Carlos Valente
parent
703dee35a4
commit
4cc1a003c0
@@ -0,0 +1,6 @@
|
|||||||
|
.eyebrow {
|
||||||
|
font-size: $aux-text-size;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
|
||||||
|
import style from './Eyebrow.module.scss';
|
||||||
|
|
||||||
|
interface EyebrowProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Eyebrow({ children, className }: PropsWithChildren<EyebrowProps>) {
|
||||||
|
return <span className={cx([style.eyebrow, className])}>{children}</span>;
|
||||||
|
}
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
.separator {
|
|
||||||
border-color: $border-color-ondark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -23,14 +19,15 @@
|
|||||||
|
|
||||||
width: 22rem;
|
width: 22rem;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
height: 100dvh;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-block: 1rem;
|
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $gray-1250;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-right: 1px solid $gray-1100;
|
border-right: 1px solid $gray-1100;
|
||||||
|
box-shadow: $box-shadow-right;
|
||||||
|
|
||||||
&[data-open] {
|
&[data-open] {
|
||||||
transform: translateX(0%);
|
transform: translateX(0%);
|
||||||
@@ -50,22 +47,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 3.5rem;
|
gap: 1rem;
|
||||||
padding-inline: 1.5rem;
|
min-height: 4rem;
|
||||||
|
padding: 0.75rem 1rem 0.75rem 1.25rem;
|
||||||
|
border-bottom: 1px solid $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clientName {
|
||||||
|
font-size: $aux-text-size;
|
||||||
|
color: $gray-600;
|
||||||
|
@include ellipsis-text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
padding-block: 0.5rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note {
|
.group {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
color: $white-20;
|
flex-direction: column;
|
||||||
|
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
padding: 0.5rem 1.25rem 0.25rem;
|
||||||
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Dialog } from '@base-ui/react/dialog';
|
import { Dialog } from '@base-ui/react/dialog';
|
||||||
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
import { useDisclosure, useFullscreenDocument } from '@mantine/hooks';
|
||||||
import { memo } from 'react';
|
import { memo, PropsWithChildren } from 'react';
|
||||||
import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5';
|
import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5';
|
||||||
import { LuCoffee } from 'react-icons/lu';
|
import { LuCoffee } from 'react-icons/lu';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
@@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore';
|
|||||||
import { useViewOptionsStore } from '../../stores/viewOptions';
|
import { useViewOptionsStore } from '../../stores/viewOptions';
|
||||||
import IconButton from '../buttons/IconButton';
|
import IconButton from '../buttons/IconButton';
|
||||||
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
import { RenameClientModal } from '../client-modal/RenameClientModal';
|
||||||
|
import Eyebrow from '../eyebrow/Eyebrow';
|
||||||
import ClientLink from './client-link/ClientLink';
|
import ClientLink from './client-link/ClientLink';
|
||||||
import EditorNavigation from './editor-navigation/EditorNavigation';
|
import EditorNavigation from './editor-navigation/EditorNavigation';
|
||||||
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem';
|
||||||
|
import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle';
|
||||||
import OtherAddresses from './other-addresses/OtherAddresses';
|
import OtherAddresses from './other-addresses/OtherAddresses';
|
||||||
|
|
||||||
import style from './NavigationMenu.module.scss';
|
import style from './NavigationMenu.module.scss';
|
||||||
@@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
|
|||||||
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
<RenameClientModal id={id} name={name} isOpen={isRenameOpen} onClose={handlers.close} />
|
||||||
<Dialog.Popup className={style.drawer}>
|
<Dialog.Popup className={style.drawer}>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<Dialog.Title>Ontime</Dialog.Title>
|
<div className={style.headerText}>
|
||||||
<IconButton variant='subtle-white' size='large' onClick={onClose}>
|
<Dialog.Title className={style.title}>Ontime</Dialog.Title>
|
||||||
|
{name && <span className={style.clientName}>{name}</span>}
|
||||||
|
</div>
|
||||||
|
<IconButton variant='subtle-white' size='large' aria-label='Close menu' onClick={onClose}>
|
||||||
<IoClose />
|
<IoClose />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.body}>
|
<div className={style.body}>
|
||||||
{supportsFullscreen && (
|
<MenuGroup label='This screen'>
|
||||||
<NavigationMenuItem active={fullscreen} onClick={toggle}>
|
{supportsFullscreen && (
|
||||||
Toggle Fullscreen
|
<NavigationMenuToggle checked={fullscreen} icon={<IoExpand />} label='Fullscreen' onToggle={toggle} />
|
||||||
{fullscreen ? <IoContract /> : <IoExpand />}
|
)}
|
||||||
|
<NavigationMenuToggle
|
||||||
|
checked={mirror}
|
||||||
|
icon={<IoSwapVertical />}
|
||||||
|
label='Flip Screen'
|
||||||
|
onToggle={() => toggleMirror()}
|
||||||
|
/>
|
||||||
|
{canUseWakeLock && (
|
||||||
|
<NavigationMenuToggle
|
||||||
|
checked={keepAwake}
|
||||||
|
icon={<LuCoffee />}
|
||||||
|
label='Keep Awake'
|
||||||
|
onToggle={toggleKeepAwake}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<NavigationMenuItem onClick={handlers.open}>
|
||||||
|
<IoPencilOutline />
|
||||||
|
Rename Client
|
||||||
</NavigationMenuItem>
|
</NavigationMenuItem>
|
||||||
)}
|
</MenuGroup>
|
||||||
<NavigationMenuItem active={mirror} onClick={() => toggleMirror()}>
|
|
||||||
Flip Screen
|
|
||||||
<IoSwapVertical />
|
|
||||||
{mirror && <span className={style.note}>Active</span>}
|
|
||||||
</NavigationMenuItem>
|
|
||||||
{canUseWakeLock && (
|
|
||||||
<NavigationMenuItem active={keepAwake} onClick={toggleKeepAwake}>
|
|
||||||
Keep Awake
|
|
||||||
<LuCoffee />
|
|
||||||
{keepAwake && <span className={style.note}>Active</span>}
|
|
||||||
</NavigationMenuItem>
|
|
||||||
)}
|
|
||||||
<NavigationMenuItem onClick={handlers.open}>Rename Client</NavigationMenuItem>
|
|
||||||
|
|
||||||
<hr className={style.separator} />
|
<MenuGroup label='Ontime app'>
|
||||||
|
<EditorNavigation />
|
||||||
<EditorNavigation />
|
|
||||||
<ClientLink
|
|
||||||
to='cuesheet'
|
|
||||||
current={location.pathname === '/cuesheet'}
|
|
||||||
postAction={isSmallScreen ? onClose : undefined}
|
|
||||||
>
|
|
||||||
<IoLockClosedOutline />
|
|
||||||
Cuesheet
|
|
||||||
</ClientLink>
|
|
||||||
<ClientLink to='op' current={location.pathname === '/op'} postAction={isSmallScreen ? onClose : undefined}>
|
|
||||||
<IoLockClosedOutline />
|
|
||||||
Operator
|
|
||||||
</ClientLink>
|
|
||||||
|
|
||||||
<hr className={style.separator} />
|
|
||||||
|
|
||||||
{navigatorConstants.map((route) => (
|
|
||||||
<ClientLink
|
<ClientLink
|
||||||
key={route.url}
|
to='cuesheet'
|
||||||
to={route.url}
|
current={location.pathname === '/cuesheet'}
|
||||||
current={location.pathname === `/${route.url}`}
|
|
||||||
postAction={isSmallScreen ? onClose : undefined}
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
>
|
>
|
||||||
{route.label}
|
<IoLockClosedOutline />
|
||||||
|
Cuesheet
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
<ClientLink
|
||||||
|
to='op'
|
||||||
|
current={location.pathname === '/op'}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
|
<IoLockClosedOutline />
|
||||||
|
Operator
|
||||||
|
</ClientLink>
|
||||||
|
</MenuGroup>
|
||||||
|
|
||||||
|
<MenuGroup label='Views'>
|
||||||
|
{navigatorConstants.map((route) => (
|
||||||
|
<ClientLink
|
||||||
|
key={route.url}
|
||||||
|
to={route.url}
|
||||||
|
current={location.pathname === `/${route.url}`}
|
||||||
|
postAction={isSmallScreen ? onClose : undefined}
|
||||||
|
>
|
||||||
|
{route.label}
|
||||||
|
</ClientLink>
|
||||||
|
))}
|
||||||
|
</MenuGroup>
|
||||||
|
|
||||||
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
<PresetNavigation isSmallScreen={isSmallScreen} onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLocalhost && (
|
{isLocalhost && <OtherAddresses currentLocation={location.pathname} />}
|
||||||
<div>
|
|
||||||
<OtherAddresses currentLocation={location.pathname} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Dialog.Popup>
|
</Dialog.Popup>
|
||||||
</Dialog.Portal>
|
</Dialog.Portal>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
|
||||||
|
return (
|
||||||
|
<div className={style.group}>
|
||||||
|
<div className={style.groupLabel}>
|
||||||
|
<Eyebrow>{label}</Eyebrow>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: urlPresets } = useUrlPresets();
|
const { data: urlPresets } = useUrlPresets();
|
||||||
@@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
|||||||
if (navPresets.length === 0) return null;
|
if (navPresets.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MenuGroup label='Presets'>
|
||||||
<hr className={style.separator} />
|
|
||||||
{navPresets.map((preset) => (
|
{navPresets.map((preset) => (
|
||||||
<ClientLink
|
<ClientLink
|
||||||
key={preset.alias}
|
key={preset.alias}
|
||||||
@@ -141,6 +160,6 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean;
|
|||||||
{preset.alias}
|
{preset.alias}
|
||||||
</ClientLink>
|
</ClientLink>
|
||||||
))}
|
))}
|
||||||
</>
|
</MenuGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useDisclosure, useHotkeys } from '@mantine/hooks';
|
import { useHotkeys } from '@mantine/hooks';
|
||||||
import { memo } from 'react';
|
import { memo, useCallback, useState } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { hasCustomParams } from '../../stores/savedViewParams';
|
import { hasCustomParams } from '../../stores/savedViewParams';
|
||||||
@@ -15,21 +15,37 @@ interface ViewNavigationMenuProps {
|
|||||||
suppressSettings?: boolean;
|
suppressSettings?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isInteractiveKeyboardAction(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
target.closest(
|
||||||
|
'button, a, input, textarea, select, [role="button"], [role="checkbox"], [role="switch"], [contenteditable]:not([contenteditable="false"])',
|
||||||
|
) !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default memo(ViewNavigationMenu);
|
export default memo(ViewNavigationMenu);
|
||||||
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) {
|
||||||
const [isMenuOpen, menuHandler] = useDisclosure();
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
const { open: showEditFormDrawer } = useViewParamsEditorStore();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const hasSavedChanges = hasCustomParams(searchParams);
|
const hasSavedChanges = hasCustomParams(searchParams);
|
||||||
|
|
||||||
|
const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []);
|
||||||
|
const closeMenu = useCallback(() => setIsMenuOpen(false), []);
|
||||||
|
|
||||||
useHotkeys([
|
useHotkeys([
|
||||||
[
|
[
|
||||||
'Space',
|
'Space',
|
||||||
() => {
|
(event) => {
|
||||||
if (isNavigationLocked) return;
|
if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return;
|
||||||
menuHandler.toggle();
|
event.preventDefault();
|
||||||
|
toggleMenu();
|
||||||
},
|
},
|
||||||
{ preventDefault: true },
|
{ preventDefault: false },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'mod + ,',
|
'mod + ,',
|
||||||
@@ -48,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FloatingNavigation
|
<FloatingNavigation
|
||||||
toggleMenu={isNavigationLocked ? undefined : menuHandler.toggle}
|
toggleMenu={isNavigationLocked ? undefined : toggleMenu}
|
||||||
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
toggleSettings={suppressSettings ? undefined : showEditFormDrawer}
|
||||||
hasSavedChanges={hasSavedChanges}
|
hasSavedChanges={hasSavedChanges}
|
||||||
/>
|
/>
|
||||||
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={menuHandler.close} />}
|
{!isNavigationLocked && <NavigationMenu isOpen={isMenuOpen} onClose={closeMenu} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-12
@@ -2,35 +2,68 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.625rem 1.25rem;
|
||||||
gap: 0.5rem;
|
gap: 0.625rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
border-left: 4px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
color: $action-text-color;
|
color: $gray-200;
|
||||||
|
font-size: $text-body-size;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
transition-property: color;
|
transition-property: color, background-color;
|
||||||
transition-duration: $transition-time-action;
|
transition-duration: $transition-time-action;
|
||||||
|
|
||||||
|
// keep icons on a fixed box so labels align regardless of the glyph
|
||||||
|
> svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
color: $gray-500;
|
||||||
|
transition: color $transition-time-action;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: $ontime-color;
|
background-color: $white-3;
|
||||||
background-color: $gray-1350;
|
color: $ui-white;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
color: $gray-300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background-color: $border-color-ondark;
|
background-color: $white-7;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus-visible {
|
||||||
outline: 2px solid $blue-500;
|
outline: 2px solid $blue-500;
|
||||||
background-color: $gray-1350;
|
outline-offset: -2px;
|
||||||
|
background-color: $white-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggle rows are labels, the focusable control sits inside them
|
||||||
|
&:focus-within {
|
||||||
|
background-color: $white-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.current {
|
&.current {
|
||||||
background-color: $gray-1300;
|
background-color: rgba($blue-500, 0.16);
|
||||||
border-left: 4px solid $action-text-color;
|
border-left-color: $blue-400;
|
||||||
|
color: $ui-white;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
> svg {
|
||||||
|
color: $blue-300;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
@include ellipsis-text;
|
||||||
|
}
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
import { PropsWithChildren } from 'react';
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
import { isKeyEnter } from '../../../utils/keyEvent';
|
import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent';
|
||||||
import { cx } from '../../../utils/styleUtils';
|
import { cx } from '../../../utils/styleUtils';
|
||||||
|
|
||||||
import style from './NavigationMenuItem.module.scss';
|
import style from './NavigationMenuItem.module.scss';
|
||||||
@@ -11,6 +11,7 @@ interface NavigationMenuItemProps {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A row in the navigation menu, and the single place which decides how a row reacts to input */
|
||||||
export default function NavigationMenuItem({
|
export default function NavigationMenuItem({
|
||||||
active,
|
active,
|
||||||
className,
|
className,
|
||||||
@@ -24,7 +25,9 @@ export default function NavigationMenuItem({
|
|||||||
role='button'
|
role='button'
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (isKeyEnter(event)) {
|
if (isKeyEnter(event) || isKeySpace(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import Switch from '../../switch/Switch';
|
||||||
|
|
||||||
|
import style from './NavigationMenuItem.module.scss';
|
||||||
|
|
||||||
|
interface NavigationMenuToggleProps {
|
||||||
|
checked: boolean;
|
||||||
|
icon: ReactNode;
|
||||||
|
label: string;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A menu row which reflects, and toggles, an on/off state */
|
||||||
|
export default function NavigationMenuToggle({ checked, icon, label, onToggle }: NavigationMenuToggleProps) {
|
||||||
|
return (
|
||||||
|
<label className={style.link}>
|
||||||
|
{icon}
|
||||||
|
<span className={style.label}>{label}</span>
|
||||||
|
<Switch checked={checked} onCheckedChange={onToggle} />
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
+34
-4
@@ -1,22 +1,52 @@
|
|||||||
|
.footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-top: 1px solid $white-10;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
font-size: calc(1rem - 2px);
|
padding: 0 1.25rem 0.25rem;
|
||||||
margin-left: 1rem;
|
|
||||||
color: $gray-700;
|
color: $gray-700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interfaces {
|
.interfaces {
|
||||||
padding: 0.5rem 0.5rem;
|
padding: 0.25rem 1.25rem 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.interface {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
max-width: 100%;
|
||||||
|
|
||||||
|
> button:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: calc(100% - 1.5rem);
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding-inline: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.interfaceCopy {
|
.interfaceCopy {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goIcon {
|
.goIcon {
|
||||||
|
flex: 0 0 auto;
|
||||||
@include rotate-fourty-five;
|
@include rotate-fourty-five;
|
||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
|
|||||||
+18
-15
@@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5';
|
|||||||
import useInfo from '../../../hooks-query/useInfo';
|
import useInfo from '../../../hooks-query/useInfo';
|
||||||
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
import { linkToOtherHost, openLink } from '../../../utils/linkUtils';
|
||||||
import CopyTag from '../../copy-tag/CopyTag';
|
import CopyTag from '../../copy-tag/CopyTag';
|
||||||
|
import Eyebrow from '../../eyebrow/Eyebrow';
|
||||||
|
import { getExternalInterfaces } from './otherAddresses.utils';
|
||||||
|
|
||||||
import style from './OtherAddresses.module.scss';
|
import style from './OtherAddresses.module.scss';
|
||||||
|
|
||||||
@@ -12,32 +14,33 @@ interface OtherAddressesProps {
|
|||||||
|
|
||||||
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
export default function OtherAddresses({ currentLocation }: OtherAddressesProps) {
|
||||||
const { data } = useInfo();
|
const { data } = useInfo();
|
||||||
|
const externalInterfaces = getExternalInterfaces(data.networkInterfaces);
|
||||||
|
|
||||||
// there is no point showing this if we only have one interface
|
if (externalInterfaces.length === 0) {
|
||||||
if (data.networkInterfaces.length < 2) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={style.footer}>
|
||||||
<div className={style.header}>Accessible on external networks</div>
|
<div className={style.header}>
|
||||||
|
<Eyebrow>Also available at</Eyebrow>
|
||||||
|
</div>
|
||||||
<div className={style.interfaces}>
|
<div className={style.interfaces}>
|
||||||
{data?.networkInterfaces?.map((nif) => {
|
{externalInterfaces.map((nif) => {
|
||||||
if (nif.name === 'localhost') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const address = linkToOtherHost(nif.address, currentLocation);
|
const address = linkToOtherHost(nif.address, currentLocation);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)} size='small'>
|
<div key={nif.name} className={style.interface}>
|
||||||
<span className={style.interfaceCopy}>
|
<CopyTag copyValue={address} onClick={() => openLink(address)} size='small'>
|
||||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
<span className={style.interfaceCopy}>
|
||||||
</span>
|
<span className={style.address}>{nif.address}</span>
|
||||||
</CopyTag>
|
<IoArrowUp className={style.goIcon} />
|
||||||
|
</span>
|
||||||
|
</CopyTag>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
|
import { getExternalInterfaces } from '../otherAddresses.utils';
|
||||||
|
|
||||||
|
describe('getExternalInterfaces', () => {
|
||||||
|
test('returns no alternatives when localhost is the only interface', () => {
|
||||||
|
expect(getExternalInterfaces([{ name: 'localhost', address: '127.0.0.1' }])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not treat an interface name as deployment information', () => {
|
||||||
|
expect(
|
||||||
|
getExternalInterfaces([
|
||||||
|
{ name: 'localhost', address: '127.0.0.1' },
|
||||||
|
{ name: 'cloud', address: '192.168.1.42' },
|
||||||
|
]),
|
||||||
|
).toEqual([{ name: 'cloud', address: '192.168.1.42' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns non-local interfaces when local and external interfaces are available', () => {
|
||||||
|
expect(
|
||||||
|
getExternalInterfaces([
|
||||||
|
{ name: 'localhost', address: '127.0.0.1' },
|
||||||
|
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||||
|
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
{ name: 'Wi-Fi', address: '192.168.1.42' },
|
||||||
|
{ name: 'Ethernet', address: '10.0.0.42' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
import { type NetworkInterface } from 'ontime-types';
|
||||||
|
|
||||||
|
/** Returns network addresses other than the localhost address injected by the info endpoint. */
|
||||||
|
export function getExternalInterfaces(interfaces: NetworkInterface[]) {
|
||||||
|
return interfaces.filter((networkInterface) => networkInterface.name !== 'localhost');
|
||||||
|
}
|
||||||
@@ -1,37 +1,77 @@
|
|||||||
.inline {
|
.multiOptionList {
|
||||||
display: inline-flex;
|
overflow-y: auto;
|
||||||
align-items: center;
|
max-height: 12rem;
|
||||||
flex-wrap: wrap;
|
background: $gray-1200;
|
||||||
gap: 1rem;
|
border: 1px solid $gray-1100;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
// styles from subtle button
|
|
||||||
.toggleSelect {
|
.toggleSelect {
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.75rem;
|
||||||
padding-inline: 0.5rem;
|
min-height: 2.5rem;
|
||||||
height: 2.5rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: $gray-1050;
|
background: transparent;
|
||||||
color: $ui-white;
|
border: 0;
|
||||||
|
color: $gray-300;
|
||||||
line-height: 1em;
|
line-height: 1em;
|
||||||
border-radius: $component-border-radius-md;
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color $transition-time-action,
|
||||||
|
border-color $transition-time-action,
|
||||||
|
color $transition-time-action;
|
||||||
|
|
||||||
&:hover:not(:disabled):not(:active) {
|
&:hover {
|
||||||
background: $gray-1000;
|
background: $gray-1000;
|
||||||
color: $blue-500;
|
color: $ui-white;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
&:active {
|
||||||
background: $gray-1100;
|
background: $gray-1100;
|
||||||
border-color: $gray-1250;
|
}
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: rgba($blue-500, 0.16);
|
||||||
|
box-shadow: inset 3px 0 $blue-400;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid $white-7;
|
||||||
|
}
|
||||||
|
|
||||||
|
> [role='checkbox'] {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.125rem;
|
||||||
|
height: 1.125rem;
|
||||||
|
border: 1px solid $gray-400;
|
||||||
|
|
||||||
|
&[data-unchecked] {
|
||||||
|
background: $gray-1100;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-checked] {
|
||||||
|
border-color: $blue-400;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
margin-left: 0.25rem;
|
flex: 0 0 auto;
|
||||||
width: 0.75em;
|
margin-left: auto;
|
||||||
height: 0.75em;
|
width: 0.625rem;
|
||||||
|
height: 0.625rem;
|
||||||
background: var(--user-bg, $gray-900);
|
background: var(--user-bg, $gray-900);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react';
|
|||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { isStringBoolean } from '../../../views/common/viewUtils';
|
import { isStringBoolean } from '../../../views/common/viewUtils';
|
||||||
|
import { cx } from '../../utils/styleUtils';
|
||||||
import Checkbox from '../checkbox/Checkbox';
|
import Checkbox from '../checkbox/Checkbox';
|
||||||
import Input from '../input/input/Input';
|
import Input from '../input/input/Input';
|
||||||
import Select, { SelectOption } from '../select/Select';
|
import Select, { SelectOption } from '../select/Select';
|
||||||
@@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||||
<div className={style.inline}>
|
<div className={style.multiOptionList}>
|
||||||
{values.map((option) => {
|
{values.map((option) => {
|
||||||
|
const selected = paramState.includes(option.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<label
|
||||||
key={option.value}
|
key={option.value}
|
||||||
className={style.toggleSelect}
|
className={cx([style.toggleSelect, selected && style.selected])}
|
||||||
style={{
|
style={{
|
||||||
'--user-bg': option.colour,
|
'--user-bg': option.colour,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={paramState.includes(option.value)}
|
checked={selected}
|
||||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||||
/>
|
/>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
.drawerFooter {
|
|
||||||
display: flex;
|
|
||||||
justify-content: end;
|
|
||||||
gap: $section-spacing;
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: 0 2em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
.backdrop {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -34,11 +24,11 @@
|
|||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding-block: 1rem 1.5rem;
|
|
||||||
|
|
||||||
background-color: $gray-1250;
|
background-color: $ui-black;
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
border-left: 1px solid $gray-1100;
|
border-left: 1px solid $gray-1100;
|
||||||
|
box-shadow: $box-shadow-l1;
|
||||||
|
|
||||||
&[data-open] {
|
&[data-open] {
|
||||||
transform: translateX(0%);
|
transform: translateX(0%);
|
||||||
@@ -58,36 +48,54 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
padding-inline: 1rem;
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
height: 3.5rem;
|
gap: 1rem;
|
||||||
|
min-height: 4rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerText {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.target {
|
||||||
|
font-size: $aux-text-size;
|
||||||
|
color: $gray-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding-inline: 1rem;
|
padding: 1rem;
|
||||||
padding-bottom: 10vh;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding-inline: 1rem;
|
padding: 0.75rem 1rem;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
background-color: $ui-black;
|
||||||
|
border-top: 1px solid $white-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionList {
|
.sectionList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2rem;
|
gap: 0.75rem;
|
||||||
padding-right: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { FormEvent, memo } from 'react';
|
|||||||
import { IoClose } from 'react-icons/io5';
|
import { IoClose } from 'react-icons/io5';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
|
import { viewLabels } from '../../../viewerConfig';
|
||||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||||
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
import { useIsSmallScreen } from '../../hooks/useIsSmallScreen';
|
||||||
import { useSavedViewParams } from '../../stores/savedViewParams';
|
import { useSavedViewParams } from '../../stores/savedViewParams';
|
||||||
@@ -71,8 +72,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
|
|||||||
<Dialog.Backdrop className={style.backdrop} />
|
<Dialog.Backdrop className={style.backdrop} />
|
||||||
<Dialog.Popup className={style.drawer}>
|
<Dialog.Popup className={style.drawer}>
|
||||||
<div className={style.header}>
|
<div className={style.header}>
|
||||||
<Dialog.Title>Customise</Dialog.Title>
|
<div className={style.headerText}>
|
||||||
<IconButton variant='subtle-white' size='large' data-testid='close-view-params' onClick={handleClose}>
|
<Dialog.Title className={style.title}>Customise</Dialog.Title>
|
||||||
|
<span className={style.target}>{viewLabels[target]}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
variant='subtle-white'
|
||||||
|
size='large'
|
||||||
|
aria-label='Close'
|
||||||
|
data-testid='close-view-params'
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
<IoClose />
|
<IoClose />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
.presetSection {
|
.presetSection {
|
||||||
background-color: $gray-1350;
|
background-color: $gray-1350;
|
||||||
|
border: 1px solid $white-10;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
min-height: 2.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 1rem 0.5rem;
|
color: $gray-300;
|
||||||
margin-bottom: 1rem;
|
}
|
||||||
|
|
||||||
|
.presetList {
|
||||||
|
border-top: 1px solid $white-7;
|
||||||
max-height: 10rem;
|
max-height: 10rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
scrollbar-gutter: stable;
|
scrollbar-gutter: stable;
|
||||||
@@ -14,12 +25,39 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
|
font-size: $inner-section-text-size;
|
||||||
|
|
||||||
|
&:not(:last-child) {
|
||||||
|
border-bottom: 1px solid $white-7;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: $blue-500;
|
background-color: rgba($blue-500, 0.16);
|
||||||
|
box-shadow: inset 3px 0 $blue-400;
|
||||||
|
color: $ui-white;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.presetName {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.presetActions {
|
.presetActions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.current {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: auto;
|
||||||
|
color: $blue-400;
|
||||||
|
font-size: $aux-text-size;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router';
|
|||||||
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
import { useViewUrlPresets } from '../../hooks-query/useUrlPresets';
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
import Button from '../buttons/Button';
|
import Button from '../buttons/Button';
|
||||||
|
import Eyebrow from '../eyebrow/Eyebrow';
|
||||||
|
|
||||||
import style from './ViewParamsPresets.module.scss';
|
import style from './ViewParamsPresets.module.scss';
|
||||||
|
|
||||||
@@ -26,22 +27,31 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.presetSection}>
|
<div className={style.presetSection}>
|
||||||
{viewPresets.map((preset) => {
|
<div className={style.header}>
|
||||||
const active = searchParams.get('alias') === preset.alias;
|
<Eyebrow>Saved presets</Eyebrow>
|
||||||
return (
|
</div>
|
||||||
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
<div className={style.presetList}>
|
||||||
<div>{preset.alias}</div>
|
{viewPresets.map((preset) => {
|
||||||
<Button
|
const active = searchParams.get('alias') === preset.alias;
|
||||||
variant={active ? 'ghosted' : 'subtle-white'}
|
return (
|
||||||
onClick={() => handleRecall(preset)}
|
<div key={preset.alias} className={cx([style.preset, active && style.active])}>
|
||||||
disabled={active}
|
<span className={style.presetName}>{preset.alias}</span>
|
||||||
className={style.presetActions}
|
{active ? (
|
||||||
>
|
<span className={style.current}>Current</span>
|
||||||
{active ? 'Applied' : 'Apply'}
|
) : (
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant='ghosted'
|
||||||
);
|
size='small'
|
||||||
})}
|
onClick={() => handleRecall(preset)}
|
||||||
|
className={style.presetActions}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+92
-22
@@ -1,48 +1,118 @@
|
|||||||
.section {
|
.section {
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
color: $ui-white;
|
color: $ui-white;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
|
background-color: $white-3;
|
||||||
|
border: 1px solid $gray-1100;
|
||||||
|
border-radius: $component-border-radius-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionHeader {
|
.sectionHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: 2rem;
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
color: $gray-300;
|
||||||
|
|
||||||
&.collapsible {
|
&.collapsible {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition-property: color, background-color;
|
||||||
|
transition-duration: $transition-time-action;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $white-3;
|
||||||
|
color: $ui-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $blue-500;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** marks a section which holds values coming from the URL */
|
||||||
|
.customised {
|
||||||
|
width: 0.4rem;
|
||||||
|
height: 0.4rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: $active-indicator;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
flex-shrink: 0;
|
||||||
|
rotate: 180deg;
|
||||||
|
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||||
|
|
||||||
|
&.closed {
|
||||||
|
rotate: 0deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
padding-inline: 1rem;
|
||||||
|
border-top: 1px solid $white-7;
|
||||||
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
margin-top: $section-spacing;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
row-gap: 0.25rem;
|
||||||
|
padding: 0.75rem 0;
|
||||||
font-size: $inner-section-text-size;
|
font-size: $inner-section-text-size;
|
||||||
color: $label-gray;
|
|
||||||
display: flex;
|
&:not(:last-child) {
|
||||||
flex-direction: column;
|
border-bottom: 1px solid $white-7;
|
||||||
gap: 0.25rem;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact controls sit beside their label instead of below it.
|
||||||
|
* Placement is done with grid areas so that the markup stays flat.
|
||||||
|
*/
|
||||||
|
.inline {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 1rem;
|
||||||
|
|
||||||
|
> .title {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .description {
|
||||||
|
grid-area: 2 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> *:last-child {
|
||||||
|
grid-area: 1 / 2 / span 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: $inner-section-text-size;
|
color: $ui-white;
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
font-size: $inner-section-text-size;
|
color: $gray-400;
|
||||||
display: block;
|
font-size: $aux-text-size;
|
||||||
color: $modal-note-color;
|
line-height: 1.4;
|
||||||
}
|
|
||||||
|
|
||||||
.closed {
|
|
||||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
|
||||||
rotate: 0deg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open {
|
|
||||||
transition: rotate 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
|
||||||
rotate: 180deg;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useLocalStorage } from '@mantine/hooks';
|
import { useLocalStorage } from '@mantine/hooks';
|
||||||
import { IoChevronDown } from 'react-icons/io5';
|
import { IoChevronDown } from 'react-icons/io5';
|
||||||
|
import { useSearchParams } from 'react-router';
|
||||||
|
|
||||||
import { cx } from '../../utils/styleUtils';
|
import { cx } from '../../utils/styleUtils';
|
||||||
|
import Eyebrow from '../eyebrow/Eyebrow';
|
||||||
import { OptionTitle } from './constants';
|
import { OptionTitle } from './constants';
|
||||||
import ParamInput from './ParamInput';
|
import ParamInput from './ParamInput';
|
||||||
import { type ParamField } from './viewParams.types';
|
import { type ParamField } from './viewParams.types';
|
||||||
@@ -14,50 +16,74 @@ interface ViewParamsSectionProps {
|
|||||||
options: ParamField[];
|
options: ParamField[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fields which are compact enough to share a row with their label */
|
||||||
|
function isInlineField(option: ParamField): boolean {
|
||||||
|
return option.type === 'boolean' || option.type === 'colour';
|
||||||
|
}
|
||||||
|
|
||||||
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
export default function ViewParamsSection({ title, collapsible, options }: ViewParamsSectionProps) {
|
||||||
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false });
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const handleCollapse = () => {
|
// hidden options are inputs without a UI, there is nothing to frame them with
|
||||||
if (collapsible) {
|
if (title === OptionTitle.Hidden) {
|
||||||
setCollapsed((prev) => !prev);
|
return <HiddenContents options={options} />;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
const isCollapsed = Boolean(collapsible && collapsed);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={style.section}>
|
<section className={style.section}>
|
||||||
{title === OptionTitle.Hidden ? (
|
<SectionHeader
|
||||||
<HiddenContents options={options} />
|
title={title}
|
||||||
) : (
|
isCustomised={options.some((option) => searchParams.has(option.id))}
|
||||||
<>
|
collapsed={isCollapsed}
|
||||||
<div className={cx([style.sectionHeader, collapsible && style.collapsible])} onClick={handleCollapse}>
|
onToggle={collapsible ? () => setCollapsed((prev) => !prev) : undefined}
|
||||||
{title}
|
/>
|
||||||
{collapsible && <IoChevronDown className={cx([collapsed ? style.closed : style.open])} />}
|
{/* collapsed options stay mounted: the form reads its values from the DOM */}
|
||||||
</div>
|
<div className={cx([style.options, isCollapsed && style.hidden])}>
|
||||||
<SectionContents options={options} collapsed={collapsed} />
|
{options.map((option) => (
|
||||||
</>
|
<label key={option.title} className={cx([style.label, isInlineField(option) && style.inline])}>
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SectionContentsProps {
|
|
||||||
options: ParamField[];
|
|
||||||
collapsed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SectionContents({ options, collapsed }: SectionContentsProps) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{options.map((option) => {
|
|
||||||
return (
|
|
||||||
<label key={option.title} className={cx([style.label, collapsed && style.hidden])}>
|
|
||||||
<span className={style.title}>{option.title}</span>
|
<span className={style.title}>{option.title}</span>
|
||||||
<span className={style.description}>{option.description}</span>
|
<span className={style.description}>{option.description}</span>
|
||||||
<ParamInput paramField={option} />
|
<ParamInput paramField={option} />
|
||||||
</label>
|
</label>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
</>
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionHeaderProps {
|
||||||
|
title: string;
|
||||||
|
isCustomised: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
/** a header without a toggle belongs to a section which cannot collapse */
|
||||||
|
onToggle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SectionHeader({ title, isCustomised, collapsed, onToggle }: SectionHeaderProps) {
|
||||||
|
const label = (
|
||||||
|
<Eyebrow className={style.sectionTitle}>
|
||||||
|
{title}
|
||||||
|
{isCustomised && <span className={style.customised} title='Contains custom values' />}
|
||||||
|
</Eyebrow>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!onToggle) {
|
||||||
|
return <div className={style.sectionHeader}>{label}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className={cx([style.sectionHeader, style.collapsible])}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
<IoChevronDown className={cx([style.chevron, collapsed && style.closed])} />
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export function isKeyEnter<T>(event: KeyboardEvent<T>): boolean {
|
|||||||
return event.key === 'Enter';
|
return event.key === 'Enter';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isKeySpace<T>(event: KeyboardEvent<T>): boolean {
|
||||||
|
return event.key === ' ' || event.code === 'Space';
|
||||||
|
}
|
||||||
|
|
||||||
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
export function isKeyEscape<T>(event: KeyboardEvent<T>): boolean {
|
||||||
return event.key === 'Escape';
|
return event.key === 'Escape';
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-8
@@ -12,20 +12,21 @@ import { useUpdateUrlPreset } from '../../../../../common/hooks-query/useUrlPres
|
|||||||
import { isUrlSafe } from '../../../../../common/utils/regex';
|
import { isUrlSafe } from '../../../../../common/utils/regex';
|
||||||
import { enDash } from '../../../../../common/utils/styleUtils';
|
import { enDash } from '../../../../../common/utils/styleUtils';
|
||||||
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
import { generateUrlPresetOptions } from '../../../../../common/utils/urlPresets';
|
||||||
|
import { viewLabels } from '../../../../../viewerConfig';
|
||||||
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
import CuesheetLinkOptions, { CuesheetPermissionValues } from '../../../../sharing/composite/CuesheetLinkOptions';
|
||||||
import * as Panel from '../../../panel-utils/PanelUtils';
|
import * as Panel from '../../../panel-utils/PanelUtils';
|
||||||
|
|
||||||
import style from './URLPresetForm.module.scss';
|
import style from './URLPresetForm.module.scss';
|
||||||
|
|
||||||
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
const targetOptions: SelectOption<OntimeViewPresettable>[] = [
|
||||||
{ value: OntimeView.Cuesheet, label: 'Cuesheet' },
|
{ value: OntimeView.Cuesheet, label: viewLabels[OntimeView.Cuesheet] },
|
||||||
{ value: OntimeView.Operator, label: 'Operator' },
|
{ value: OntimeView.Operator, label: viewLabels[OntimeView.Operator] },
|
||||||
{ value: OntimeView.Timer, label: 'Timer' },
|
{ value: OntimeView.Timer, label: viewLabels[OntimeView.Timer] },
|
||||||
{ value: OntimeView.Backstage, label: 'Backstage' },
|
{ value: OntimeView.Backstage, label: viewLabels[OntimeView.Backstage] },
|
||||||
{ value: OntimeView.Timeline, label: 'Timeline' },
|
{ value: OntimeView.Timeline, label: viewLabels[OntimeView.Timeline] },
|
||||||
{ value: OntimeView.StudioClock, label: 'Studio Clock' },
|
{ value: OntimeView.StudioClock, label: viewLabels[OntimeView.StudioClock] },
|
||||||
{ value: OntimeView.Countdown, label: 'Countdown' },
|
{ value: OntimeView.Countdown, label: viewLabels[OntimeView.Countdown] },
|
||||||
{ value: OntimeView.ProjectInfo, label: 'Project Info' },
|
{ value: OntimeView.ProjectInfo, label: viewLabels[OntimeView.ProjectInfo] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const formId = 'url-preset-form';
|
const formId = 'url-preset-form';
|
||||||
|
|||||||
@@ -1,12 +1,30 @@
|
|||||||
export const navigatorConstants = [
|
import { OntimeView } from 'ontime-types';
|
||||||
{ url: 'timer', label: 'Timer' },
|
|
||||||
{ url: 'backstage', label: 'Backstage' },
|
/** User facing name of each view, the single source for view naming in the UI */
|
||||||
{ url: 'timeline', label: 'Timeline' },
|
export const viewLabels: Record<OntimeView, string> = {
|
||||||
{ url: 'studio', label: 'Studio Clock' },
|
[OntimeView.Editor]: 'Editor',
|
||||||
{ url: 'countdown', label: 'Countdown' },
|
[OntimeView.Cuesheet]: 'Cuesheet',
|
||||||
{ url: 'info', label: 'Project Info' },
|
[OntimeView.Operator]: 'Operator',
|
||||||
|
[OntimeView.Timer]: 'Timer',
|
||||||
|
[OntimeView.Backstage]: 'Backstage',
|
||||||
|
[OntimeView.Timeline]: 'Timeline',
|
||||||
|
[OntimeView.StudioClock]: 'Studio Clock',
|
||||||
|
[OntimeView.Countdown]: 'Countdown',
|
||||||
|
[OntimeView.ProjectInfo]: 'Project Info',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Views offered in the navigation menu, in the order they are shown. Their value is also their route */
|
||||||
|
const navigatorViews = [
|
||||||
|
OntimeView.Timer,
|
||||||
|
OntimeView.Backstage,
|
||||||
|
OntimeView.Timeline,
|
||||||
|
OntimeView.StudioClock,
|
||||||
|
OntimeView.Countdown,
|
||||||
|
OntimeView.ProjectInfo,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const navigatorConstants = navigatorViews.map((view) => ({ url: view, label: viewLabels[view] }));
|
||||||
|
|
||||||
// default time format to use for users in 12 hour clocks
|
// default time format to use for users in 12 hour clocks
|
||||||
export const FORMAT_12 = 'h:mm:ss a';
|
export const FORMAT_12 = 'h:mm:ss a';
|
||||||
// default time format to use for users in 24 hour clocks
|
// default time format to use for users in 24 hour clocks
|
||||||
|
|||||||
@@ -48,6 +48,47 @@ test.describe('test view navigation feature', () => {
|
|||||||
await expect(page).toHaveURL('/timer');
|
await expect(page).toHaveURL('/timer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shortcut has to read the current open state, not the one captured when it was
|
||||||
|
* registered, otherwise it only ever opens the menu.
|
||||||
|
*/
|
||||||
|
test('Space closes the menu as well as opening it', async ({ page }) => {
|
||||||
|
const menu = page.getByRole('dialog');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
|
||||||
|
await openNavigationMenu(page);
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Space closes a menu opened with the button', async ({ page }) => {
|
||||||
|
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||||
|
await page.getByTestId('navigation__toggle-menu').click();
|
||||||
|
|
||||||
|
const menu = page.getByRole('dialog');
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Space toggles a focused menu switch without closing the menu', async ({ page }) => {
|
||||||
|
await openNavigationMenu(page);
|
||||||
|
|
||||||
|
const menu = page.getByRole('dialog');
|
||||||
|
const flipScreen = page.getByRole('switch', { name: 'Flip Screen' });
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
|
||||||
|
const initiallyChecked = await flipScreen.getAttribute('aria-checked');
|
||||||
|
await flipScreen.focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
|
||||||
|
await expect(flipScreen).toHaveAttribute('aria-checked', initiallyChecked === 'true' ? 'false' : 'true');
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('not-found', async ({ page }) => {
|
test('not-found', async ({ page }) => {
|
||||||
await page.goto('/not-found');
|
await page.goto('/not-found');
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,28 @@ test('View params configures timer view', async ({ page }) => {
|
|||||||
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||||
await expect(page).toHaveURL(/.*hideClock=true/);
|
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The form gathers its values from the DOM, so a collapsed section has to stay mounted.
|
||||||
|
* Unmounting it would quietly drop everything the user set in it on the next apply.
|
||||||
|
*/
|
||||||
|
test('View params keeps the values of collapsed sections', async ({ page }) => {
|
||||||
|
// hideClock lives in the section we are about to collapse
|
||||||
|
await page.goto('/timer?hideClock=true');
|
||||||
|
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||||
|
|
||||||
|
await page.mouse.move(Math.random() * 100, Math.random() * 100);
|
||||||
|
await page.getByTestId('navigation__toggle-settings').click();
|
||||||
|
|
||||||
|
const section = page.getByRole('button', { name: /Element visibility/ });
|
||||||
|
await expect(section).toHaveAttribute('aria-expanded', 'true');
|
||||||
|
await section.focus();
|
||||||
|
await page.keyboard.press('Space');
|
||||||
|
await expect(section).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
await expect(page.getByRole('dialog', { name: 'Ontime' })).toBeHidden();
|
||||||
|
|
||||||
|
await page.getByTestId('apply-view-params').click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/.*hideClock=true/);
|
||||||
|
await expect(page.getByText('TIME NOW', { exact: true })).not.toBeInViewport();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user