From 4cc1a003c013c64cd75c5221d4fc084c7b179f96 Mon Sep 17 00:00:00 2001 From: Carlos Valente <34649812+cpvalente@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:36:31 +0000 Subject: [PATCH] refactor(ui): polish navigation menu and view params editor --- .../components/eyebrow/Eyebrow.module.scss | 6 + .../src/common/components/eyebrow/Eyebrow.tsx | 13 ++ .../NavigationMenu.module.scss | 46 +++++-- .../navigation-menu/NavigationMenu.tsx | 127 ++++++++++-------- .../navigation-menu/ViewNavigationMenu.tsx | 34 +++-- .../NavigationMenuItem.module.scss | 57 ++++++-- .../NavigationMenuItem.tsx | 7 +- .../NavigationMenuToggle.tsx | 23 ++++ .../OtherAddresses.module.scss | 38 +++++- .../other-addresses/OtherAddresses.tsx | 33 ++--- .../__tests__/otherAddresses.utils.test.ts | 31 +++++ .../other-addresses/otherAddresses.utils.ts | 6 + .../view-params-editor/ParamInput.module.scss | 78 ++++++++--- .../view-params-editor/ParamInput.tsx | 9 +- .../ViewParamsEditor.module.scss | 46 ++++--- .../view-params-editor/ViewParamsEditor.tsx | 14 +- .../ViewParamsPresets.module.scss | 48 ++++++- .../view-params-editor/ViewParamsPresets.tsx | 42 +++--- .../ViewParamsSection.module.scss | 114 +++++++++++++--- .../view-params-editor/ViewParamsSection.tsx | 94 ++++++++----- apps/client/src/common/utils/keyEvent.ts | 4 + .../feature-panel/composite/URLPresetForm.tsx | 17 +-- apps/client/src/viewerConfig.ts | 32 ++++- e2e/tests/002-view-navigation.spec.ts | 41 ++++++ e2e/tests/features/207-view-params.spec.ts | 25 ++++ 25 files changed, 744 insertions(+), 241 deletions(-) create mode 100644 apps/client/src/common/components/eyebrow/Eyebrow.module.scss create mode 100644 apps/client/src/common/components/eyebrow/Eyebrow.tsx create mode 100644 apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx create mode 100644 apps/client/src/common/components/navigation-menu/other-addresses/__tests__/otherAddresses.utils.test.ts create mode 100644 apps/client/src/common/components/navigation-menu/other-addresses/otherAddresses.utils.ts diff --git a/apps/client/src/common/components/eyebrow/Eyebrow.module.scss b/apps/client/src/common/components/eyebrow/Eyebrow.module.scss new file mode 100644 index 000000000..e66d56092 --- /dev/null +++ b/apps/client/src/common/components/eyebrow/Eyebrow.module.scss @@ -0,0 +1,6 @@ +.eyebrow { + font-size: $aux-text-size; + font-weight: 400; + letter-spacing: 0.06em; + text-transform: uppercase; +} diff --git a/apps/client/src/common/components/eyebrow/Eyebrow.tsx b/apps/client/src/common/components/eyebrow/Eyebrow.tsx new file mode 100644 index 000000000..b63772415 --- /dev/null +++ b/apps/client/src/common/components/eyebrow/Eyebrow.tsx @@ -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) { + return {children}; +} diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss index 5b2b2fdcc..e2fc194ca 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.module.scss @@ -1,7 +1,3 @@ -.separator { - border-color: $border-color-ondark; -} - .backdrop { position: fixed; inset: 0; @@ -23,14 +19,15 @@ width: 22rem; height: 100vh; + height: 100dvh; display: flex; flex-direction: column; - padding-block: 1rem; background-color: $gray-1250; color: $ui-white; border-right: 1px solid $gray-1100; + box-shadow: $box-shadow-right; &[data-open] { transform: translateX(0%); @@ -50,22 +47,51 @@ } .header { + flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; - height: 3.5rem; - padding-inline: 1.5rem; + gap: 1rem; + 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-size: 1.25rem; + line-height: 1.2; +} + +.clientName { + font-size: $aux-text-size; + color: $gray-600; + @include ellipsis-text; } .body { flex: 1; + min-height: 0; overflow-y: auto; + padding-block: 0.5rem 1rem; } -.note { - margin-left: auto; - color: $white-20; +.group { + display: flex; + flex-direction: column; + + &:not(:first-child) { + margin-top: 0.75rem; + } +} + +.groupLabel { + padding: 0.5rem 1.25rem 0.25rem; + color: $gray-700; } diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7f78b6e5d..044cdf10b 100644 --- a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx @@ -1,7 +1,7 @@ import { Dialog } from '@base-ui/react/dialog'; import { useDisclosure, useFullscreenDocument } from '@mantine/hooks'; -import { memo } from 'react'; -import { IoClose, IoContract, IoExpand, IoLockClosedOutline, IoSwapVertical } from 'react-icons/io5'; +import { memo, PropsWithChildren } from 'react'; +import { IoClose, IoExpand, IoLockClosedOutline, IoPencilOutline, IoSwapVertical } from 'react-icons/io5'; import { LuCoffee } from 'react-icons/lu'; import { useLocation } from 'react-router'; @@ -14,9 +14,11 @@ import { useClientStore } from '../../stores/clientStore'; import { useViewOptionsStore } from '../../stores/viewOptions'; import IconButton from '../buttons/IconButton'; import { RenameClientModal } from '../client-modal/RenameClientModal'; +import Eyebrow from '../eyebrow/Eyebrow'; import ClientLink from './client-link/ClientLink'; import EditorNavigation from './editor-navigation/EditorNavigation'; import NavigationMenuItem from './navigation-menu-item/NavigationMenuItem'; +import NavigationMenuToggle from './navigation-menu-item/NavigationMenuToggle'; import OtherAddresses from './other-addresses/OtherAddresses'; import style from './NavigationMenu.module.scss'; @@ -52,75 +54,93 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
- Ontime - +
+ Ontime + {name && {name}} +
+
- {supportsFullscreen && ( - - Toggle Fullscreen - {fullscreen ? : } + + {supportsFullscreen && ( + } label='Fullscreen' onToggle={toggle} /> + )} + } + label='Flip Screen' + onToggle={() => toggleMirror()} + /> + {canUseWakeLock && ( + } + label='Keep Awake' + onToggle={toggleKeepAwake} + /> + )} + + + Rename Client - )} - toggleMirror()}> - Flip Screen - - {mirror && Active} - - {canUseWakeLock && ( - - Keep Awake - - {keepAwake && Active} - - )} - Rename Client + -
- - - - - Cuesheet - - - - Operator - - -
- - {navigatorConstants.map((route) => ( + + - {route.label} + + Cuesheet - ))} + + + Operator + + + + + {navigatorConstants.map((route) => ( + + {route.label} + + ))} +
- {isLocalhost && ( -
- -
- )} + {isLocalhost && }
); } +function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; onClose: () => void }) { const location = useLocation(); const { data: urlPresets } = useUrlPresets(); @@ -129,8 +149,7 @@ function PresetNavigation({ isSmallScreen, onClose }: { isSmallScreen: boolean; if (navPresets.length === 0) return null; return ( - <> -
+ {navPresets.map((preset) => ( ))} - + ); } diff --git a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx index 4545333bb..09fbfcc56 100644 --- a/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx +++ b/apps/client/src/common/components/navigation-menu/ViewNavigationMenu.tsx @@ -1,5 +1,5 @@ -import { useDisclosure, useHotkeys } from '@mantine/hooks'; -import { memo } from 'react'; +import { useHotkeys } from '@mantine/hooks'; +import { memo, useCallback, useState } from 'react'; import { useSearchParams } from 'react-router'; import { hasCustomParams } from '../../stores/savedViewParams'; @@ -15,21 +15,37 @@ interface ViewNavigationMenuProps { 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); function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNavigationMenuProps) { - const [isMenuOpen, menuHandler] = useDisclosure(); + const [isMenuOpen, setIsMenuOpen] = useState(false); const { open: showEditFormDrawer } = useViewParamsEditorStore(); const [searchParams] = useSearchParams(); const hasSavedChanges = hasCustomParams(searchParams); + const toggleMenu = useCallback(() => setIsMenuOpen((prev) => !prev), []); + const closeMenu = useCallback(() => setIsMenuOpen(false), []); + useHotkeys([ [ 'Space', - () => { - if (isNavigationLocked) return; - menuHandler.toggle(); + (event) => { + if (isNavigationLocked || isInteractiveKeyboardAction(event.target)) return; + event.preventDefault(); + toggleMenu(); }, - { preventDefault: true }, + { preventDefault: false }, ], [ 'mod + ,', @@ -48,11 +64,11 @@ function ViewNavigationMenu({ isNavigationLocked, suppressSettings }: ViewNaviga return ( <> - {!isNavigationLocked && } + {!isNavigationLocked && } ); } diff --git a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss index f7c0a34a0..399a12009 100644 --- a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss @@ -2,35 +2,68 @@ display: flex; align-items: center; - padding: 0.75rem 1.5rem; - gap: 0.5rem; + padding: 0.625rem 1.25rem; + gap: 0.625rem; width: 100%; - border-left: 4px solid transparent; - color: $action-text-color; + border-left: 3px solid transparent; + color: $gray-200; + font-size: $text-body-size; white-space: nowrap; + text-align: left; cursor: pointer; - transition-property: color; + transition-property: color, background-color; 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 { - color: $ontime-color; - background-color: $gray-1350; + background-color: $white-3; + color: $ui-white; + + > svg { + color: $gray-300; + } } &:active { - background-color: $border-color-ondark; + background-color: $white-7; } - &:focus { + &:focus-visible { 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 { - background-color: $gray-1300; - border-left: 4px solid $action-text-color; + background-color: rgba($blue-500, 0.16); + border-left-color: $blue-400; + color: $ui-white; + font-weight: 600; + + > svg { + color: $blue-300; + } } } + +.label { + flex: 1; + min-width: 0; + @include ellipsis-text; +} diff --git a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.tsx b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.tsx index 789b3b99c..e62cd1b63 100644 --- a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.tsx +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.tsx @@ -1,6 +1,6 @@ import { PropsWithChildren } from 'react'; -import { isKeyEnter } from '../../../utils/keyEvent'; +import { isKeyEnter, isKeySpace } from '../../../utils/keyEvent'; import { cx } from '../../../utils/styleUtils'; import style from './NavigationMenuItem.module.scss'; @@ -11,6 +11,7 @@ interface NavigationMenuItemProps { onClick: () => void; } +/** A row in the navigation menu, and the single place which decides how a row reacts to input */ export default function NavigationMenuItem({ active, className, @@ -24,7 +25,9 @@ export default function NavigationMenuItem({ role='button' onClick={onClick} onKeyDown={(event) => { - if (isKeyEnter(event)) { + if (isKeyEnter(event) || isKeySpace(event)) { + event.preventDefault(); + event.stopPropagation(); onClick(); } }} diff --git a/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx new file mode 100644 index 000000000..64ff3f84c --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx @@ -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 ( + + ); +} diff --git a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss index 9045d2685..797819bc9 100644 --- a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss +++ b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.module.scss @@ -1,22 +1,52 @@ +.footer { + flex: 0 0 auto; + padding: 0.75rem 0; + border-top: 1px solid $white-10; +} + .header { - font-size: calc(1rem - 2px); - margin-left: 1rem; + padding: 0 1.25rem 0.25rem; color: $gray-700; } .interfaces { - padding: 0.5rem 0.5rem; + padding: 0.25rem 1.25rem 0; display: flex; 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 { display: flex; align-items: center; + min-width: 0; + width: 100%; +} + +.address { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .goIcon { + flex: 0 0 auto; @include rotate-fourty-five; margin-left: 0.25rem; margin-bottom: 0.25rem; diff --git a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.tsx b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.tsx index 69f3dfce3..4f03dfbc1 100644 --- a/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.tsx +++ b/apps/client/src/common/components/navigation-menu/other-addresses/OtherAddresses.tsx @@ -3,6 +3,8 @@ import { IoArrowUp } from 'react-icons/io5'; import useInfo from '../../../hooks-query/useInfo'; import { linkToOtherHost, openLink } from '../../../utils/linkUtils'; import CopyTag from '../../copy-tag/CopyTag'; +import Eyebrow from '../../eyebrow/Eyebrow'; +import { getExternalInterfaces } from './otherAddresses.utils'; import style from './OtherAddresses.module.scss'; @@ -12,32 +14,33 @@ interface OtherAddressesProps { export default function OtherAddresses({ currentLocation }: OtherAddressesProps) { const { data } = useInfo(); + const externalInterfaces = getExternalInterfaces(data.networkInterfaces); - // there is no point showing this if we only have one interface - if (data.networkInterfaces.length < 2) { + if (externalInterfaces.length === 0) { return null; } return ( - <> -
Accessible on external networks
+
+
+ Also available at +
- {data?.networkInterfaces?.map((nif) => { - if (nif.name === 'localhost') { - return null; - } - + {externalInterfaces.map((nif) => { const address = linkToOtherHost(nif.address, currentLocation); return ( - openLink(address)} size='small'> - - {nif.address} - - +
+ openLink(address)} size='small'> + + {nif.address} + + + +
); })}
- +
); } diff --git a/apps/client/src/common/components/navigation-menu/other-addresses/__tests__/otherAddresses.utils.test.ts b/apps/client/src/common/components/navigation-menu/other-addresses/__tests__/otherAddresses.utils.test.ts new file mode 100644 index 000000000..6e1d6030a --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/other-addresses/__tests__/otherAddresses.utils.test.ts @@ -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' }, + ]); + }); +}); diff --git a/apps/client/src/common/components/navigation-menu/other-addresses/otherAddresses.utils.ts b/apps/client/src/common/components/navigation-menu/other-addresses/otherAddresses.utils.ts new file mode 100644 index 000000000..b5686f035 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/other-addresses/otherAddresses.utils.ts @@ -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'); +} diff --git a/apps/client/src/common/components/view-params-editor/ParamInput.module.scss b/apps/client/src/common/components/view-params-editor/ParamInput.module.scss index fd5fa2ae6..47e9a069a 100644 --- a/apps/client/src/common/components/view-params-editor/ParamInput.module.scss +++ b/apps/client/src/common/components/view-params-editor/ParamInput.module.scss @@ -1,37 +1,77 @@ -.inline { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 1rem; +.multiOptionList { + overflow-y: auto; + max-height: 12rem; + background: $gray-1200; + border: 1px solid $gray-1100; + border-radius: $component-border-radius-md; } -// styles from subtle button .toggleSelect { + box-sizing: border-box; display: flex; align-items: center; - gap: 0.25rem; - padding-inline: 0.5rem; - height: 2.5rem; - background: $gray-1050; - color: $ui-white; + gap: 0.75rem; + min-height: 2.5rem; + padding: 0.5rem 0.75rem; + background: transparent; + border: 0; + color: $gray-300; 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; - color: $blue-500; + color: $ui-white; } - &:active:not(:disabled) { + &:active { 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 { content: ''; - margin-left: 0.25rem; - width: 0.75em; - height: 0.75em; + flex: 0 0 auto; + margin-left: auto; + width: 0.625rem; + height: 0.625rem; background: var(--user-bg, $gray-900); border-radius: 50%; } diff --git a/apps/client/src/common/components/view-params-editor/ParamInput.tsx b/apps/client/src/common/components/view-params-editor/ParamInput.tsx index f95692403..764a38bb8 100644 --- a/apps/client/src/common/components/view-params-editor/ParamInput.tsx +++ b/apps/client/src/common/components/view-params-editor/ParamInput.tsx @@ -2,6 +2,7 @@ import { ComponentProps, useEffect, useState } from 'react'; import { useSearchParams } from 'react-router'; import { isStringBoolean } from '../../../views/common/viewUtils'; +import { cx } from '../../utils/styleUtils'; import Checkbox from '../checkbox/Checkbox'; import Input from '../input/input/Input'; import Select, { SelectOption } from '../select/Select'; @@ -107,18 +108,20 @@ function MultiOption({ paramField, options }: EditFormMultiOptionProps) { return ( <> -
+
{values.map((option) => { + const selected = paramState.includes(option.value); + return (