diff --git a/client/common/Tooltip.tsx b/client/common/Tooltip.tsx new file mode 100644 index 0000000000..f5e5778c3d --- /dev/null +++ b/client/common/Tooltip.tsx @@ -0,0 +1,79 @@ +import React, { ReactElement, useRef, useState } from 'react'; + +export type TooltipDirection = 'n' | 's' | 'e' | 'w'; + +export type TooltipProps = { + content: string; + direction?: TooltipDirection; + noDelay?: boolean; + children: ReactElement; +}; + +export function Tooltip({ + content, + direction = 'n', + noDelay = false, + children +}: TooltipProps) { + const [open, setOpen] = useState(false); + const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`); + + const childProps: Record = { + 'aria-label': content, + className: [ + (children.props && children.props.className) || '', + 'tooltipped', + `tooltipped-${direction}`, + noDelay ? 'tooltipped-no-delay' : '' + ] + .filter(Boolean) + .join(' '), + 'aria-describedby': tooltipIdRef.current, + onFocus: (e: React.FocusEvent) => { + setOpen(true); + if (children.props && typeof children.props.onFocus === 'function') { + children.props.onFocus(e); + } + }, + onBlur: (e: React.FocusEvent) => { + setOpen(false); + if (children.props && typeof children.props.onBlur === 'function') { + children.props.onBlur(e); + } + }, + onMouseEnter: (e: React.MouseEvent) => { + setOpen(true); + if (children.props && typeof children.props.onMouseEnter === 'function') { + children.props.onMouseEnter(e); + } + }, + onMouseLeave: (e: React.MouseEvent) => { + setOpen(false); + if (children.props && typeof children.props.onMouseLeave === 'function') { + children.props.onMouseLeave(e); + } + }, + onKeyDown: (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setOpen(false); + (e.target as HTMLElement)?.blur(); + } + if (children.props && typeof children.props.onKeyDown === 'function') { + children.props.onKeyDown(e); + } + } + }; + + const trigger = React.cloneElement(children, childProps); + + return ( + + {trigger} + {open && ( + + {content} + + )} + + ); +} diff --git a/client/components/Menubar/MenubarItem.tsx b/client/components/Menubar/MenubarItem.tsx index d2b3a1b1a3..9b69d24304 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, TooltipDirection } from '../../common/Tooltip'; export enum MenubarItemRole { MENU_ITEM = 'menuitem', @@ -13,6 +14,8 @@ export interface MenubarItemProps extends Omit { */ role?: MenubarItemRole; selected?: boolean; + tooltipContent?: string; + tooltipDirection?: TooltipDirection; } /** @@ -54,6 +57,8 @@ export function MenubarItem({ role: customRole = MenubarItemRole.MENU_ITEM, isDisabled = false, selected = false, + tooltipContent, + tooltipDirection, ...rest }: MenubarItemProps) { const { createMenuItemHandlers, hasFocus } = useContext(MenubarContext); @@ -86,6 +91,26 @@ export function MenubarItem({ return unregister; }, [submenuItems, registerSubmenuItem]); + const buttonOrLink = ( + + ); + + const content = tooltipContent ? ( + + {buttonOrLink} + + ) : ( + buttonOrLink + ); + return (
  • - + {content}
  • ); } 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..a7f6169bf3 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/components/_nav.scss b/client/styles/components/_nav.scss index 5d597596ac..dfd08cf4c2 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; } @@ -253,6 +253,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..d4249fc443 --- /dev/null +++ b/client/styles/components/_tooltip.scss @@ -0,0 +1,31 @@ +.tooltip-wrapper { + position: relative; + display: inline-flex; +} + +.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",