diff --git a/.storybook/preview.js b/.storybook/preview.js index 64ad6c22bf..bd50fc6a44 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,9 +1,9 @@ import React from 'react'; import { QueryClient, QueryClientProvider } from 'react-query'; import { CoreUiThemeProvider } from '../src/lib/next'; -import { brand, coreUIAvailableThemes} from '../src/lib/style/theme'; +import { brand, coreUIAvailableThemes } from '../src/lib/style/theme'; import { Wrapper } from '../stories/common'; - +import { ToastProvider } from '../src/lib'; export const globalTypes = { theme: { @@ -14,22 +14,28 @@ export const globalTypes = { title: 'Preview Theme', dynamicTitle: false, // array of plain string values or MenuItem shape (see below) - items: [{ value: 'darkRebrand', title: 'Dark', icon: 'moon' }, { value: 'artescaLight', title: 'Light', icon: 'sun' }, {value: 'ring9dark', title: 'Ring Dark', icon: 'moon'}], + items: [ + { value: 'darkRebrand', title: 'Dark', icon: 'moon' }, + { value: 'artescaLight', title: 'Light', icon: 'sun' }, + { value: 'ring9dark', title: 'Ring Dark', icon: 'moon' }, + ], }, }, }; const withThemeProvider = (Story, context) => { const theme = coreUIAvailableThemes[context.globals.theme]; - const {viewMode} = context + const { viewMode } = context; return ( {/* Wrapper to make the stories take the full screen but not in docs */} -
- - - +
+ + + + +
@@ -40,17 +46,18 @@ export const decorators = [withThemeProvider]; export const parameters = { layout: 'fullscreen', - docs:{ - toc : {headingSelector: 'h2,h3', - title: "Table of Contents"}, + docs: { + toc: { headingSelector: 'h2,h3', title: 'Table of Contents' }, }, - controls:{ + controls: { //All props with color in name will automatically have a control 'color' //with colors presets to theme colors, possible to have the color name from theme in control - presetColors: Object.entries(brand).map(color => {return {color: color[1],title:color[0] }}), - matchers:{ - color: /color/i - } + presetColors: Object.entries(brand).map((color) => { + return { color: color[1], title: color[0] }; + }), + matchers: { + color: /color/i, + }, }, options: { storySort: { @@ -60,8 +67,15 @@ export const parameters = { 'Guidelines', 'Templates', 'Components', - ['Navigation', 'Data Display', 'Inputs', 'Feedback', 'Progress & loading', 'Styling', 'Deprecated'] - + [ + 'Navigation', + 'Data Display', + 'Inputs', + 'Feedback', + 'Progress & loading', + 'Styling', + 'Deprecated', + ], ], }, }, diff --git a/package-lock.json b/package-lock.json index af307a2073..88380c7b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/react-hooks": "^8.0.1", - "@testing-library/user-event": "^13.1.9", + "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", diff --git a/package.json b/package.json index 5f1a074631..311518b45d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^11.2.7", "@testing-library/react-hooks": "^8.0.1", - "@testing-library/user-event": "^13.1.9", + "@testing-library/user-event": "^13.5.0", "@types/jest": "^27.5.0", "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", @@ -95,21 +95,21 @@ }, "dependencies": { "@floating-ui/dom": "^0.1.10", - "@storybook/preview-api": "^7.6.6", - "framer-motion": "^4.1.17", - "react-hook-form": "^7.49.2", "@fortawesome/fontawesome-free": "^5.10.2", "@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/react-fontawesome": "^0.1.14", "@js-temporal/polyfill": "^0.4.4", + "@storybook/preview-api": "^7.6.6", + "framer-motion": "^4.1.17", "polished": "3.4.1", "pretty-bytes": "^5.6.0", "react": "^17.0.2", "react-debounce-input": "3.2.2", "react-dom": "^17.0.2", "react-dropzone": "^14.2.3", + "react-hook-form": "^7.49.2", "react-query": "^3.34.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", diff --git a/src/lib/components/buttonv2/Buttonv2.component.tsx b/src/lib/components/buttonv2/Buttonv2.component.tsx index 19719289a6..f8bac3bfac 100644 --- a/src/lib/components/buttonv2/Buttonv2.component.tsx +++ b/src/lib/components/buttonv2/Buttonv2.component.tsx @@ -4,6 +4,13 @@ import { spacing } from '../../spacing'; import { fontSize, fontWeight } from '../../style/theme'; import { Loader } from '../loader/Loader.component'; import { Tooltip, Props as TooltipProps } from '../tooltip/Tooltip.component'; + +export const FocusVisibleStyle = css` + outline: dashed ${spacing.r2} ${(props) => props.theme.selectedActive}; + outline-offset: ${spacing.r2}; + z-index: 1000; +`; + export type Props = Omit< ButtonHTMLAttributes, 'size' | 'label' @@ -54,8 +61,7 @@ export const ButtonStyled = styled.button` } // :focus-visible is the keyboard-only version of :focus &:focus-visible:enabled { - outline: dashed ${spacing.r2} ${brand.selectedActive}; - outline-offset: ${spacing.r2}; + ${FocusVisibleStyle} color: ${brand.textPrimary}; } &:active:enabled { @@ -76,8 +82,7 @@ export const ButtonStyled = styled.button` color: ${brand.textPrimary}; } &:focus-visible:enabled { - outline: dashed ${spacing.r2} ${brand.selectedActive}; - outline-offset: ${spacing.r2}; + ${FocusVisibleStyle} color: ${brand.textPrimary}; } &:active:enabled { @@ -97,8 +102,7 @@ export const ButtonStyled = styled.button` border: ${spacing.r1} solid ${brand.infoPrimary}; } &:focus-visible:enabled { - outline: dashed ${spacing.r2} ${brand.selectedActive}; - outline-offset: ${spacing.r2}; + ${FocusVisibleStyle}s } &:active:enabled { cursor: pointer; @@ -117,8 +121,7 @@ export const ButtonStyled = styled.button` color: ${brand.textPrimary}; } &:focus-visible:enabled { - outline: dashed ${spacing.r2} ${brand.selectedActive}; - outline-offset: ${spacing.r2}; + ${FocusVisibleStyle} border-color: ${brand.buttonSecondary}; } &:active:enabled { diff --git a/src/lib/components/card/Card.component.tsx b/src/lib/components/card/Card.component.tsx index 7e63b69ed7..15cdae349d 100644 --- a/src/lib/components/card/Card.component.tsx +++ b/src/lib/components/card/Card.component.tsx @@ -1,8 +1,9 @@ // @ts-nocheck import { HTMLProps } from 'react'; import { createContext } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { hex2RGB } from '../../utils'; +import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component'; const CardContext = createContext(null); type CardElementProps = { children: React.ReactNode; @@ -101,18 +102,17 @@ const StyledCard = styled.div<{ ${(props) => props.onClick && !props.disabled - ? ` - cursor: pointer; + ? css` + cursor: pointer; - &:hover { - box-shadow: 0 0 0 2px ${props.theme.highlight}; - } + &:hover { + box-shadow: 0 0 0 2px ${props.theme.highlight}; + } - &:focus { - outline: 2px solid ${props.theme.buttonSecondary}; - outline-offset: 2px; - } - ` + &:focus { + ${FocusVisibleStyle} + } + ` : ''}; &.active { diff --git a/src/lib/components/checkbox/Checkbox.component.tsx b/src/lib/components/checkbox/Checkbox.component.tsx index 86f5ac85bd..25ee430540 100644 --- a/src/lib/components/checkbox/Checkbox.component.tsx +++ b/src/lib/components/checkbox/Checkbox.component.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import { spacing, Stack } from '../../spacing'; import { Text } from '../text/Text.component'; +import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component'; type Props = { label?: string; @@ -113,8 +114,7 @@ const StyledCheckbox = styled.label<{ } [type='checkbox']:focus-visible:enabled { - outline: dashed ${spacing.r2} ${(props) => props.theme.selectedActive}; - outline-offset: ${spacing.r2}; + ${FocusVisibleStyle} } /* Disabled */ diff --git a/src/lib/components/dropdown/Dropdown.component.tsx b/src/lib/components/dropdown/Dropdown.component.tsx index 2b4c2c8931..05c0401fb3 100644 --- a/src/lib/components/dropdown/Dropdown.component.tsx +++ b/src/lib/components/dropdown/Dropdown.component.tsx @@ -12,6 +12,8 @@ import { spacing } from '../../spacing'; import { fontSize } from '../../style/theme'; import { getThemePropSelector } from '../../utils'; import { Icon } from '../icon/Icon.component'; +import { useSelect } from 'downshift'; +import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component'; export type Item = { label: string; name?: string; @@ -41,38 +43,14 @@ const DropdownMenuStyled = styled.ul` position: absolute; margin: 0; padding: 0; + top: 50px; border: 1px solid ${getThemePropSelector('backgroundLevel1')}; z-index: ${zIndex.dropdown}; max-height: 200px; min-width: 100%; overflow: auto; border-bottom: 0.3px solid ${getThemePropSelector('border')}; - ${(props) => { - if ( - props.size && - props.triggerSize && - props.triggerSize.x + props.size.width > window.innerWidth - ) { - return css` - right: 0; - top: 100%; - `; - } else if ( - props.size && - props.triggerSize && - props.triggerSize.y + props.size.height > window.innerHeight - ) { - return css` - left: 0; - bottom: ${props.triggerSize.height + 'px'}; - `; - } else { - return css` - left: 0; - top: 100%; - `; - } - }}; + display: ${(props) => (props.isOpen ? 'auto' : 'none')}; `; const DropdownMenuItemStyled = styled.li` display: flex; @@ -81,25 +59,36 @@ const DropdownMenuItemStyled = styled.li` white-space: nowrap; cursor: pointer; font-size: ${fontSize.base}; + ${(props) => { + console.log(props.isSelected); + return props.isSelected + ? `background-color: ${props.theme.highlight};` + : `background-color: ${props.theme.backgroundLevel1};`; + }} + + color: ${getThemePropSelector('textPrimary')}; + border-top: 0.3px solid ${getThemePropSelector('border')}; + border-left: 0.3px solid ${getThemePropSelector('border')}; + border-right: 0.3px solid ${getThemePropSelector('border')}; - ${css` - background-color: ${getThemePropSelector('backgroundLevel1')}; - color: ${getThemePropSelector('textPrimary')}; - border-top: 0.3px solid ${getThemePropSelector('border')}; - border-left: 0.3px solid ${getThemePropSelector('border')}; - border-right: 0.3px solid ${getThemePropSelector('border')}; - &:hover { - background-color: ${getThemePropSelector('highlight')}; - } - &:active { - background-color: ${getThemePropSelector('highlight')}; - } - `}; + &:hover { + background-color: ${getThemePropSelector('highlight')}; + } + &:active { + background-color: ${getThemePropSelector('highlight')}; + } `; const Caret = styled.span` margin-left: ${spacing.r16}; `; -const TriggerStyled = ButtonStyled.withComponent('div'); +const Trigger = ButtonStyled.withComponent('div'); +const TriggerStyled = styled(Trigger)` + // :focus-visible is the keyboard-only version of :focus + &:focus-visible { + ${FocusVisibleStyle} + color: ${(props) => props.theme.textPrimary}; + } +`; function Dropdown({ items, @@ -111,19 +100,16 @@ function Dropdown({ caret = true, ...rest }: Props) { - const [open, setOpen] = useState(false); - const [menuSize, setMenuSize] = useState(); - const [triggerSize, setTriggerSize] = useState(); - const refMenuCallback = useCallback((node) => { - if (node !== null) { - setMenuSize(node.getBoundingClientRect()); - } - }, []); - const refTriggerCallback = useCallback((node) => { - if (node !== null) { - setTriggerSize(node.getBoundingClientRect()); - } - }, []); + const { + isOpen, + getToggleButtonProps, + getMenuProps, + getItemProps, + highlightedIndex, + } = useSelect({ + items, + itemToString: (item) => item?.label || '', + }); return ( setOpen(!open)} - onFocus={() => setOpen(!open)} - onClick={(event) => event.stopPropagation()} - tabIndex="0" title={title} - ref={refTriggerCallback} + {...getToggleButtonProps()} > {icon && ( @@ -153,29 +135,31 @@ function Dropdown({ )} - {open && ( - - {items.map(({ label, onClick, ...itemRest }) => { - return ( - - {label} - - ); - })} - - )} + + + {items.map((item, index) => { + return ( + + {item.label} + + ); + })} + ); diff --git a/src/lib/components/inlineinput/InlineInput.test.tsx b/src/lib/components/inlineinput/InlineInput.test.tsx new file mode 100644 index 0000000000..0d769879a2 --- /dev/null +++ b/src/lib/components/inlineinput/InlineInput.test.tsx @@ -0,0 +1,211 @@ +import React, { PropsWithChildren } from 'react'; +import { + QueryClient, + QueryClientProvider, + useMutation, + UseMutationResult, +} from 'react-query'; +import { ToastProvider } from '../toast/ToastProvider'; +import { + render, + screen, + waitFor, + waitForElementToBeRemoved, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { InlineInput } from './InlineInput'; + +const queryClient = new QueryClient(); +const Wrapper = ({ children }: PropsWithChildren<{}>) => { + return ( + + {children} + + ); +}; + +const ChangeMutationProvider = ({ + onChange, + children, +}: { + onChange: (value: string) => void; + children: ({ + changeMutation, + }: { + changeMutation: UseMutationResult; + }) => JSX.Element; +}) => { + const changeMutation = useMutation({ + mutationFn: ({ value }) => { + return new Promise((resolve) => { + onChange(value); + resolve(null); + }); + }, + }); + return <>{children({ changeMutation })}; +}; + +const selectors = { + confirmationModal: () => screen.getByRole('dialog', { name: /Confirm/i }), +}; + +describe('InlineInput', () => { + describe('when the user clicks accepts the edit', () => { + test('without confirmation modal', async () => { + //S + const mock = jest.fn(); + render( + + {({ changeMutation }) => ( + + )} + , + { wrapper: Wrapper }, + ); + + //E + /// First focus the edit button + await userEvent.tab(); + /// Then press enter to edit the input + await userEvent.keyboard('{enter}'); + /// Then type a new value + await userEvent.type(document.activeElement, 'new value'); + /// Then press enter to confirm the new value + await userEvent.keyboard('{enter}'); + await waitForElementToBeRemoved(() => screen.getByRole('textbox')); + + //V + expect(mock).toHaveBeenCalledWith('testnew value'); + expect(mock).toHaveBeenCalledTimes(1); + expect(screen.getByText('testnew value')).toBeInTheDocument(); + }); + + test('with confirmation modal', async () => { + //S + const mock = jest.fn(); + render( + + {({ changeMutation }) => ( + Confirm
, + body:
Are you sure?
, + }} + /> + )} + , + { wrapper: Wrapper }, + ); + + //E + /// First focus the edit button + await userEvent.tab(); + /// Then press enter to edit the input + await userEvent.keyboard('{enter}'); + /// Then type a new value + await userEvent.type(document.activeElement, 'new value'); + /// Then press enter to confirm the new value + await userEvent.keyboard('{enter}'); + /// Expect the confirmation modal to be opened + await waitFor(() => + expect(selectors.confirmationModal()).toBeInTheDocument(), + ); + /// Click the confirm button + await userEvent.click(screen.getByRole('button', { name: /confirm/i })); + /// Wait for modal to be closed + await waitForElementToBeRemoved(() => selectors.confirmationModal()); + + //V + expect(mock).toHaveBeenCalledWith('testnew value'); + expect(mock).toHaveBeenCalledTimes(1); + expect(screen.getByText('testnew value')).toBeInTheDocument(); + }); + }); + + describe('when the user clicks reset the edit', () => { + test('without confirmation modal', async () => { + //S + const mock = jest.fn(); + render( + + {({ changeMutation }) => ( + + )} + , + { wrapper: Wrapper }, + ); + + //E + /// First focus the edit button + await userEvent.tab(); + /// Then press enter to edit the input + await userEvent.keyboard('{enter}'); + /// Then type a new value + await userEvent.type(document.activeElement, 'new value'); + /// Then press escape to cancel the new value + await userEvent.keyboard('{esc}'); + + //V + expect(mock).not.toHaveBeenCalled(); + expect(screen.getByText('test')).toBeInTheDocument(); + }); + test('with confirmation modal', async () => { + //S + const mock = jest.fn(); + render( + + {({ changeMutation }) => ( + Confirm, + body:
Are you sure?
, + }} + /> + )} +
, + { wrapper: Wrapper }, + ); + + //E + /// First focus the edit button + await userEvent.tab(); + /// Then press enter to edit the input + await userEvent.keyboard('{enter}'); + /// Then type a new value + await userEvent.type(document.activeElement, 'new value'); + /// Then press enter to confirm the new value + await userEvent.keyboard('{enter}'); + /// Expect the confirmation modal to be opened + await waitFor(() => + expect(selectors.confirmationModal()).toBeInTheDocument(), + ); + /// Click the cancel button + await userEvent.click( + within(selectors.confirmationModal()).getByRole('button', { + name: /Cancel/i, + }), + ); + + //V + expect(mock).not.toHaveBeenCalled(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('textbox').value).toBe('testnew value'); + }); + }); +}); diff --git a/src/lib/components/inlineinput/InlineInput.tsx b/src/lib/components/inlineinput/InlineInput.tsx new file mode 100644 index 0000000000..872a0ea37d --- /dev/null +++ b/src/lib/components/inlineinput/InlineInput.tsx @@ -0,0 +1,179 @@ +import styled from 'styled-components'; +import { Button } from '../buttonv2/Buttonv2.component'; +import { Icon } from '../icon/Icon.component'; +import { Input, InputProps } from '../inputv2/inputv2'; +import { Modal } from '../modal/Modal.component'; +import { useToast } from '../toast/ToastProvider'; +import { useForm } from 'react-hook-form'; +import { UseMutationResult } from 'react-query'; +import { Text } from '../text/Text.component'; +import { useState } from 'react'; +import { Stack, Wrap } from '../../spacing'; + +const UnderlinedText = styled(Text)` + text-decoration-line: underline; + text-decoration-style: dashed; + cursor: text; +`; + +type InlineInputForm = { + value: string; +}; +type InlineInputProps = { + defaultValue?: string; + confirmationModal?: { + title: JSX.Element; + body: JSX.Element; + }; + changeMutation: UseMutationResult; +} & InputProps; + +export const InlineInput = ({ + defaultValue, + confirmationModal, + changeMutation, + ...props +}: InlineInputProps) => { + const { register, handleSubmit, watch, reset } = useForm({ + defaultValues: { + value: defaultValue, + }, + }); + const [isConfirmationModalOpened, setIsConfirmationModalOpened] = + useState(false); + const handleSuccess = () => { + setIsConfirmationModalOpened(false); + setIsEditing(false); + setIsHover(false); + }; + const onSubmit = (data: InlineInputForm) => { + if (confirmationModal) { + setIsConfirmationModalOpened(true); + } else { + changeMutation.mutate(data, { + onSuccess: () => { + handleSuccess(); + }, + onError: () => { + showToast({ + open: true, + status: 'error', + message: 'An error occurred while updating the value', + }); + }, + }); + } + }; + const { showToast } = useToast(); + const [isHover, setIsHover] = useState(false); + const [isEditing, setIsEditing] = useState(false); + + const handleReset = () => { + reset(); + setIsEditing(false); + setIsHover(false); + }; + + //handle esc key to cancel editing + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + handleReset(); + } + }; + + if (isEditing) { + return ( + <> +
+ + +