Implement Keyboard Shortcuts and Tooltip Enhancements
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added a new KeyboardKey component for consistent keyboard key styling across the application.
- Introduced KeyboardShortcut component to wrap buttons with keyboard shortcut hints, improving user accessibility.
- Enhanced Tooltip and CursorTooltip components to support a hideBorder prop, allowing for customizable tooltip appearances.
- Updated CSS styles for keyboard keys in light and dark modes, ensuring visual consistency and improved user experience.
This commit is contained in:
Tom Butcher 2026-08-21 19:34:56 +01:00
parent 59bf2702bf
commit e1fbd6070a
7 changed files with 188 additions and 53 deletions

View File

@ -357,8 +357,83 @@ code {
padding: 0 !important; padding: 0 !important;
} }
.ant-popover-inner:has(.keyboard-shortcut-tooltip) { :root,
padding: 8px !important; .light-mode,
html:has(.light-mode) {
--kbd-color: #363636;
--kbd-bg: #efefef;
--kbd-inset: #e8e8e8;
--kbd-edge: #c3c3c3;
--kbd-ridge: #c9c9c9;
--kbd-drop-shadow: #333333;
--kbd-text-shadow: #f6f6f6;
}
.dark-mode,
html:has(.dark-mode) {
--kbd-color: #ececec;
--kbd-bg: #3a3a3a;
--kbd-inset: #323232;
--kbd-edge: #1c1c1c;
--kbd-ridge: #141414;
--kbd-drop-shadow: #000000;
--kbd-text-shadow: #111111;
}
.keyboard-key {
display: inline-block;
color: var(--kbd-color);
text-decoration: none;
text-align: center;
background: var(--kbd-bg);
padding: 3px 7px;
margin: 5px 3px;
border-radius: 4px;
box-shadow:
inset 0 0 25px var(--kbd-inset),
0 1px 0 var(--kbd-edge),
0 2px 0 var(--kbd-ridge),
0 2px 3px var(--kbd-drop-shadow);
text-shadow: 0 1px 0 var(--kbd-text-shadow);
}
.keyboard-key-md {
padding: 4.5px 10.5px;
margin: 7.5px 3.5px;
font-size: 110%;
border-radius: 6px;
box-shadow:
inset 0 0 37.5px var(--kbd-inset),
0 1.5px 0 var(--kbd-edge),
0 3px 0 var(--kbd-ridge),
0 3px 4.5px var(--kbd-drop-shadow);
text-shadow: 0 1.5px 0 var(--kbd-text-shadow);
}
.keyboard-key-lg {
padding: 6px 14px;
margin: 10px 6px;
font-size: 200%;
border-radius: 8px;
box-shadow:
inset 0 0 50px var(--kbd-inset),
0 2px 0 var(--kbd-edge),
0 4px 0 var(--kbd-ridge),
0 4px 6px var(--kbd-drop-shadow);
text-shadow: 0 2px 0 var(--kbd-text-shadow);
}
.keyboard-key-xl {
padding: 9px 21px;
margin: 15px 9px;
font-size: 300%;
border-radius: 12px;
box-shadow:
inset 0 0 75px var(--kbd-inset),
0 3px 0 var(--kbd-edge),
0 6px 0 var(--kbd-ridge),
0 6px 9px var(--kbd-drop-shadow);
text-shadow: 0 3px 0 var(--kbd-text-shadow);
} }
.cursor-tooltip { .cursor-tooltip {

View File

@ -15,17 +15,19 @@ const getTooltipRoot = () => {
return root return root
} }
const CursorTooltip = ({ content, visible, pointRef }) => { const CursorTooltip = ({ content, visible, pointRef, hideBorder = false }) => {
const tooltipRef = useRef(null) const tooltipRef = useRef(null)
const sizeRef = useRef({ width: 0, height: 0 }) const sizeRef = useRef({ width: 0, height: 0 })
const frameRef = useRef(null) const frameRef = useRef(null)
const contentRef = useRef(content) const contentRef = useRef(content)
const lastContentRef = useRef(content) const lastContentRef = useRef(content)
const lastHideBorderRef = useRef(hideBorder)
const [root, setRoot] = useState(null) const [root, setRoot] = useState(null)
contentRef.current = content contentRef.current = content
if (content != null && content !== false && content !== '') { if (content != null && content !== false && content !== '') {
lastContentRef.current = content lastContentRef.current = content
lastHideBorderRef.current = hideBorder
} }
const applyPosition = useCallback(() => { const applyPosition = useCallback(() => {
@ -96,10 +98,28 @@ const CursorTooltip = ({ content, visible, pointRef }) => {
if (!root) return null if (!root) return null
const tooltipContent = lastHideBorderRef.current ? (
lastContentRef.current
) : (
<Card
size='small'
styles={{ body: { padding: '4px 10px' } }}
style={{
maxWidth: 360,
borderRadius: 10,
backgroundColor: 'var(--layout-modal-bg)'
}}
>
{lastContentRef.current}
</Card>
)
return createPortal( return createPortal(
<div <div
ref={tooltipRef} ref={tooltipRef}
className={`cursor-tooltip${visible ? ' is-visible' : ''}`} className={`cursor-tooltip${visible ? ' is-visible' : ''}${
lastHideBorderRef.current ? ' hide-border' : ''
}`}
style={{ style={{
position: 'fixed', position: 'fixed',
top: 0, top: 0,
@ -109,17 +129,7 @@ const CursorTooltip = ({ content, visible, pointRef }) => {
willChange: 'transform, opacity' willChange: 'transform, opacity'
}} }}
> >
<Card {tooltipContent}
size='small'
styles={{ body: { padding: '4px 10px' } }}
style={{
maxWidth: 360,
borderRadius: 10,
backgroundColor: 'var(--layout-modal-bg)'
}}
>
{lastContentRef.current}
</Card>
</div>, </div>,
root root
) )
@ -128,6 +138,7 @@ const CursorTooltip = ({ content, visible, pointRef }) => {
CursorTooltip.propTypes = { CursorTooltip.propTypes = {
content: PropTypes.node, content: PropTypes.node,
visible: PropTypes.bool, visible: PropTypes.bool,
hideBorder: PropTypes.bool,
pointRef: PropTypes.shape({ pointRef: PropTypes.shape({
current: PropTypes.shape({ current: PropTypes.shape({
x: PropTypes.number, x: PropTypes.number,

View File

@ -19,6 +19,7 @@ import SimpleBar from 'simplebar-react'
import { useThemeContext } from '../context/ThemeContext' import { useThemeContext } from '../context/ThemeContext'
import { filterSidebarItemsByListPermission } from '../../../database/Sidebars' import { filterSidebarItemsByListPermission } from '../../../database/Sidebars'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import KeyboardShortcut from './KeyboardShortcut'
const { Sider } = Layout const { Sider } = Layout
const CollapsedItemIcon = ({ className, icon, title }) => { const CollapsedItemIcon = ({ className, icon, title }) => {
@ -158,13 +159,21 @@ const DashboardSidebar = ({
<Flex vertical style={{ width: '100%' }}> <Flex vertical style={{ width: '100%' }}>
<Divider style={{ margin: 0 }} /> <Divider style={{ margin: 0 }} />
<Flex style={{ padding: '4px', width: '100%' }}> <Flex style={{ padding: '4px', width: '100%' }}>
<Button <KeyboardShortcut
size={isElectron ? 'middle' : 'large'} shortcut='alt+shift+s'
type='text' hint='ALT ⇧ S'
icon={collapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />} onTrigger={() => handleCollapse(!collapsed)}
style={{ flexGrow: 1, width: '100%' }} >
onClick={() => handleCollapse(!collapsed)} <Button
/> size={isElectron ? 'middle' : 'large'}
type='text'
icon={
collapsed ? <ExpandSidebarIcon /> : <CollapseSidebarIcon />
}
style={{ flexGrow: 1, width: '100%' }}
onClick={() => handleCollapse(!collapsed)}
/>
</KeyboardShortcut>
</Flex> </Flex>
</Flex> </Flex>
</Flex> </Flex>

View File

@ -0,0 +1,29 @@
import PropTypes from 'prop-types'
const SIZE_CLASS = {
sm: '',
md: 'keyboard-key-md',
lg: 'keyboard-key-lg',
xl: 'keyboard-key-xl'
}
const KeyboardKey = ({ children, size = 'sm', className }) => {
const sizeClass = SIZE_CLASS[size] ?? ''
return (
<kbd
className={['keyboard-key', sizeClass, className]
.filter(Boolean)
.join(' ')}
>
{children}
</kbd>
)
}
KeyboardKey.propTypes = {
children: PropTypes.node.isRequired,
size: PropTypes.oneOf(['sm', 'md', 'lg', 'xl']),
className: PropTypes.string
}
export default KeyboardKey

View File

@ -1,8 +1,9 @@
import { useEffect, useRef, cloneElement, useMemo } from 'react' import { useEffect, useRef, cloneElement, useMemo } from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { Popover, Typography } from 'antd'
import loglevel from 'loglevel' import loglevel from 'loglevel'
import config from '../../../config' import config from '../../../config'
import Tooltip from './Tooltip'
import KeyboardKey from './KeyboardKey'
const logger = loglevel.getLogger('ApiServerContext') const logger = loglevel.getLogger('ApiServerContext')
logger.setLevel(config.logLevel) logger.setLevel(config.logLevel)
@ -42,8 +43,6 @@ function getPressedKey(event) {
return null return null
} }
const { Text } = Typography
const KeyboardShortcut = ({ const KeyboardShortcut = ({
shortcut, shortcut,
children, children,
@ -98,17 +97,15 @@ const KeyboardShortcut = ({
const element = cloneElement(children, { ref: childRef }) const element = cloneElement(children, { ref: childRef })
if (hint) { if (hint) {
var osSpecificHint = hint.replaceAll('ALT', '⌥')
return ( return (
<Popover <Tooltip
content={ hideBorder
<Text keyboard className='keyboard-shortcut-tooltip'> title={<KeyboardKey size='md'>{osSpecificHint}</KeyboardKey>}
{hint}
</Text>
}
arrow={false}
> >
{element} {element}
</Popover> </Tooltip>
) )
} }
return element return element

View File

@ -1,10 +1,4 @@
import { import { cloneElement, isValidElement, useEffect, useId, useRef } from 'react'
cloneElement,
isValidElement,
useEffect,
useId,
useRef
} from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { useTooltipContext } from '../context/TooltipContext' import { useTooltipContext } from '../context/TooltipContext'
@ -13,7 +7,13 @@ const mergeHandler = (original, next) => (event) => {
original?.(event) original?.(event)
} }
const Tooltip = ({ children, title, content, listenParents = 0 }) => { const Tooltip = ({
children,
title,
content,
listenParents = 0,
hideBorder = false
}) => {
const { showTooltip, hideTooltip } = useTooltipContext() const { showTooltip, hideTooltip } = useTooltipContext()
const id = useId() const id = useId()
const tooltipContent = title ?? content const tooltipContent = title ?? content
@ -21,7 +21,9 @@ const Tooltip = ({ children, title, content, listenParents = 0 }) => {
const hoveredTargetsRef = useRef(new Set()) const hoveredTargetsRef = useRef(new Set())
const hoveringRef = useRef(false) const hoveringRef = useRef(false)
const tooltipContentRef = useRef(tooltipContent) const tooltipContentRef = useRef(tooltipContent)
const hideBorderRef = useRef(hideBorder)
tooltipContentRef.current = tooltipContent tooltipContentRef.current = tooltipContent
hideBorderRef.current = hideBorder
useEffect(() => { useEffect(() => {
return () => hideTooltip(id) return () => hideTooltip(id)
@ -29,14 +31,20 @@ const Tooltip = ({ children, title, content, listenParents = 0 }) => {
useEffect(() => { useEffect(() => {
if (hoveringRef.current) { if (hoveringRef.current) {
showTooltip(id, tooltipContent) showTooltip(id, tooltipContent, undefined, undefined, hideBorder)
} }
}, [id, showTooltip, tooltipContent]) }, [id, showTooltip, tooltipContent, hideBorder])
const onMouseEnter = (event) => { const onMouseEnter = (event) => {
hoveredTargetsRef.current.add(event.currentTarget) hoveredTargetsRef.current.add(event.currentTarget)
hoveringRef.current = true hoveringRef.current = true
showTooltip(id, tooltipContentRef.current, event.clientX, event.clientY) showTooltip(
id,
tooltipContentRef.current,
event.clientX,
event.clientY,
hideBorderRef.current
)
} }
const onMouseLeave = (event) => { const onMouseLeave = (event) => {
@ -82,11 +90,7 @@ const Tooltip = ({ children, title, content, listenParents = 0 }) => {
}, [listenParents]) }, [listenParents])
const wrapWithSpan = (node) => ( const wrapWithSpan = (node) => (
<span <span ref={spanRef} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
ref={spanRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{node} {node}
</span> </span>
) )
@ -115,7 +119,8 @@ Tooltip.propTypes = {
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
title: PropTypes.node, title: PropTypes.node,
content: PropTypes.node, content: PropTypes.node,
listenParents: PropTypes.number listenParents: PropTypes.number,
hideBorder: PropTypes.bool
} }
export default Tooltip export default Tooltip

View File

@ -17,6 +17,7 @@ const FADE_OUT_MS = 250
export const TooltipProvider = ({ children }) => { export const TooltipProvider = ({ children }) => {
const [content, setContent] = useState(null) const [content, setContent] = useState(null)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [hideBorder, setHideBorder] = useState(false)
const stackRef = useRef([]) const stackRef = useRef([])
const hideTimerRef = useRef(null) const hideTimerRef = useRef(null)
const fadeTimerRef = useRef(null) const fadeTimerRef = useRef(null)
@ -46,7 +47,7 @@ export const TooltipProvider = ({ children }) => {
}, []) }, [])
const showTooltip = useCallback( const showTooltip = useCallback(
(id, nextContent, x, y) => { (id, nextContent, x, y, nextHideBorder = false) => {
if (nextContent == null || nextContent === false || nextContent === '') { if (nextContent == null || nextContent === false || nextContent === '') {
return return
} }
@ -57,11 +58,13 @@ export const TooltipProvider = ({ children }) => {
clearTimers() clearTimers()
const hideBorderValue = Boolean(nextHideBorder)
stackRef.current = [ stackRef.current = [
...stackRef.current.filter((entry) => entry.id !== id), ...stackRef.current.filter((entry) => entry.id !== id),
{ id, content: nextContent } { id, content: nextContent, hideBorder: hideBorderValue }
] ]
setContent(nextContent) setContent(nextContent)
setHideBorder(hideBorderValue)
if (!visibleRef.current) { if (!visibleRef.current) {
window.requestAnimationFrame(() => setVisible(true)) window.requestAnimationFrame(() => setVisible(true))
@ -81,6 +84,7 @@ export const TooltipProvider = ({ children }) => {
if (last) { if (last) {
setContent(last.content) setContent(last.content)
setHideBorder(Boolean(last.hideBorder))
setVisible(true) setVisible(true)
return return
} }
@ -100,7 +104,12 @@ export const TooltipProvider = ({ children }) => {
return ( return (
<TooltipContext.Provider value={{ showTooltip, hideTooltip }}> <TooltipContext.Provider value={{ showTooltip, hideTooltip }}>
{children} {children}
<CursorTooltip content={content} visible={visible} pointRef={pointRef} /> <CursorTooltip
content={content}
visible={visible}
pointRef={pointRef}
hideBorder={hideBorder}
/>
</TooltipContext.Provider> </TooltipContext.Provider>
) )
} }