diff --git a/src/components/Buttons/DropdownButton.tsx b/src/components/Buttons/DropdownButton.tsx new file mode 100644 index 00000000..9ec2bcc4 --- /dev/null +++ b/src/components/Buttons/DropdownButton.tsx @@ -0,0 +1,134 @@ +import { ButtonControlProps, ButtonControl } from "./ButtonControl"; +import { Icon, Icons } from "components/Icon"; +import React, { + useRef, + Ref, + Dispatch, + SetStateAction, + MouseEventHandler, + KeyboardEvent, + SyntheticEvent, + useEffect, + useState, +} from "react"; + +import { Backdrop, useBackdrop } from "components/layout/Backdrop"; + +export interface DropdownButtonProps extends ButtonControlProps { + icon: Icons; + open: boolean; + setOpen: Dispatch>; + selectedItem: string; + setSelectedItem: Dispatch>; + options: Array; +} + +export interface OptionProps { + option: OptionItem; + onClick: MouseEventHandler; + tabIndex?: number; +} + +export interface OptionItem { + id: string; + name: string; + icon: Icons; +} + +function Option({ option, onClick, tabIndex }: OptionProps) { + return ( +
+ + + +
+ ); +} + +export const DropdownButton = React.forwardRef< + HTMLDivElement, + DropdownButtonProps +>((props, ref) => { + const [setBackdrop, backdropProps, highlightedProps] = useBackdrop(); + const [delayedSelectedId, setDelayedSelectedId] = useState( + props.selectedItem + ); + + useEffect(() => { + let id: NodeJS.Timeout; + + if (props.open) { + setDelayedSelectedId(props.selectedItem); + } else { + id = setTimeout(() => { + setDelayedSelectedId(props.selectedItem); + }, 200); + } + return () => { + if (id) clearTimeout(id); + }; + }, [props.open]); + + const selectedItem: OptionItem = props.options.find( + (opt) => opt.id === props.selectedItem + ) || { id: "movie", name: "movie", icon: Icons.ARROW_LEFT }; + + useEffect(() => { + setBackdrop(props.open); + }, [props.open]); + + const onOptionClick = (e: SyntheticEvent, option: OptionItem) => { + e.stopPropagation(); + props.setSelectedItem(option.id); + props.setOpen(false); + }; + + return ( +
+
+ + + {selectedItem.name} + + +
+ {props.options + .filter((opt) => opt.id != delayedSelectedId) + .map((opt) => ( +
+
+ props.setOpen(false)} {...backdropProps} /> +
+ ); +}); diff --git a/src/components/Buttons/IconButton.tsx b/src/components/Buttons/IconButton.tsx index ff8e6747..b786fc09 100644 --- a/src/components/Buttons/IconButton.tsx +++ b/src/components/Buttons/IconButton.tsx @@ -9,7 +9,7 @@ export function IconButton(props: IconButtonProps) { return ( {props.children} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index effc8bc4..50d9502b 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -4,10 +4,15 @@ export enum Icons { CLOCK = "clock", EYE_SLASH = "eyeSlash", ARROW_LEFT = "arrowLeft", + CHEVRON_DOWN = "chevronDown", + CLAPPER_BOARD = "clapperBoard", + FILM = "film", + DRAGON = "dragon", } export interface IconProps { icon: Icons; + className?: string; } const iconList = { @@ -16,8 +21,17 @@ const iconList = { clock: ``, eyeSlash: ``, arrowLeft: ``, -} + chevronDown: ``, + clapperBoard: ``, + film: ``, + dragon: ``, +}; export function Icon(props: IconProps) { - return ; + return ( + + ); } diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index d08c17cf..e6757ac4 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,10 +1,12 @@ -import { IconButton } from "./Buttons/IconButton"; +import { DropdownButton } from "./Buttons/DropdownButton"; import { Icons } from "./Icon"; import { TextInputControl, TextInputControlPropsNoLabel, } from "./TextInputs/TextInputControl"; +import { useState, useRef, useEffect } from "react"; + export interface SearchBarProps extends TextInputControlPropsNoLabel { buttonText?: string; onClick?: () => void; @@ -12,17 +14,61 @@ export interface SearchBarProps extends TextInputControlPropsNoLabel { } export function SearchBarInput(props: SearchBarProps) { + const [dropdownOpen, setDropdownOpen] = useState(false); + const [dropdownSelected, setDropdownSelected] = useState("movie"); + + const dropdownRef = useRef(); + + const handleClick = (e: MouseEvent) => { + if (dropdownRef.current?.contains(e.target as Node)) { + // inside click + return; + } + // outside click + closeDropdown(); + }; + + const closeDropdown = () => { + setDropdownOpen(false); + }; + return ( -
+
- + + setDropdownOpen((old) => !old)} + ref={dropdownRef} + > {props.buttonText || "Search"} - +
); } diff --git a/src/components/Text/Tagline.tsx b/src/components/Text/Tagline.tsx new file mode 100644 index 00000000..88633f5e --- /dev/null +++ b/src/components/Text/Tagline.tsx @@ -0,0 +1,7 @@ +export interface TaglineProps { + children?: React.ReactNode; +} + +export function Tagline(props: TaglineProps) { + return

{props.children}

; +} diff --git a/src/components/Text/Title.tsx b/src/components/Text/Title.tsx new file mode 100644 index 00000000..0cc373e1 --- /dev/null +++ b/src/components/Text/Title.tsx @@ -0,0 +1,7 @@ +export interface TitleProps { + children?: React.ReactNode; +} + +export function Title(props: TitleProps) { + return

{props.children}

; +} diff --git a/src/components/layout/Backdrop.tsx b/src/components/layout/Backdrop.tsx new file mode 100644 index 00000000..1cecaf52 --- /dev/null +++ b/src/components/layout/Backdrop.tsx @@ -0,0 +1,66 @@ +import { useFade } from "hooks/useFade"; +import { useEffect, useState } from "react"; + +interface BackdropProps { + onClick?: (e: MouseEvent) => void; + onBackdropHide?: () => void; + active?: boolean; +} + +export function useBackdrop(): [ + (state: boolean) => void, + BackdropProps, + { style: any } +] { + const [backdrop, setBackdropState] = useState(false); + const [isHighlighted, setisHighlighted] = useState(false); + + const setBackdrop = (state: boolean) => { + setBackdropState(state); + if (state) setisHighlighted(true); + }; + + const backdropProps: BackdropProps = { + active: backdrop, + onBackdropHide() { + setisHighlighted(false); + }, + }; + + const highlightedProps = { + style: isHighlighted + ? { + zIndex: "1000", + position: "relative", + } + : {}, + }; + + return [setBackdrop, backdropProps, highlightedProps]; +} + +export function Backdrop(props: BackdropProps) { + const clickEvent = props.onClick || ((e: MouseEvent) => {}); + const animationEvent = props.onBackdropHide || (() => {}); + const [isVisible, setVisible, fadeProps] = useFade(); + + useEffect(() => { + setVisible(!!props.active); + }, [props.active]); + + useEffect(() => { + if (!isVisible) animationEvent(); + }, [isVisible]); + + if (!isVisible) return null; + + return ( +
clickEvent(e.nativeEvent)} + >
+ ); +} diff --git a/src/components/layout/Loading.tsx b/src/components/layout/Loading.tsx new file mode 100644 index 00000000..6de1742e --- /dev/null +++ b/src/components/layout/Loading.tsx @@ -0,0 +1,10 @@ +export function Loading() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/src/components/layout/SectionHeading.tsx b/src/components/layout/SectionHeading.tsx index ac4f99dc..9d19c4d7 100644 --- a/src/components/layout/SectionHeading.tsx +++ b/src/components/layout/SectionHeading.tsx @@ -10,7 +10,7 @@ interface SectionHeadingProps { export function SectionHeading(props: SectionHeadingProps) { return (
-

+

{props.icon ? ( diff --git a/src/components/layout/ThinContainer.tsx b/src/components/layout/ThinContainer.tsx index bf35380d..c1866956 100644 --- a/src/components/layout/ThinContainer.tsx +++ b/src/components/layout/ThinContainer.tsx @@ -1,12 +1,16 @@ import { ReactNode } from "react"; interface ThinContainerProps { - classNames?: string, - children?: ReactNode, + classNames?: string; + children?: ReactNode; } export function ThinContainer(props: ThinContainerProps) { - return (

- {props.children} -
) + return ( +
+ {props.children} +
+ ); } diff --git a/src/components/layout/loading.css b/src/components/layout/loading.css new file mode 100644 index 00000000..e69de29b diff --git a/src/hooks/useFade.css b/src/hooks/useFade.css new file mode 100644 index 00000000..9f1deb64 --- /dev/null +++ b/src/hooks/useFade.css @@ -0,0 +1,17 @@ +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/src/hooks/useFade.ts b/src/hooks/useFade.ts new file mode 100644 index 00000000..4ba9a9ba --- /dev/null +++ b/src/hooks/useFade.ts @@ -0,0 +1,27 @@ +import React, { useEffect, useState } from "react"; +import './useFade.css' + +export const useFade = (initial: boolean = false): [boolean, React.Dispatch>, any] => { + const [show, setShow] = useState(initial); + const [isVisible, setVisible] = useState(show); + + // Update visibility when show changes + useEffect(() => { + if (show) setVisible(true); + }, [show]); + + // When the animation finishes, set visibility to false + const onAnimationEnd = () => { + if (!show) setVisible(false); + }; + + const style = { animation: `${show ? "fadeIn" : "fadeOut"} .3s` }; + + // These props go on the fading DOM element + const fadeProps = { + style, + onAnimationEnd + }; + + return [isVisible, setShow, fadeProps]; +}; diff --git a/src/views/SearchView.tsx b/src/views/SearchView.tsx index 7a2b23b4..26ad7c83 100644 --- a/src/views/SearchView.tsx +++ b/src/views/SearchView.tsx @@ -5,6 +5,9 @@ import { useState } from "react"; import { ThinContainer } from "components/layout/ThinContainer"; import { SectionHeading } from "components/layout/SectionHeading"; import { Icons } from "components/Icon"; +import { Loading } from "components/layout/Loading"; +import { Tagline } from "components/Text/Tagline"; +import { Title } from "components/Text/Title"; export function SearchView() { const [results, setResults] = useState([]); @@ -22,12 +25,8 @@ export function SearchView() {
-

- Because watching legally is boring -

-

- What movie do you want to watch? -

+ Because watching legally is boring + What movie do you want to watch?
))} + ); } diff --git a/tailwind.config.js b/tailwind.config.js index bf00a812..db5da7d2 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,20 +2,37 @@ module.exports = { content: ["./src/**/*.{js,jsx,ts,tsx}"], theme: { extend: { + /* colors */ colors: { - "dink-900": "#131119", - "dink-500": "#252037", - "dink-400": "#231F34", - "dink-300": "#70688B", - "dink-200": "#3A364D", - "dink-150": "#8F87AB", - "dink-100": "#393447", - bink: "#D588E3", - "pink-900": "#412B57", + "bink-100": "#432449", + "bink-200": "#412B57", + "bink-300": "#533670", + "bink-400": "#714C97", + "bink-500": "#8D66B5", + "bink-600": "#A87FD1", + "bink-700": "#CD97D6", + "denim-100": "#131119", + "denim-200": "#1E1A29", + "denim-300": "#282336", + "denim-400": "#322D43", + "denim-500": "#433D55", + "denim-600": "#5A5370", + "denim-700": "#817998", }, + + /* fonts */ fontFamily: { "open-sans": "'Open Sans'", }, + + /* animations */ + keyframes: { + "loading-pin": { + "0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" }, + "20%": { height: "1em", "background-color": "white" }, + }, + }, + animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }, }, }, plugins: [],