forked from Github/frigate
feat(web): detect, clips, snapshots toggles
This commit is contained in:
committed by
Blake Blackshear
parent
e399790442
commit
b6ba6459fb
@@ -1,4 +1,6 @@
|
||||
import { h } from 'preact';
|
||||
import { h, Fragment } from 'preact';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const ButtonColors = {
|
||||
blue: {
|
||||
@@ -22,6 +24,13 @@ const ButtonColors = {
|
||||
text:
|
||||
'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||
},
|
||||
gray: {
|
||||
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
|
||||
outlined:
|
||||
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
text:
|
||||
'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||
},
|
||||
disabled: {
|
||||
contained: 'bg-gray-400',
|
||||
outlined:
|
||||
@@ -52,6 +61,9 @@ export default function Button({
|
||||
type = 'contained',
|
||||
...attrs
|
||||
}) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const ref = useRef();
|
||||
|
||||
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||
ButtonColors[disabled ? 'disabled' : color][type]
|
||||
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||
@@ -62,18 +74,32 @@ export default function Button({
|
||||
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||
}
|
||||
|
||||
const handleMousenter = useCallback((event) => {
|
||||
setHovered(true);
|
||||
}, []);
|
||||
|
||||
const handleMouseleave = useCallback((event) => {
|
||||
setHovered(false);
|
||||
}, []);
|
||||
|
||||
const Element = href ? 'a' : 'div';
|
||||
|
||||
return (
|
||||
<Element
|
||||
role="button"
|
||||
aria-disabled={disabled ? 'true' : 'false'}
|
||||
tabindex="0"
|
||||
className={classes}
|
||||
href={href}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
<Fragment>
|
||||
<Element
|
||||
role="button"
|
||||
aria-disabled={disabled ? 'true' : 'false'}
|
||||
tabindex="0"
|
||||
className={classes}
|
||||
href={href}
|
||||
ref={ref}
|
||||
onmouseenter={handleMousenter}
|
||||
onmouseleave={handleMouseleave}
|
||||
{...attrs}
|
||||
>
|
||||
{children}
|
||||
</Element>
|
||||
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} /> : null}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function Box({
|
||||
elevated = true,
|
||||
header,
|
||||
href,
|
||||
icons = [],
|
||||
media = null,
|
||||
...props
|
||||
}) {
|
||||
@@ -26,8 +27,8 @@ export default function Box({
|
||||
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
||||
</Element>
|
||||
) : null}
|
||||
{buttons.length || content ? (
|
||||
<div className="pl-4 pb-2">
|
||||
{buttons.length || content || icons.length ? (
|
||||
<div className="px-4 pb-2">
|
||||
{content || null}
|
||||
{buttons.length ? (
|
||||
<div className="flex space-x-4 -ml-2">
|
||||
@@ -36,6 +37,12 @@ export default function Box({
|
||||
{name}
|
||||
</Button>
|
||||
))}
|
||||
<div class="flex-grow" />
|
||||
{icons.map(({ name, icon: Icon, ...props }) => (
|
||||
<Button aria-label={name} className="rounded-full" key={name} type="text" {...props}>
|
||||
<Icon className="w-6" />
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
61
web/src/components/Tooltip.jsx
Normal file
61
web/src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { h } from 'preact';
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
|
||||
const TIP_SPACE = 20;
|
||||
|
||||
export default function Tooltip({ relativeTo, text }) {
|
||||
const [position, setPosition] = useState({ top: -Infinity, left: -Infinity });
|
||||
const portalRoot = document.getElementById('tooltips');
|
||||
const ref = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if (ref && ref.current && relativeTo && relativeTo.current) {
|
||||
const windowWidth = window.innerWidth;
|
||||
const {
|
||||
x: relativeToX,
|
||||
y: relativeToY,
|
||||
width: relativeToWidth,
|
||||
height: relativeToHeight,
|
||||
} = relativeTo.current.getBoundingClientRect();
|
||||
const { width: tipWidth, height: tipHeight } = ref.current.getBoundingClientRect();
|
||||
|
||||
const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
|
||||
const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
|
||||
|
||||
let newTop = top - TIP_SPACE - tipHeight;
|
||||
let newLeft = left - Math.round(tipWidth / 2);
|
||||
// too far right
|
||||
if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
|
||||
newLeft = left - tipWidth - TIP_SPACE;
|
||||
newTop = top - Math.round(tipHeight / 2);
|
||||
}
|
||||
// too far left
|
||||
else if (newLeft < TIP_SPACE + window.scrollX) {
|
||||
newLeft = left + TIP_SPACE;
|
||||
newTop = top - Math.round(tipHeight / 2);
|
||||
}
|
||||
// too close to top
|
||||
else if (newTop <= TIP_SPACE + window.scrollY) {
|
||||
newTop = top + tipHeight + TIP_SPACE;
|
||||
}
|
||||
|
||||
setPosition({ left: newLeft, top: newTop });
|
||||
}
|
||||
}, [relativeTo, ref]);
|
||||
|
||||
const tooltip = (
|
||||
<div
|
||||
role="tooltip"
|
||||
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-opacity duration-200 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
|
||||
position.top >= 0 ? 'opacity-100' : ''
|
||||
}`}
|
||||
ref={ref}
|
||||
style={position.top >= 0 ? position : null}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
|
||||
}
|
||||
115
web/src/components/__tests__/Toolltip.test.jsx
Normal file
115
web/src/components/__tests__/Toolltip.test.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { h, createRef } from 'preact';
|
||||
import Tooltip from '../Tooltip';
|
||||
import { render, screen } from '@testing-library/preact';
|
||||
|
||||
describe('Tooltip', () => {
|
||||
test('renders in a relative position', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 50,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 40, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('105px');
|
||||
expect(style.top).toEqual('70px');
|
||||
});
|
||||
|
||||
test('if too far right, renders to the left', async () => {
|
||||
window.innerWidth = 1024;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 1000,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('942px');
|
||||
expect(style.top).toEqual('97px');
|
||||
});
|
||||
|
||||
test('if too far left, renders to the right', async () => {
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 0,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('32px');
|
||||
expect(style.top).toEqual('97px');
|
||||
});
|
||||
|
||||
test('if too close to top, renders to the bottom', async () => {
|
||||
window.scrollY = 90;
|
||||
jest
|
||||
.spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
|
||||
// relativeTo
|
||||
.mockReturnValueOnce({
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 24,
|
||||
height: 10,
|
||||
})
|
||||
// tooltip
|
||||
.mockReturnValueOnce({ width: 50, height: 15 });
|
||||
|
||||
const ref = createRef();
|
||||
render(
|
||||
<div>
|
||||
<div ref={ref} />
|
||||
<Tooltip relativeTo={ref} text="hello" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
const style = window.getComputedStyle(tooltip);
|
||||
expect(style.left).toEqual('87px');
|
||||
expect(style.top).toEqual('160px');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user