Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions client/common/Tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import userEvent from '@testing-library/user-event';
import { render, screen } from '../test-utils';
import { Tooltip } from './Tooltip';

describe('Tooltip', () => {
it('renders the child element', () => {
render(
<Tooltip content="This is a tooltip">
<button>Hover me</button>
</Tooltip>
);
expect(screen.getByRole('button')).toBeInTheDocument();
expect(screen.getByText('Hover me')).toBeInTheDocument();
});

it('does not show the tooltip when the user is not hovering over the element', () => {
render(
<Tooltip content="Tooltip text">
<button>Button</button>
</Tooltip>
);

const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
expect(button).not.toHaveClass('tooltipped-visible');
});

it('shows the tooltip if the user hovers over the element', async () => {
const user = userEvent.setup();
render(
<Tooltip content="Tooltip text">
<button>Button</button>
</Tooltip>
);

const button = screen.getByRole('button');
await user.hover(button);

expect(button).toHaveClass('tooltipped');
expect(button).toHaveAttribute('aria-label', 'Tooltip text');
});

it('adds the aria-label with tooltip content to the child element', () => {
render(
<Tooltip content="Save your changes">
<button>Save</button>
</Tooltip>
);

const button = screen.getByRole('button');
expect(button).toHaveAttribute('aria-label', 'Save your changes');
});

it('applies tooltipped-no-delay class when noDelay is true', () => {
render(
<Tooltip content="No delay tooltip" noDelay>
<button>Button</button>
</Tooltip>
);

const button = screen.getByRole('button');
expect(button).toHaveClass('tooltipped-no-delay');
});

it('does not apply tooltipped-no-delay class when noDelay is false', () => {
render(
<Tooltip content="Normal tooltip" noDelay={false}>
<button>Button</button>
</Tooltip>
);

const button = screen.getByRole('button');
expect(button).not.toHaveClass('tooltipped-no-delay');
});

it('preserves existing className on the child element', () => {
render(
<Tooltip content="Tooltip">
<button className="custom-class">Button</button>
</Tooltip>
);

const button = screen.getByRole('button');
expect(button).toHaveClass('custom-class');
expect(button).toHaveClass('tooltipped');
});

it('wraps the child in a tooltip-wrapper span', () => {
const { container } = render(
<Tooltip content="Tooltip">
<button>Button</button>
</Tooltip>
);

const wrapper = container.querySelector('.tooltip-wrapper');
expect(wrapper).toBeInTheDocument();
expect(wrapper?.tagName.toLowerCase()).toBe('span');
});
});
35 changes: 35 additions & 0 deletions client/common/Tooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { ReactElement, useMemo } from 'react';

export type TooltipProps = {
content: string;
noDelay?: boolean;
children: ReactElement;
};

export function Tooltip({ content, noDelay = false, children }: TooltipProps) {
const tooltipClasses = useMemo(() => {
const existingClassName = children.props?.className || '';
return [
existingClassName,
'tooltipped',
'tooltipped-n',
noDelay && 'tooltipped-no-delay'
]
.filter(Boolean)
.join(' ');
}, [children.props?.className, noDelay]);

const childProps = useMemo(
() => ({
'aria-label': content,
className: tooltipClasses
}),
[content, tooltipClasses]
);

return (
<span className="tooltip-wrapper">
{React.cloneElement(children, childProps)}
</span>
);
}
35 changes: 26 additions & 9 deletions client/components/Menubar/MenubarItem.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect, useContext, useRef } from 'react';
import { MenubarContext, SubmenuContext, ParentMenuContext } from './contexts';
import { ButtonOrLink, ButtonOrLinkProps } from '../../common/ButtonOrLink';
import { Tooltip, TooltipProps } from '../../common/Tooltip';

export enum MenubarItemRole {
MENU_ITEM = 'menuitem',
Expand All @@ -13,6 +14,7 @@ export interface MenubarItemProps extends Omit<ButtonOrLinkProps, 'role'> {
*/
role?: MenubarItemRole;
selected?: boolean;
tooltipContent?: TooltipProps['content'];
}

/**
Expand Down Expand Up @@ -54,6 +56,7 @@ export function MenubarItem({
role: customRole = MenubarItemRole.MENU_ITEM,
isDisabled = false,
selected = false,
tooltipContent,
...rest
}: MenubarItemProps) {
const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext);
Expand Down Expand Up @@ -94,15 +97,29 @@ export function MenubarItem({
ref={menuItemRef}
onMouseEnter={handleMouseEnter}
>
<ButtonOrLink
{...rest}
{...handlers}
{...ariaSelected}
role={role}
tabIndex={-1}
id={id}
isDisabled={isDisabled}
/>
{tooltipContent ? (
<Tooltip content={tooltipContent}>
<ButtonOrLink
{...rest}
{...handlers}
{...ariaSelected}
role={role}
tabIndex={-1}
id={id}
isDisabled={isDisabled}
/>
</Tooltip>
) : (
<ButtonOrLink
{...rest}
{...handlers}
{...ariaSelected}
role={role}
tabIndex={-1}
id={id}
isDisabled={isDisabled}
/>
)}
</li>
);
}
32 changes: 29 additions & 3 deletions client/modules/IDE/components/Header/Nav.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,12 @@ const ProjectMenu = () => {
isDisabled={
!user.authenticated ||
!isLoginEnabled ||
(project?.owner && !isUserOwner)
(!!project?.owner && !isUserOwner)
}
tooltipContent={
!user.authenticated || !isLoginEnabled
? t('Nav.File.SaveTooltipUnauthenticated')
: undefined
}
onClick={() => saveSketch(cmRef.current)}
>
Expand All @@ -194,36 +199,57 @@ const ProjectMenu = () => {
</MenubarItem>
<MenubarItem
id="file-duplicate"
isDisabled={isUnsaved || !user.authenticated}
isDisabled={!user.authenticated || isUnsaved}
tooltipContent={
!user.authenticated
? t('Nav.File.DuplicateTooltipUnauthenticated')
: undefined
}
onClick={() => dispatch(cloneProject())}
>
{t('Nav.File.Duplicate')}
</MenubarItem>
<MenubarItem
id="file-share"
isDisabled={isUnsaved}
tooltipContent={
isUnsaved ? t('Nav.File.ShareTooltipUnsaved') : undefined
}
onClick={shareSketch}
>
{t('Nav.File.Share')}
</MenubarItem>
<MenubarItem
id="file-download"
isDisabled={isUnsaved}
tooltipContent={
isUnsaved ? t('Nav.File.DownloadTooltipUnsaved') : undefined
}
onClick={downloadSketch}
>
{t('Nav.File.Download')}
</MenubarItem>
<MenubarItem
id="file-open"
isDisabled={!user.authenticated}
tooltipContent={
!user.authenticated
? t('Nav.File.OpenTooltipUnauthenticated')
: undefined
}
href={`/${user.username}/sketches`}
>
{t('Nav.File.Open')}
</MenubarItem>
<MenubarItem
id="file-add-to-collection"
isDisabled={
!isUiCollectionsEnabled || !user.authenticated || isUnsaved
!user.authenticated || !isUiCollectionsEnabled || isUnsaved
}
tooltipContent={
!user.authenticated
? t('Nav.File.AddToCollectionTooltipUnauthenticated')
: undefined
}
href={`/${user.username}/sketches/${project?.id}/add-to-collection`}
>
Expand Down
25 changes: 21 additions & 4 deletions client/modules/IDE/components/Sidebar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { getAuthenticated, selectCanEditSketch } from '../selectors/users';
import ConnectedFileNode from './FileNode';
import { PlusIcon } from '../../../common/icons';
import { FileDrawer } from './Editor/MobileEditor';
import { Tooltip } from '../../../common/Tooltip';

// TODO: use a generic Dropdown UI component

Expand Down Expand Up @@ -124,8 +125,8 @@ export default function SideBar() {
{t('Sidebar.AddFile')}
</button>
</li>
{isAuthenticated && (
<li>
<li>
{isAuthenticated ? (
<button
aria-label={t('Sidebar.UploadFileARIA')}
onClick={() => {
Expand All @@ -135,8 +136,24 @@ export default function SideBar() {
>
{t('Sidebar.UploadFile')}
</button>
</li>
)}
) : (
<Tooltip
content={t('Sidebar.UploadFileTooltipUnauthenticated')}
>
<button
aria-label={t('Sidebar.UploadFileARIA')}
aria-disabled
onClick={(e) => {
// prevent any action when unauthenticated
e.preventDefault();
e.stopPropagation();
}}
>
{t('Sidebar.UploadFile')}
</button>
</Tooltip>
)}
</li>
</ul>
)}
</div>
Expand Down
2 changes: 1 addition & 1 deletion client/styles/abstracts/_placeholders.scss
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@
background-color: getThemifyVariable('button-background-hover-color');
color: getThemifyVariable('button-hover-color')
}
& button, & a {
& button, & a, & .tooltip-wrapper button {
@include themify() {
color: getThemifyVariable('button-hover-color');
}
Expand Down
18 changes: 15 additions & 3 deletions client/styles/components/_nav.scss
Original file line number Diff line number Diff line change
Expand Up @@ -174,8 +174,8 @@
.nav__dropdown {
@extend %dropdown-open-left;
display: none;
max-height: 60vh;
overflow-y: auto;
max-height: none;
overflow: visible;
.nav__item--open & {
display: flex;
}
Expand Down Expand Up @@ -212,13 +212,24 @@

.nav__dropdown-item {
& button,
& a {
& a,
& .tooltip-wrapper button,
& .tooltip-wrapper a {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}

&:hover {
& .tooltip-wrapper button,
& .tooltip-wrapper a {
@include themify() {
color: getThemifyVariable('button-hover-color');
}
}
}
}

.nav__item-logo {
Expand Down Expand Up @@ -253,6 +264,7 @@
.nav__keyboard-shortcut {
font-size: #{math.div(12, $base-font-size)}rem;
font-family: Inconsololata, monospace;
margin-left: auto;

@include themify() {
color: getThemifyVariable('keyboard-shortcut-color');
Expand Down
Loading