diff --git a/app/components/Dropdown.tsx b/app/components/Dropdown.tsx index 6722abb..3d6e708 100644 --- a/app/components/Dropdown.tsx +++ b/app/components/Dropdown.tsx @@ -1,82 +1,320 @@ -import { useState } from "react"; +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import styled, { css } from "styled-components"; type MenuItem = { label: string; - onClick?: () => void; }; type DropdownProps = { buttonLabel: string; menuItems: MenuItem[]; + onChange?: (index: number, label: string) => void; }; -export default function Dropdown({ buttonLabel, menuItems }: DropdownProps) { +export default function Dropdown({ + buttonLabel, + menuItems, + onChange, +}: DropdownProps) { const [isOpen, setIsOpen] = useState(false); + const containerRef = useRef(null); + const itemsRef = useRef>([]); + + // default selection: buttonLabel match -> that index; else 0 + const defaultIndex = useMemo(() => { + const matchIndex = menuItems.findIndex( + (item) => item.label === buttonLabel, + ); + return matchIndex >= 0 ? matchIndex : 0; + }, [buttonLabel, menuItems]); + + const [selectedIndex, setSelectedIndex] = useState(defaultIndex); + + useEffect(() => { + setSelectedIndex(defaultIndex); + }, [defaultIndex]); - const toggleMenu = () => { - setIsOpen((prev) => !prev); + const toggleMenu = () => setIsOpen((prev) => !prev); + const closeMenu = () => setIsOpen(false); + + // Close on Escape anywhere + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") closeMenu(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, []); + + // Close when focus leaves the container + const handleBlur: React.FocusEventHandler = (event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node)) { + closeMenu(); + } }; - const closeMenu = () => { - setIsOpen(false); + const focusItem = (index: number) => { + const el = itemsRef.current[index]; + el?.focus(); }; + // When menu opens, focus selected item (or first) + useEffect(() => { + if (isOpen) { + const id = window.setTimeout(() => focusItem(selectedIndex ?? 0), 0); + return () => window.clearTimeout(id); + } + }, [isOpen, selectedIndex]); + + const moveFocus = (direction: 1 | -1) => { + const len = menuItems.length; + const current = itemsRef.current.findIndex( + (el) => el === document.activeElement, + ); + const base = current === -1 ? selectedIndex : current; + const nextIndex = (base + direction + len) % len; + focusItem(nextIndex); + }; + + const menuId = "dropdown-menu"; + return ( -
-
- -
- - {isOpen && ( -
-
- {menuItems.map((item, index) => { - return ( - - ); - })} -
-
- )} -
+ + {/* Entire header is the control */} + { + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + setIsOpen(true); + } + }} + > + {menuItems[selectedIndex]?.label ?? buttonLabel} + arrow_drop_down + + + {/* Menu panel */} + { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + moveFocus(1); + break; + case "ArrowUp": + event.preventDefault(); + moveFocus(-1); + break; + case "Home": + event.preventDefault(); + focusItem(0); + break; + case "End": + event.preventDefault(); + focusItem(menuItems.length - 1); + break; + } + }} + > + + {menuItems.map((item, index) => { + const isSelected = index === selectedIndex; + return ( + { + itemsRef.current[index] = el; + }} + onClick={() => { + setSelectedIndex(index); + onChange?.(index, item.label); + closeMenu(); + }} + > + {/* Left icon (star) */} + star + + {/* Text */} + + {item.label} + + + ); + })} + + + ); } + +/* =================== Styled components =================== */ + +const Container = styled.div` + position: relative; + display: inline-block; + text-align: left; +`; + +const HeaderButton = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 8px; + background: var(--white); + padding: 8px 12px; + font-size: 14px; + font-weight: 600; + color: var(--foreground); + border: 1px solid rgba(0, 0, 0, 0.15); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + cursor: pointer; + + &:hover { + background: var(--light-gray); + } + + &:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; + } +`; + +const arrowRotate = css<{ $open: boolean }>` + transform: rotate(${(props) => (props.$open ? 180 : 0)}deg); +`; + +const ArrowIcon = styled.span<{ $open: boolean }>` + font-family: "Material Symbols Rounded"; + font-size: 20px; + line-height: 1; + height: 20px; + width: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + color: var(--black); + transition: transform 150ms ease; + ${arrowRotate} +`; + +const Menu = styled.div<{ $open: boolean }>` + position: absolute; + left: 0; + top: calc(100% + 8px); + z-index: 10; + width: 15rem; + display: inline-flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + background: var(--white); + border-radius: 8px; + border: 1px solid rgba(0, 0, 0, 0.05); + box-shadow: + 0 10px 15px rgba(0, 0, 0, 0.1), + 0 4px 6px rgba(0, 0, 0, 0.05); + outline: none; + opacity: ${(props) => (props.$open ? 1 : 0)}; + transform: scale(${(props) => (props.$open ? 1 : 0.98)}); + transform-origin: top left; + transition: + opacity 120ms ease, + transform 120ms ease; + pointer-events: ${(props) => (props.$open ? "auto" : "none")}; +`; + +const Frame = styled.div` + width: 100%; + position: relative; + border-radius: 4px; + border: 2px solid var(--dark-gray); + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + padding: 4px 2px; + text-align: left; + font-size: 10px; + color: var(--black); +`; + +const ItemButton = styled.button<{ $selected?: boolean }>` + align-self: stretch; + display: flex; + align-items: center; + padding: 4px 2px; + gap: 4px; + cursor: pointer; + background: ${(props) => + props.$selected ? "var(--light-orange)" : "transparent"}; + border: 0; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + outline: none; + + &:hover { + background: var(--light-gray); + } + &:focus-visible { + background: var(--light-gray); + } +`; + +const StarIcon = styled.span` + font-family: "Material Symbols Rounded"; + font-size: 16px; + line-height: 1; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + color: var(--black); +`; + +const LabelWrapper = styled.div` + flex: 1; + display: flex; + align-items: center; + justify-content: flex-start; +`; + +const LabelText = styled.div` + flex: 1 0 0; + position: relative; + overflow: hidden; + color: var(--black); + text-overflow: ellipsis; + text-align: left; + font-family: Inter; + font-size: 10px; + font-style: normal; + font-weight: 400; + line-height: 16px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 1; + line-clamp: 1; +`; diff --git a/app/components/H1.tsx b/app/components/H1.tsx index ef9c3ea..3bfce70 100644 --- a/app/components/H1.tsx +++ b/app/components/H1.tsx @@ -1,33 +1,21 @@ "use client"; import styled from "styled-components"; - -// TODO: fix compatability with color/design scheme -type TextColorType = - | "white" - | "black" - | "gray" - | "red" - | "green" - | "blue" - | "yellow" - | "purple" - | "orange" - | "pink" - | "brown" - | "gray" - | "black" - | "white"; +import { ColorType } from "../types/colors"; type H1Props = { text: string; - textcolor: TextColorType; + color?: ColorType; }; -// TODO: implement H1 component -export default function H1({}: H1Props) { - return ; +export default function H1({ text, color = "black" }: H1Props) { + return {text}; } -const StyledH1 = styled.h1` - /* TODO: Add styles for H1 */ +const StyledH1 = styled.h1<{ $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 3rem; + font-style: normal; + font-weight: 700; + line-height: 3.5rem; /* 116.667% */ `; diff --git a/app/components/H2.tsx b/app/components/H2.tsx index 75f99d3..696db41 100644 --- a/app/components/H2.tsx +++ b/app/components/H2.tsx @@ -1,16 +1,21 @@ "use client"; import styled from "styled-components"; +import { ColorType } from "../types/colors"; -// TODO: Add props for H2 type H2Props = { - propName: string; // replace string with actual prop type + text: string; + color?: ColorType; }; -// TODO: implement H2 component -export default function H2({}: H2Props) { - return ; +export default function H2({ text, color = "black" }: H2Props) { + return {text}; } -const StyledH2 = styled.h2` - /* TODO: Add styles for H2 */ +const StyledH2 = styled.h2<{ $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 2rem; + font-style: normal; + font-weight: 600; + line-height: 2.5rem; /* 125% */ `; diff --git a/app/components/H3.tsx b/app/components/H3.tsx index 4c6670c..9c8119e 100644 --- a/app/components/H3.tsx +++ b/app/components/H3.tsx @@ -1,16 +1,21 @@ "use client"; import styled from "styled-components"; +import { ColorType } from "../types/colors"; -// TODO: Add props for H3 type H3Props = { - propName: string; // replace string with actual prop type + text: string; + color?: ColorType; }; -// TODO: implement H3 component -export default function H3({}: H3Props) { - return ; +export default function H3({ text, color = "black" }: H3Props) { + return {text}; } -const StyledH3 = styled.h3` - /* TODO: Add styles for H3 */ +const StyledH3 = styled.h3<{ $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 1.5rem; + font-style: normal; + font-weight: 600; + line-height: 2rem; /* 133.333% */ `; diff --git a/app/components/Subtitle.tsx b/app/components/Subtitle.tsx index b9a5902..bc09d02 100644 --- a/app/components/Subtitle.tsx +++ b/app/components/Subtitle.tsx @@ -1,16 +1,31 @@ "use client"; import styled from "styled-components"; +import { ColorType } from "../types/colors"; -// TODO: Add props for Subtitle type SubtitleProps = { - variant: string; // replace string with the actual variant option(s), e.g. "bold" + text: string; + bold?: boolean; + color?: ColorType; }; -// TODO: implement Subtitle component -export default function Subtitle({}: SubtitleProps) { - return ; +export default function Subtitle({ + text, + bold = false, + color = "black", +}: SubtitleProps) { + return ( + + {text} + + ); } -const StyledSubtitle = styled.p` - /* TODO: Add styles for Subtitle */ +const StyledSubtitle = styled.p<{ bold: boolean; $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 0.625rem; + font-style: normal; + line-height: 1rem; /* 160% */ + + font-weight: ${({ bold }) => (bold ? "600" : "400")}; `; diff --git a/app/components/TextMedium.tsx b/app/components/TextMedium.tsx index dd9f20a..cc45c73 100644 --- a/app/components/TextMedium.tsx +++ b/app/components/TextMedium.tsx @@ -1,16 +1,21 @@ "use client"; import styled from "styled-components"; +import { ColorType } from "../types/colors"; -// TODO: Add props for TextMedium type TextMediumProps = { - propName: string; // replace with actual prop + text: string; + color?: ColorType; }; -// TODO: implement TextMedium component -export default function TextMedium({}: TextMediumProps) { - return ; +export default function TextMedium({ text, color = "black" }: TextMediumProps) { + return {text}; } -const StyledTextMedium = styled.p` - /* TODO: Add styles for TextMedium */ +const StyledTextMedium = styled.p<{ $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 1rem; + font-style: normal; + font-weight: 500; + line-height: 1.5rem; /* 150% */ `; diff --git a/app/components/TextRegular.tsx b/app/components/TextRegular.tsx index 7683071..7bc8577 100644 --- a/app/components/TextRegular.tsx +++ b/app/components/TextRegular.tsx @@ -1,16 +1,24 @@ "use client"; import styled from "styled-components"; +import { ColorType } from "../types/colors"; -// TODO: Add props for TextRegular type TextRegularProps = { - propName: string; // replace with actual prop + text: string; + color?: ColorType; }; -// TODO: implement TextRegular component -export default function TextRegular({}: TextRegularProps) { - return ; +export default function TextRegular({ + text, + color = "black", +}: TextRegularProps) { + return {text}; } -const StyledTextRegular = styled.p` - /* TODO: Add styles for TextRegular */ +const StyledTextRegular = styled.p<{ $color: ColorType }>` + color: var(--${({ $color }) => $color}); + font-family: Inter; + font-size: 0.875rem; + font-style: normal; + font-weight: 400; + line-height: 1.25rem; /* 142.857% */ `; diff --git a/app/layout.tsx b/app/layout.tsx index 81e2a18..cd0c935 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -25,6 +25,12 @@ export default function RootLayout({ }>) { return ( + + + {children} diff --git a/app/types/colors.ts b/app/types/colors.ts new file mode 100644 index 0000000..396420b --- /dev/null +++ b/app/types/colors.ts @@ -0,0 +1,13 @@ +export type ColorType = + | "black" + | "dark-gray" + | "gray" + | "light-gray" + | "white" + | "dark-orange" + | "orange" + | "light-orange" + | "positive" + | "dark-positive" + | "negative" + | "dark-negative";