diff --git a/.storybook/preview-body.html b/.storybook/preview-body.html index 24fbaed..5aa0018 100644 --- a/.storybook/preview-body.html +++ b/.storybook/preview-body.html @@ -1,2 +1,3 @@
+
\ No newline at end of file diff --git a/package.json b/package.json index a89564b..7a82f72 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@babel/core": "^7.17.7", + "@babel/runtime": "^7.12.8", "@emotion/babel-plugin": "^11.7.2", "@storybook/addon-a11y": "^6.4.19", "@storybook/addon-actions": "^6.4.19", diff --git a/src/components/Auth/Auth.styled.tsx b/src/components/Auth/Auth.styled.tsx index 833ba72..dc61c28 100644 --- a/src/components/Auth/Auth.styled.tsx +++ b/src/components/Auth/Auth.styled.tsx @@ -2,6 +2,32 @@ import styled from '@emotion/styled'; import { pxToRem } from 'utils'; import { StyleInputProps } from './Auth.types'; +export const StyledAuthContainer = styled.div` + padding: 15vh 0 0 0; + > * { + max-width: ${pxToRem(400)}; + display: block; + width: 40vw; + min-width: ${pxToRem(300)}; + margin: 0 auto; + } +`; + +export const StyledForm = styled.form` + margin: ${pxToRem(18)} auto; + min-width: ${pxToRem(280)}; + display: flex; + flex-direction: column; + gap: ${pxToRem(4)}; + input, + button { + padding: 0 ${pxToRem(24)}; + height: ${pxToRem(36)}; + border: none; + border-radius: ${pxToRem(5)} ${pxToRem(5)}; + } +`; + export const StyledFieldError = styled.div` height: ${pxToRem(32)}; line-height: 1.8; diff --git a/src/components/Auth/Auth.tsx b/src/components/Auth/Auth.tsx index 682b691..3148cf2 100644 --- a/src/components/Auth/Auth.tsx +++ b/src/components/Auth/Auth.tsx @@ -1,35 +1,40 @@ import { FormikProps, withFormik } from 'formik'; import { FormValues, FormProps } from './Auth.types'; -import { StyledInput, StyledFieldError } from './Auth.styled'; +import { StyledForm, StyledInput, StyledFieldError, StyledAuthContainer } from './Auth.styled'; import { AUTH_FUNC, SCHEMA, INITIAL_VALUES, FIELDS, HEADING, PLACEHOLDER, TYPE } from './AuthServices'; +import { Button } from 'components/Button/Button'; const AuthForm = (props: FormProps & FormikProps): JSX.Element => { const { currentForm, values, errors, dirty, touched, isValid, handleChange, handleBlur, handleSubmit } = props; return ( -
- {FIELDS[currentForm].map((field): JSX.Element => ( - <> - - - {touched[field] && errors[field]} - - ))} + + + {FIELDS[currentForm].map( + (field): JSX.Element => ( + <> + + + {touched[field] && errors[field]} + + ), + )} - - + + + ); }; diff --git a/src/components/Button/Button.styled.tsx b/src/components/Button/Button.styled.tsx index 1ddf08d..b717eae 100644 --- a/src/components/Button/Button.styled.tsx +++ b/src/components/Button/Button.styled.tsx @@ -6,6 +6,10 @@ export const StyledButton = styled.button` border: none; background: none; border-radius: ${({ round }) => round && '30px'}; + &:disabled { + background-color: ${({ theme }) => theme.color.gray300}; + cursor: not-allowed; + } `; export const StyledOutlineButton = styled(StyledButton)` diff --git a/src/components/Button/Button.types.ts b/src/components/Button/Button.types.ts index 9867044..7e47160 100644 --- a/src/components/Button/Button.types.ts +++ b/src/components/Button/Button.types.ts @@ -12,6 +12,7 @@ export interface StyledButtonProps { className?: string; onClick?: () => void; title?: string; + disabled?: boolean; } export interface ButtonProps extends StyledButtonProps { diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000..0abbb48 --- /dev/null +++ b/src/components/Dialog/Dialog.stories.tsx @@ -0,0 +1,21 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Dialog } from './Dialog'; + +export default { + title: 'Dialog', + component: Dialog, + args: { + onClose: () => console.log('closed'), + children:

hahaha

, + nodeId: 'dialog', + label: 'test', + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const DefaultDialog = Template.bind({}); + +export const TestDialog = Template.bind({}); + +TestDialog.args = { ...DefaultDialog.args, children:

TESTTEST

}; diff --git a/src/components/Dialog/Dialog.styled.tsx b/src/components/Dialog/Dialog.styled.tsx new file mode 100644 index 0000000..50573c8 --- /dev/null +++ b/src/components/Dialog/Dialog.styled.tsx @@ -0,0 +1,46 @@ +import styled from '@emotion/styled'; +import { IconButton } from 'components'; + +export const StyledDialogContainer = styled.div` + z-index: 200; + position: fixed; + top: 0; + width: 90%; + height: 90%; + overflow: auto; +`; + +export const StyledDialogContent = styled.div` + z-index: 200; + color: #121212; + background: rgba(36, 36, 36, 0.8); + backdrop-filter: blur(3px); + min-height: 100%; +`; + +export const StyledCloseButton = styled(IconButton)` + cursor: pointer; + position: absolute; + z-index: 200; + top: 20px; + right: 20px; + border: 0; + padding: 10px; + background: transparent; + color: #fefefe; + svg { + pointer-events: none; + fill: currentColor; + } +`; + +export const StyledDim = styled.div` + position: absolute; + z-index: 100; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(36, 36, 36, 0.8); + backdrop-filter: blur(2px); +`; diff --git a/src/components/Dialog/Dialog.tsx b/src/components/Dialog/Dialog.tsx new file mode 100644 index 0000000..d953e31 --- /dev/null +++ b/src/components/Dialog/Dialog.tsx @@ -0,0 +1,79 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { getTabbableElements } from 'utils'; +import { StyledCloseButton, StyledDialogContainer, StyledDialogContent, StyledDim } from './Dialog.styled'; +import { DialogProps } from './Dialog.types'; + +export function Dialog({ onClose, children, nodeId = 'dialog', label, ...restProps }: DialogProps) { + const dialogRef = useRef(null); + const openButtonRef = useRef(null); + + const handleClose = useCallback(() => { + onClose(); + openButtonRef.current.focus(); + }, [onClose]); + + useEffect(() => { + openButtonRef.current = document.activeElement; + + const tabbableElements = getTabbableElements(dialogRef.current!); + const firstTabbableElement = tabbableElements[0]; + const lastTabbableElement = tabbableElements[tabbableElements.length - 1]; + + firstTabbableElement.focus(); + let eventType = 'keydown'; + + const eventListener = (e: KeyboardEvent) => { + const { key, shiftKey, target } = e; + + if (Object.is(target, firstTabbableElement) && shiftKey && key === 'Tab') { + e.preventDefault(); + lastTabbableElement.focus(); + } + + if (Object.is(target, lastTabbableElement) && !shiftKey && key === 'Tab') { + e.preventDefault(); + firstTabbableElement.focus(); + } + + if (key === 'Escape') { + handleClose(); + } + }; + document.addEventListener(eventType as keyof DocumentEventMap, eventListener as EventListener); + document.body.style['overflow-y'] = 'hidden'; + document.getElementById('__next')!.setAttribute('aria-hidden', 'true'); + + return () => { + document.removeEventListener(eventType, eventListener as EventListener); + document.getElementById('__next')!.removeAttribute('aria-hidden'); + document.body.style['overflow-y'] = ''; + }; + }, [handleClose, label]); + + return createPortal( + <> + + {children} + + + + , + document.getElementById(nodeId), + ); +} diff --git a/src/components/Dialog/Dialog.types.ts b/src/components/Dialog/Dialog.types.ts new file mode 100644 index 0000000..f3acfe2 --- /dev/null +++ b/src/components/Dialog/Dialog.types.ts @@ -0,0 +1,9 @@ +import React from 'react'; + +export interface DialogProps { + onClose: () => void; + children: React.ReactNode; + nodeId?: string; + label: string; + [restProps: string]: any; +} diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx new file mode 100644 index 0000000..064d3aa --- /dev/null +++ b/src/pages/_document.tsx @@ -0,0 +1,17 @@ +import { Html, Head, Main, NextScript } from 'next/document'; +import { useContext } from 'react'; + +export default function Document() { + return ( + + + +
+
+
+
+ + + + ); +} diff --git a/src/pages/search/[keyword].tsx b/src/pages/search/[keyword].tsx new file mode 100644 index 0000000..5daedd2 --- /dev/null +++ b/src/pages/search/[keyword].tsx @@ -0,0 +1,47 @@ +import { useRouter } from 'next/router'; +import { NextPage } from 'next'; +import { useSearchRecipeQuery } from 'store/services'; +import { useState } from 'react'; +const RESULTS_PER_PAGE = 12; + +const Search: NextPage = ({ data }) => { + // const { + // query: { keyword }, + // } = useRouter(); + // const [currentIndex, setCurrentIndex] = useState(0); + // const { data, error, isLoading } = useSearchRecipeQuery({ + // keyword, + // number: RESULTS_PER_PAGE, + // offset: (currentIndex - 1) * RESULTS_PER_PAGE, + // }); + // console.log(data); + return ( +
+
    + {data.map(({ id, title }) => ( +
  • {title}
  • + ))} +
+
+ ); +}; + +export async function getServerSideProps(context) { + const { keyword } = context.query; + const { results: data } = await fetch(`https://spoonacular-recipe-food-nutrition-v1.p.rapidapi.com//recipes/search`, { + headers: { + 'content-type': 'application/json', + 'x-rapidapi-host': process.env.NEXT_PUBLIC_RAPID_API_HOST, + 'x-rapidapi-key': process.env.NEXT_PUBLIC_RAPID_API_KEY, + }, + params: { + query: keyword, + number: RESULTS_PER_PAGE, + offset: 0, + }, + }).then((res) => res.json()); + return { + props: { data }, + }; +} +export default Search; diff --git a/src/utils/index.ts b/src/utils/index.ts index 42bc1cc..96ee6ae 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './style'; export * from './dom'; +export * from './tabbable'; diff --git a/src/utils/tabbable.ts b/src/utils/tabbable.ts new file mode 100644 index 0000000..f5e4c5d --- /dev/null +++ b/src/utils/tabbable.ts @@ -0,0 +1,57 @@ +const focusableSelector = ` + a[href], + area[href], + button, + input, + select, + textarea, + iframe, + summary, + details, + video[controls], + audio[controls], + [contenteditable=""], + [contenteditable="true"], + [tabindex] +` + .replace(/\n\s+/g, '') + .trim(); + +export const isFocusable = (elementNode: HTMLElement): boolean => { + const current = document.activeElement; + if (current === elementNode) return true; + + const protectEvent = (e) => e.stopImmediatePropagation(); + elementNode.addEventListener('focus', protectEvent, true); + elementNode.addEventListener('blur', protectEvent, true); + elementNode.focus({ preventScroll: true }); + + const result = document.activeElement === elementNode; + elementNode.blur(); + + if (current) current.focus({ preventScroll: true }); + elementNode.removeEventListener('focus', protectEvent, true); + elementNode.removeEventListener('blur', protectEvent, true); + + return result; +}; + +export const getFocusableElements = (node: HTMLElement): HTMLElement[] => getElements(node, true); + +/* -------------------------------------------------------------------------- */ +/* TABBABLE (tabindex="-1" 제외) */ +/* -------------------------------------------------------------------------- */ + +const tabbableSelector = focusableSelector.replace(/\[tabindex\]/, '[tabindex]:not([tabindex^="-"])'); + +export const isTabbable = (elementNode: HTMLElement) => + isFocusable(elementNode) && Number(elementNode.getAttribute('tabindex')) >= 0; + +export const getTabbableElements = (node: HTMLElement) => getElements(node); + +/* -------------------------------------------------------------------------- */ +/* GET ELEMENTS */ +/* -------------------------------------------------------------------------- */ + +const getElements = (node: HTMLElement, isFocusable = false): HTMLElement[] => + Array.from(node.querySelectorAll(isFocusable ? focusableSelector : tabbableSelector));