diff --git a/src/electron/main/main.ts b/src/electron/main/main.ts index 77dff84c..268717de 100644 --- a/src/electron/main/main.ts +++ b/src/electron/main/main.ts @@ -9,6 +9,7 @@ import { Menu, MenuItemConstructorOptions, MessageBoxOptions, + PopupOptions, shell, Tray, } from 'electron'; @@ -397,8 +398,8 @@ export class Main { return dialog.showMessageBox(options); } - private onShowHelpMenu() { - this.menu.popup(); + private onShowHelpMenu(e: IpcMainEvent, options: PopupOptions) { + this.menu.popup(options); } private onRendererClosed() { diff --git a/src/electron/renderer/components/File.tsx b/src/electron/renderer/components/File.tsx index 0ef3135a..54443435 100644 --- a/src/electron/renderer/components/File.tsx +++ b/src/electron/renderer/components/File.tsx @@ -3,6 +3,7 @@ import { ChangeEvent, useState } from 'react'; import styled from 'styled-components'; import { ClearButton } from './Buttons'; import { useField } from './Form'; +import { VisuallyHiddenInput } from './VisuallyHiddenInput'; const FileOutput = styled.output` flex-grow: 1; @@ -85,8 +86,13 @@ export const File = ({ accept }: FileProps) => { ) : ( No file selected )} + Change - ); }; diff --git a/src/electron/renderer/components/Form.tsx b/src/electron/renderer/components/Form.tsx index 86ac6965..923a0712 100644 --- a/src/electron/renderer/components/Form.tsx +++ b/src/electron/renderer/components/Form.tsx @@ -33,7 +33,7 @@ const StyledField = styled(Row)` flex-shrink: 0; } - &:hover > ${StyledLabel} { + &:hover > ${StyledLabel}, &:focus-within > ${StyledLabel} { color: var(--accent); background: linear-gradient( 90deg, @@ -144,6 +144,8 @@ export const Field = forwardRef>( ref={ref} onMouseEnter={onHover ? () => onHover(name) : undefined} onMouseLeave={onHover ? () => onHover(null) : undefined} + onFocus={onHover ? () => onHover(name) : undefined} + onBlur={onHover ? () => onHover(null) : undefined} > { }); }, [props.image, props.showBoxes]); - return ; + return ; }; diff --git a/src/electron/renderer/components/KeyBind.tsx b/src/electron/renderer/components/KeyBind.tsx index 9979e1af..c40d94bd 100644 --- a/src/electron/renderer/components/KeyBind.tsx +++ b/src/electron/renderer/components/KeyBind.tsx @@ -11,6 +11,7 @@ import styled from 'styled-components'; import { ClearButton } from './Buttons'; import { Row } from './Flex'; import { OnBeforeValueChange, useField } from './Form'; +import { VisuallyHiddenInput } from './VisuallyHiddenInput'; export interface Transformer { toUniversal(input: string): string[]; @@ -49,22 +50,6 @@ const KeyBindText = styled.span` text-transform: uppercase; `; -const VisuallyHiddenInput = styled.input` - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; - white-space: nowrap; - outline: 0; - appearance: none; - top: 0; - left: 0; -`; - const KeyCode = styled.kbd` border: 1px solid var(--accent); color: var(--accent); diff --git a/src/electron/renderer/components/List.tsx b/src/electron/renderer/components/List.tsx new file mode 100644 index 00000000..1b73599b --- /dev/null +++ b/src/electron/renderer/components/List.tsx @@ -0,0 +1,68 @@ +import { NavLink } from 'react-router-dom'; +import styled from 'styled-components'; + +export const List = styled.ul` + overflow-y: auto; + padding: 0; + margin: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.5rem; + flex: 1 0 250px; // required? + align-self: stretch; // required? +`; + +export const ListItem = styled.li``; + +export const ListItemLink = styled(NavLink)` + border: 1px solid var(--primary); + background: var(--background); + height: 70px; + display: flex; + align-items: center; + color: var(--accent); + flex-shrink: 0; + text-decoration: none; + gap: 1rem; + text-transform: uppercase; + font-size: 1.2rem; + font-weight: 500; + padding: 0 1rem; + + &:focus-visible { + outline-offset: -2px; + } + + &:hover:not(.active) { + border-color: var(--accent); + background: var(--primary-darker); + } + + &.active { + border-color: var(--accent); + background: var(--accent-darker); + } +`; + +// const Sequence = styled.button<{ active?: boolean }>` +// background: ${(p) => (p.active ? 'var(--accent-dark)' : 'var(--background)')}; +// color: var(--accent); +// border: 1px solid; +// border-color: ${(p) => (p.active ? 'var(--accent)' : 'var(--primary)')}; +// padding: 1rem; +// font-weight: 500; +// font-size: 1.3rem; +// cursor: pointer; +// display: flex; +// gap: 0.3rem; + +// ${(p) => +// !p.active && +// css` +// &:hover { +// background: var(--primary-darker); +// border-color: var(--accent); +// } +// `} +// `; diff --git a/src/electron/renderer/components/RangeSlider.tsx b/src/electron/renderer/components/RangeSlider.tsx index 52ad0c72..9bd65a05 100644 --- a/src/electron/renderer/components/RangeSlider.tsx +++ b/src/electron/renderer/components/RangeSlider.tsx @@ -1,4 +1,5 @@ -import { ChangeEvent, useEffect, useState } from 'react'; +import { VK_ENTER } from '@/common'; +import { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react'; import styled from 'styled-components'; import { OnBeforeValueChange, useField } from './Form'; @@ -86,6 +87,12 @@ export function RangeSlider({ } } + function setValueOnEnter(e: KeyboardEvent) { + if (e.code === VK_ENTER) { + onValueChange(e); + } + } + return ( setDisplayValue(coerceInputValue(e))} onMouseUp={onValueChange} + onKeyUp={setValueOnEnter} /> {displayValue} diff --git a/src/electron/renderer/components/RawDataPreview.tsx b/src/electron/renderer/components/RawDataPreview.tsx index 15458335..37bca365 100644 --- a/src/electron/renderer/components/RawDataPreview.tsx +++ b/src/electron/renderer/components/RawDataPreview.tsx @@ -5,7 +5,7 @@ import { import styled from 'styled-components'; import { Col } from './Flex'; -const RawDataPreviewWrapper = styled.pre` +const RawDataPreviewWrapper = styled.pre.attrs({ tabIndex: 0 })` border: 1px solid var(--primary); background: var(--background); color: var(--accent); @@ -16,6 +16,10 @@ const RawDataPreviewWrapper = styled.pre` overflow-y: auto; margin: 0; flex-grow: 1; + + &:focus-visible { + outline-offset: -2px; + } `; // prettier-ignore diff --git a/src/electron/renderer/components/TitleBar.test.tsx b/src/electron/renderer/components/TitleBar.test.tsx new file mode 100644 index 00000000..9600ab3f --- /dev/null +++ b/src/electron/renderer/components/TitleBar.test.tsx @@ -0,0 +1,71 @@ +/** @jest-environment jsdom */ +import { render, unmountComponentAtNode } from 'react-dom'; +import { act } from 'react-dom/test-utils'; +import { TitleBar } from './TitleBar'; + +let container: HTMLDivElement = null; + +describe('Component: TitleBar', () => { + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + unmountComponentAtNode(container); + + document.body.removeChild(container); + container = null; + (global as any).api = null; + }); + + it('should render buttons', () => { + act(() => { + render(, container); + }); + + const buttons = container.querySelectorAll('button'); + + expect(buttons.length).toBe(4); + }); + + it('should send correct events', () => { + act(() => { + render(, container); + }); + + const send = jest.fn(); + (global as any).api = { send }; + + const buttons = container.querySelectorAll('button'); + const events = [ + 'main:show-help-menu', + 'main:minimize', + 'main:maximize', + 'main:close', + ]; + + buttons.forEach((b, i) => { + const event = events[i]; + + expect(b.childNodes.length).toBe(1); + expect(b.firstChild.nodeName).toBe('svg'); + + b.click(); + + expect(send).toHaveBeenCalledTimes(i + 1); + + if (event === 'main:show-help-menu') { + expect(send).toHaveBeenCalledWith( + event, + expect.objectContaining({ + x: expect.any(Number), + y: expect.any(Number), + }) + ); + } else { + expect(send).toHaveBeenCalledWith(event); + } + }); + }); +}); diff --git a/src/electron/renderer/components/TitleBar.tsx b/src/electron/renderer/components/TitleBar.tsx index ac185222..d158a6ad 100644 --- a/src/electron/renderer/components/TitleBar.tsx +++ b/src/electron/renderer/components/TitleBar.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, MouseEvent } from 'react'; import styled from 'styled-components'; const StyledSvg = styled.svg.attrs({ width: 20, height: 20 })``; @@ -62,6 +62,10 @@ const IconButton = styled.button<{ close?: boolean }>` align-items: center; -webkit-app-region: no-drag; + &:focus-visible { + outline-offset: -2px; + } + &:hover { background: ${(p) => (p.close ? '#e81123' : '#461f23')}; } @@ -71,9 +75,17 @@ const IconButton = styled.button<{ close?: boolean }>` } `; +function showHelpMenu(event: MouseEvent) { + const target = event.currentTarget as HTMLButtonElement; + const x = target.offsetLeft; + const y = target.offsetHeight; + + api.send('main:show-help-menu', { x, y }); +} + export const TitleBar = memo(() => ( - api.send('main:show-help-menu')}> + api.send('main:minimize')}> diff --git a/src/electron/renderer/components/VisuallyHiddenInput.tsx b/src/electron/renderer/components/VisuallyHiddenInput.tsx new file mode 100644 index 00000000..5f4e4003 --- /dev/null +++ b/src/electron/renderer/components/VisuallyHiddenInput.tsx @@ -0,0 +1,22 @@ +import styled from 'styled-components'; + +export const VisuallyHiddenInput = styled.input` + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; + outline: 0; + appearance: none; + top: 0; + left: 0; + + &:focus-visible + label { + outline: 2px solid var(--outline); + outline-offset: 2px; + } +`; diff --git a/src/electron/renderer/components/index.ts b/src/electron/renderer/components/index.ts index ddbd9a92..41506bc8 100644 --- a/src/electron/renderer/components/index.ts +++ b/src/electron/renderer/components/index.ts @@ -12,6 +12,7 @@ export * from './GeneralSettings'; export * from './GridViewer'; export * from './HistoryViewer'; export * from './KeyBindsSettings'; +export * from './List'; export * from './Navigation'; export * from './PerformanceSettings'; export * from './RangeSlider'; @@ -26,3 +27,4 @@ export * from './Switch'; export * from './ThirdPartyLicensesDialog'; export * from './TitleBar'; export * from './TypesFragmentStatus'; +export * from './VisuallyHiddenInput'; diff --git a/src/electron/renderer/pages/Calibrate.tsx b/src/electron/renderer/pages/Calibrate.tsx index 71d6f9a7..a272ee8a 100644 --- a/src/electron/renderer/pages/Calibrate.tsx +++ b/src/electron/renderer/pages/Calibrate.tsx @@ -41,7 +41,7 @@ export const Calibrate = () => { return ( - + {entry.fragments.map((f) => ( {fromCamelCase(f.id)} diff --git a/src/electron/renderer/pages/CalibrateFragment.tsx b/src/electron/renderer/pages/CalibrateFragment.tsx index 2bf60cea..806bf3e9 100644 --- a/src/electron/renderer/pages/CalibrateFragment.tsx +++ b/src/electron/renderer/pages/CalibrateFragment.tsx @@ -32,6 +32,19 @@ const Title = styled.h3` letter-spacing: 0; `; +const FragmentPreviewContainer = styled(Col).attrs({ + tabIndex: 0, + grow: true, + scroll: true, +})<{ isLoading: boolean }>` + justify-content: ${(p) => (p.isLoading ? 'center' : 'flex-start')}; + align-items: center; + + &:focus-visible { + outline-offset: -2px; + } +`; + interface CalibrateFormValues { showBoxes: boolean; testThreshold: number; @@ -143,14 +156,7 @@ export const CalibrateFragment = ({ entry }: CalibrateFragmentProps) => { Fragment preview - + {loading ? ( ) : ( @@ -161,7 +167,7 @@ export const CalibrateFragment = ({ entry }: CalibrateFragmentProps) => { format={entry.settings.format} /> )} - + ); diff --git a/src/electron/renderer/pages/History.tsx b/src/electron/renderer/pages/History.tsx index 83b5a4fb..cf1a89dd 100644 --- a/src/electron/renderer/pages/History.tsx +++ b/src/electron/renderer/pages/History.tsx @@ -11,7 +11,7 @@ import { } from 'react-router-dom'; import styled from 'styled-components'; import { transformTimestamp } from '../common'; -import { Col } from '../components'; +import { Col, List, ListItem, ListItemLink } from '../components'; import { StateContext } from '../state'; import { HistoryDetails } from './HistoryDetails'; @@ -102,23 +102,23 @@ export const History = () => { return ( - + {history.map((e) => { const { time, distance } = transformTimestamp(e.startedAt); return ( -
  • - + +

    {distance}

    {time}

    -
    -
  • + + ); })} -
    + diff --git a/src/electron/renderer/styles/global.tsx b/src/electron/renderer/styles/global.tsx index c19d65cf..f63df516 100644 --- a/src/electron/renderer/styles/global.tsx +++ b/src/electron/renderer/styles/global.tsx @@ -13,6 +13,7 @@ export const GlobalStyles = createGlobalStyle` --accent-darker: #213d3d; --success: #1bd676; --disabled: #bb9a95; + --outline: #ffb46b; } html, @@ -44,4 +45,9 @@ export const GlobalStyles = createGlobalStyle` ::-webkit-scrollbar-thumb { background: var(--primary); } + + :focus-visible { + outline: 2px solid var(--outline); + outline-offset: 2px; + } `;