+
)}
@@ -121,6 +133,15 @@ function NavigationMenu({ isOpen, onClose }: NavigationMenuProps) {
);
}
+function MenuGroup({ label, children }: PropsWithChildren<{ label: string }>) {
+ return (
+
+ );
+}
+
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 (
-