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..cf0fb9661 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,63 @@ } .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; overflow-y: auto; + padding-block: 0.5rem 1rem; + + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } -.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; + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-700; +} + +.footer { + flex: 0 0 auto; + padding: 0.75rem 0; + border-top: 1px solid $white-10; } diff --git a/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx b/apps/client/src/common/components/navigation-menu/NavigationMenu.tsx index 7f78b6e5d..48ca0eca1 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'; @@ -17,6 +17,7 @@ import { RenameClientModal } from '../client-modal/RenameClientModal'; 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,66 +53,77 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
- Ontime - +
+ Ontime + {name && {name}} +
+
- {supportsFullscreen && ( - - Toggle Fullscreen - {fullscreen ? : } + + {supportsFullscreen && ( + } label='Fullscreen' onClick={toggle} /> + )} + } + label='Flip Screen' + onClick={() => toggleMirror()} + /> + {canUseWakeLock && ( + } + label='Keep Awake' + onClick={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 && ( -
+
)} @@ -121,6 +133,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) { ); } +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 +150,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/navigation-menu-item/NavigationMenuItem.module.scss b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuItem.module.scss index f7c0a34a0..981c090f8 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,98 @@ 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; } &.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; +} + +/** + * Presentational switch for toggle rows. + * The row itself owns the interaction (role=switch), so this is aria-hidden: + * nesting a real control inside a clickable row would double up the hit target. + * Metrics and colours are kept in sync with the medium Switch component. + */ +.toggleTrack { + flex-shrink: 0; + box-sizing: content-box; + width: 2.5rem; + height: calc(1.5rem - 4px); + padding: 2px; + border-radius: $component-border-radius-full; + background-color: $gray-1100; + transition: background-color 125ms cubic-bezier(0.26, 0.75, 0.38, 0.45); + + &::after { + content: ''; + display: block; + width: calc(1.5rem - 4px); + height: 100%; + border-radius: $component-border-radius-full; + background-color: $ui-white; + transition: translate 150ms ease; + } +} + +[aria-checked='true'] > .toggleTrack { + background-color: $blue-700; + + &::after { + translate: calc(2.5rem - (1.5rem - 4px)) 0; } } 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..4538de7d3 --- /dev/null +++ b/apps/client/src/common/components/navigation-menu/navigation-menu-item/NavigationMenuToggle.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react'; + +import { isKeyEnter } from '../../../utils/keyEvent'; + +import style from './NavigationMenuItem.module.scss'; + +interface NavigationMenuToggleProps { + checked: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +} + +/** + * A menu row which reflects an on/off state. + * We keep the div + Enter handling of NavigationMenuItem instead of a button: + * Space is a global hotkey for toggling the menu, and a button would react to it as well. + */ +export default function NavigationMenuToggle({ checked, icon, label, onClick }: NavigationMenuToggleProps) { + return ( +
{ + if (isKeyEnter(event)) { + onClick(); + } + }} + > + {icon} + {label} + +
+ ); +} 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..02fe094cc 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,11 +1,14 @@ .header { - font-size: calc(1rem - 2px); - margin-left: 1rem; + padding: 0 1.25rem 0.25rem; + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; color: $gray-700; } .interfaces { - padding: 0.5rem 0.5rem; + padding: 0.25rem 1.25rem 0; display: flex; flex-wrap: wrap; gap: 0.5rem; diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss index 4cb5a43e6..b6262805e 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.module.scss @@ -1,13 +1,3 @@ -.drawerFooter { - display: flex; - justify-content: end; - gap: $section-spacing; - - button { - padding: 0 2em; - } -} - .backdrop { position: fixed; inset: 0; @@ -34,11 +24,11 @@ display: flex; flex-direction: column; - padding-block: 1rem 1.5rem; background-color: $gray-1250; color: $ui-white; border-left: 1px solid $gray-1100; + box-shadow: $box-shadow-l1; &[data-open] { transform: translateX(0%); @@ -58,36 +48,58 @@ } .header { - padding-inline: 1rem; + flex: 0 0 auto; display: flex; align-items: center; justify-content: space-between; - height: 3.5rem; + gap: 1rem; + min-height: 4rem; + padding: 0.75rem 1rem; + 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; +} + +.target { + font-size: $aux-text-size; + color: $gray-600; } .body { flex: 1; - padding-inline: 1rem; - padding-bottom: 10vh; + padding: 1rem; overflow-y: auto; + + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } .footer { + flex: 0 0 auto; display: flex; - padding-inline: 1rem; + padding: 0.75rem 1rem; gap: 1rem; align-items: center; justify-content: flex-end; + + background-color: $gray-1250; + border-top: 1px solid $white-10; } .sectionList { display: flex; flex-direction: column; - gap: 2rem; - padding-right: 0.5rem; + gap: 1rem; } .info { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx index 6e376ccc3..95068206f 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsEditor.tsx @@ -18,6 +18,19 @@ import ViewParamsSection from './ViewParamsSection'; import style from './ViewParamsEditor.module.scss'; +/** User facing names for the views we can customise */ +const viewLabels: Record = { + [OntimeView.Editor]: 'Editor', + [OntimeView.Cuesheet]: 'Cuesheet', + [OntimeView.Operator]: 'Operator', + [OntimeView.Timer]: 'Timer', + [OntimeView.Backstage]: 'Backstage', + [OntimeView.Timeline]: 'Timeline', + [OntimeView.StudioClock]: 'Studio Clock', + [OntimeView.Countdown]: 'Countdown', + [OntimeView.ProjectInfo]: 'Project Info', +}; + interface EditFormDrawerProps { target: OntimeView; viewOptions: ViewOption[]; @@ -71,8 +84,17 @@ function ViewParamsEditor({ target, viewOptions }: EditFormDrawerProps) {
- Customise - +
+ Customise + {viewLabels[target]} +
+
diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss index f8877f094..4476c3538 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.module.scss @@ -1,22 +1,45 @@ .presetSection { - background-color: $gray-1350; - display: flex; - flex-direction: column; - gap: 0.25rem; - padding: 1rem 0.5rem; + background-color: $white-2; + border: 1px solid $gray-1100; + border-radius: $panel-border-radius; margin-bottom: 1rem; +} + +.header { + padding: 0.5rem 1rem; + min-height: 2.75rem; + display: flex; + align-items: center; + + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-300; +} + +.presetList { + padding-inline: 0.5rem; + border-top: 1px solid $white-7; max-height: 10rem; overflow-y: auto; scrollbar-gutter: stable; + scrollbar-width: thin; + scrollbar-color: $gray-1000 transparent; } .preset { display: flex; align-items: center; gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: $component-border-radius-md; + font-size: $inner-section-text-size; &.active { - color: $blue-500; + background-color: rgba($blue-500, 0.16); + color: $ui-white; + font-weight: 600; } } diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx index 80386b75c..a65c8caf9 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsPresets.tsx @@ -26,22 +26,25 @@ export function ViewParamsPresets({ target }: { target: OntimeView }) { return (
- {viewPresets.map((preset) => { - const active = searchParams.get('alias') === preset.alias; - return ( -
-
{preset.alias}
- -
- ); - })} +
Presets
+
+ {viewPresets.map((preset) => { + const active = searchParams.get('alias') === preset.alias; + return ( +
+
{preset.alias}
+ +
+ ); + })} +
); } diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss b/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss index 9a3a010e2..97c63d814 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss +++ b/apps/client/src/common/components/view-params-editor/ViewParamsSection.module.scss @@ -1,48 +1,116 @@ .section { color: $ui-white; font-size: 1rem; + background-color: $white-2; + border: 1px solid $gray-1100; + border-radius: $panel-border-radius; } .sectionHeader { display: flex; justify-content: space-between; align-items: center; - height: 2rem; + gap: 0.5rem; + min-height: 2.75rem; + padding: 0.5rem 1rem; + + font-size: $aux-text-size; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: $gray-300; &.collapsible { cursor: pointer; + border-radius: $panel-border-radius; + 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 { - 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; - color: $label-gray; - display: flex; - flex-direction: column; - gap: 0.25rem; + + &:not(:last-child) { + border-bottom: 1px solid $white-7; + } +} + +/** + * 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 { - font-size: $inner-section-text-size; - display: block; - width: 100%; + color: $ui-white; } .description { - font-size: $inner-section-text-size; - display: block; - color: $modal-note-color; -} - -.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; + color: $gray-400; + font-size: $aux-text-size; + line-height: 1.4; + max-width: 60ch; } .hidden { diff --git a/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx b/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx index 28e5ea134..16a51da7e 100644 --- a/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx +++ b/apps/client/src/common/components/view-params-editor/ViewParamsSection.tsx @@ -1,6 +1,8 @@ import { useLocalStorage } from '@mantine/hooks'; import { IoChevronDown } from 'react-icons/io5'; +import { useSearchParams } from 'react-router'; +import { isKeyEnter } from '../../utils/keyEvent'; import { cx } from '../../utils/styleUtils'; import { OptionTitle } from './constants'; import ParamInput from './ParamInput'; @@ -14,8 +16,19 @@ interface ViewParamsSectionProps { 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) { const [collapsed, setCollapsed] = useLocalStorage({ key: `params-${title}`, defaultValue: false }); + const [searchParams] = useSearchParams(); + + // hidden options are inputs without a UI, there is nothing to frame them with + if (title === OptionTitle.Hidden) { + return ; + } const handleCollapse = () => { if (collapsible) { @@ -23,34 +36,43 @@ export default function ViewParamsSection({ title, collapsible, options }: ViewP } }; + const isCollapsed = Boolean(collapsible && collapsed); + const isCustomised = options.some((option) => searchParams.has(option.id)); + return (
- {title === OptionTitle.Hidden ? ( - - ) : ( - <> -
- {title} - {collapsible && } -
- - - )} +
{ + if (isKeyEnter(event)) { + handleCollapse(); + } + }} + role={collapsible ? 'button' : undefined} + tabIndex={collapsible ? 0 : undefined} + aria-expanded={collapsible ? !isCollapsed : undefined} + > + + {title} + {isCustomised && } + + {collapsible && } +
+ {/* collapsed options stay mounted: the form reads its values from the DOM */} +
+ +
); } -interface SectionContentsProps { - options: ParamField[]; - collapsed: boolean; -} - -function SectionContents({ options, collapsed }: SectionContentsProps) { +function SectionContents({ options }: { options: ParamField[] }) { return ( <> {options.map((option) => { return ( -