diff --git a/client/common/Tooltip.test.tsx b/client/common/Tooltip.test.tsx
new file mode 100644
index 0000000000..f1db9f1e39
--- /dev/null
+++ b/client/common/Tooltip.test.tsx
@@ -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(
+
+
+
+ );
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveAttribute('aria-label', 'Save your changes');
+ });
+
+ it('applies tooltipped-no-delay class when noDelay is true', () => {
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).toHaveClass('tooltipped-no-delay');
+ });
+
+ it('does not apply tooltipped-no-delay class when noDelay is false', () => {
+ render(
+
+
+
+ );
+
+ const button = screen.getByRole('button');
+ expect(button).not.toHaveClass('tooltipped-no-delay');
+ });
+
+ it('preserves existing className on the child element', () => {
+ render(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ const wrapper = container.querySelector('.tooltip-wrapper');
+ expect(wrapper).toBeInTheDocument();
+ expect(wrapper?.tagName.toLowerCase()).toBe('span');
+ });
+});
diff --git a/client/common/Tooltip.tsx b/client/common/Tooltip.tsx
new file mode 100644
index 0000000000..d9bf66c836
--- /dev/null
+++ b/client/common/Tooltip.tsx
@@ -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 (
+
+ {React.cloneElement(children, childProps)}
+
+ );
+}
diff --git a/client/components/Menubar/MenubarItem.tsx b/client/components/Menubar/MenubarItem.tsx
index d2b3a1b1a3..def3568661 100644
--- a/client/components/Menubar/MenubarItem.tsx
+++ b/client/components/Menubar/MenubarItem.tsx
@@ -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',
@@ -13,6 +14,7 @@ export interface MenubarItemProps extends Omit {
*/
role?: MenubarItemRole;
selected?: boolean;
+ tooltipContent?: TooltipProps['content'];
}
/**
@@ -54,6 +56,7 @@ export function MenubarItem({
role: customRole = MenubarItemRole.MENU_ITEM,
isDisabled = false,
selected = false,
+ tooltipContent,
...rest
}: MenubarItemProps) {
const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext);
@@ -94,15 +97,29 @@ export function MenubarItem({
ref={menuItemRef}
onMouseEnter={handleMouseEnter}
>
-
+ {tooltipContent ? (
+
+
+
+ ) : (
+
+ )}
);
}
diff --git a/client/modules/IDE/components/Header/Nav.jsx b/client/modules/IDE/components/Header/Nav.jsx
index 28995a2d56..48e0935ae9 100644
--- a/client/modules/IDE/components/Header/Nav.jsx
+++ b/client/modules/IDE/components/Header/Nav.jsx
@@ -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)}
>
@@ -194,7 +199,12 @@ const ProjectMenu = () => {
dispatch(cloneProject())}
>
{t('Nav.File.Duplicate')}
@@ -202,6 +212,9 @@ const ProjectMenu = () => {
{t('Nav.File.Share')}
@@ -209,6 +222,9 @@ const ProjectMenu = () => {
{t('Nav.File.Download')}
@@ -216,6 +232,11 @@ const ProjectMenu = () => {
{t('Nav.File.Open')}
@@ -223,7 +244,12 @@ const ProjectMenu = () => {
diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx
index 24fd487c9a..7c6335689c 100644
--- a/client/modules/IDE/components/Sidebar.jsx
+++ b/client/modules/IDE/components/Sidebar.jsx
@@ -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
@@ -124,8 +125,8 @@ export default function SideBar() {
{t('Sidebar.AddFile')}
- {isAuthenticated && (
-
+
+ {isAuthenticated ? (
-
- )}
+ ) : (
+
+
+
+ )}
+
)}
diff --git a/client/styles/abstracts/_placeholders.scss b/client/styles/abstracts/_placeholders.scss
index 65e115a38a..7d61d52cb8 100644
--- a/client/styles/abstracts/_placeholders.scss
+++ b/client/styles/abstracts/_placeholders.scss
@@ -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');
}
diff --git a/client/styles/components/_nav.scss b/client/styles/components/_nav.scss
index 5d597596ac..887407db45 100644
--- a/client/styles/components/_nav.scss
+++ b/client/styles/components/_nav.scss
@@ -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;
}
@@ -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 {
@@ -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');
diff --git a/client/styles/components/_tooltip.scss b/client/styles/components/_tooltip.scss
new file mode 100644
index 0000000000..8edc0b7156
--- /dev/null
+++ b/client/styles/components/_tooltip.scss
@@ -0,0 +1,32 @@
+.tooltip-wrapper {
+ position: relative;
+ display: flex;
+ width: 100%;
+}
+
+.tooltip-wrapper .tooltipped::after {
+ @include themify() {
+ background-color: getThemifyVariable('button-background-hover-color');
+ color: getThemifyVariable('button-hover-color');
+ }
+ font-family: Montserrat, sans-serif;
+ font-size: 1rem;
+ padding: 0.5rem 0.75rem;
+ max-width: none;
+ white-space: nowrap;
+ left: 1rem;
+ right: auto;
+ transform: translateX(0);
+ text-align: left;
+}
+
+.tooltip-wrapper .tooltipped-n::before,
+.tooltip-wrapper .tooltipped::before {
+ @include themify() {
+ color: getThemifyVariable('button-background-hover-color');
+ border-top-color: getThemifyVariable('button-background-hover-color');
+ }
+ left: 1.75rem;
+ right: auto;
+ transform: translateX(0);
+}
diff --git a/client/styles/main.scss b/client/styles/main.scss
index 3792c192f1..400da75bfb 100644
--- a/client/styles/main.scss
+++ b/client/styles/main.scss
@@ -58,6 +58,7 @@
@import 'components/admonition';
@import 'components/banner';
@import 'components/visibility-dropdown';
+@import 'components/tooltip';
@import 'layout/dashboard';
@import 'layout/ide';
\ No newline at end of file
diff --git a/translations/locales/en-US/translations.json b/translations/locales/en-US/translations.json
index 1d254a5afc..3198d1a42e 100644
--- a/translations/locales/en-US/translations.json
+++ b/translations/locales/en-US/translations.json
@@ -8,7 +8,13 @@
"Open": "Open",
"Download": "Download",
"AddToCollection": "Add to Collection",
- "Examples": "Examples"
+ "Examples": "Examples",
+ "SaveTooltipUnauthenticated": "Log in to save your sketch",
+ "DuplicateTooltipUnauthenticated": "Log in to duplicate this sketch",
+ "OpenTooltipUnauthenticated": "Log in to open your sketches",
+ "AddToCollectionTooltipUnauthenticated": "Log in to add to collections",
+ "ShareTooltipUnsaved": "Save your sketch before sharing",
+ "DownloadTooltipUnsaved": "Save your sketch before downloading"
},
"Edit": {
"Title": "Edit",
@@ -275,7 +281,8 @@
"AddFile": "Create file",
"AddFileARIA": "add file",
"UploadFile": "Upload file",
- "UploadFileARIA": "upload file"
+ "UploadFileARIA": "upload file",
+ "UploadFileTooltipUnauthenticated": "Log in to upload files"
},
"FileNode": {
"OpenFolderARIA": "Open folder contents",