diff --git a/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.test.tsx b/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.test.tsx deleted file mode 100644 index 19c16f95f5..0000000000 --- a/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React from "react" -import { render, screen } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import { SearchInput } from "./SearchInput" -import type { SearchInputProps } from "./SearchInput" -import invariant from "tiny-invariant" -import { ThemeProvider } from "ol-components" - -const getSearchInput = () => { - const element = screen.getByLabelText("Search for") - invariant(element instanceof HTMLInputElement) - return element -} - -const getSearchButton = (): HTMLButtonElement => { - const button = screen.getByLabelText("Search") - invariant(button instanceof HTMLButtonElement) - return button -} - -/** - * This actually returns an icon (inside a button) - */ -const getClearButton = (): HTMLButtonElement => { - const button = screen.getByLabelText("Clear search text") - invariant(button instanceof HTMLButtonElement) - return button -} - -const searchEvent = (value: string) => - expect.objectContaining({ target: { value } }) - -describe("SearchInput", () => { - const renderSearchInput = (props: Partial = {}) => { - const { value = "", ...otherProps } = props - const onSubmit = jest.fn() - const onChange = jest.fn((e) => e.persist()) - const onClear = jest.fn() - render( - , - { wrapper: ThemeProvider }, - ) - const user = userEvent.setup() - const spies = { onClear, onChange, onSubmit } - return { user, spies } - } - - it("Renders the given value in input", () => { - renderSearchInput({ value: "math" }) - expect(getSearchInput().value).toBe("math") - }) - - it("Calls onChange when text is typed", async () => { - const { user, spies } = renderSearchInput({ value: "math" }) - const input = getSearchInput() - await user.type(getSearchInput(), "s") - expect(spies.onChange).toHaveBeenCalledWith( - expect.objectContaining({ target: input }), - ) - }) - - it("Calls onSubmit when search is clicked", async () => { - const { user, spies } = renderSearchInput({ value: "chemistry" }) - await user.click(getSearchButton()) - expect(spies.onSubmit).toHaveBeenCalledWith(searchEvent("chemistry")) - }) - - it("Calls onClear clear is clicked", async () => { - const { user, spies } = renderSearchInput({ value: "biology" }) - await user.click(getClearButton()) - expect(spies.onClear).toHaveBeenCalled() - }) -}) diff --git a/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.tsx b/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.tsx deleted file mode 100644 index 27fcdce3a3..0000000000 --- a/frontends/mit-learn/src/page-components/SearchDisplay/SearchInput.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import React, { useCallback } from "react" - -import { - Input, - AdornmentButton, - FormGroup, - Button, - styled, - css, -} from "ol-components" -import type { InputProps } from "ol-components" -import { RiSearch2Line, RiCloseLine } from "@remixicon/react" - -export interface SearchSubmissionEvent { - target: { - value: string - } - /** - * Deprecated. course-search-utils calls unnecessarily. - */ - preventDefault: () => void -} - -const StyledInput = styled(Input)` - border-radius: 0; - border-top-left-radius: 8px; - border-bottom-left-radius: 8px; - width: 556px; - border-right: none; - height: 48px; - - &.Mui-focused { - border-color: ${({ theme }) => theme.custom.colors.darkGray2}; - color: ${({ theme }) => theme.custom.colors.darkGray2}; - } - - ${({ theme }) => theme.breakpoints.down("md")} { - height: 37px; - border-top-left-radius: 4px; - border-bottom-left-radius: 4px; - width: 80%; - } -` - -const StyledButton = styled(Button)` - border-top-left-radius: 0; - border-bottom-left-radius: 0; - - ${({ theme }) => theme.breakpoints.up("md")} { - ${({ theme }) => css({ ...theme.typography.body2 })}; - min-width: 64px; - height: 48px; - padding: 8px 16px; - border-top-right-radius: 8px; - border-bottom-right-radius: 8px; - - svg { - height: 1.5em; - width: 1.5em; - } - } - - ${({ theme }) => theme.breakpoints.down("md")} { - ${({ theme }) => css({ ...theme.typography.body4 })}; - height: 37px; - width: 40px; - min-width: 40px; - padding: 0; - border-top-right-radius: 4px; - border-bottom-right-radius: 4px; - - svg { - height: 1em; - width: 1em; - font-size: 18px; - } - } -` - -const StyledFormGroup = styled(FormGroup)` - width: 100%; -` - -type SearchSubmitHandler = (event: SearchSubmissionEvent) => void - -interface SearchInputProps { - className?: string - classNameInput?: string - - classNameClear?: string - classNameSearch?: string - value: string - placeholder?: string - autoFocus?: boolean - onChange: React.ChangeEventHandler - onClear: React.MouseEventHandler - onSubmit: SearchSubmitHandler - size?: InputProps["size"] - fullWidth?: boolean -} - -const muiInputProps = { "aria-label": "Search for" } - -const SearchInput: React.FC = (props) => { - const { onSubmit, value } = props - const handleSubmit = useCallback(() => { - const event = { - target: { value }, - preventDefault: () => null, - } - onSubmit(event) - }, [onSubmit, value]) - const onInputKeyDown: React.KeyboardEventHandler = - useCallback( - (e) => { - if (e.key !== "Enter") return - handleSubmit() - }, - [handleSubmit], - ) - - return ( - - - - - ) - } - /> - - - - - ) -} - -export { SearchInput } -export type { SearchInputProps } diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx index 30fec19a8c..3e77db3e06 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.tsx @@ -14,36 +14,25 @@ import type { } from "@mitodl/course-search-utils" import { useSearchParams } from "@mitodl/course-search-utils/react-router" import SearchDisplay from "@/page-components/SearchDisplay/SearchDisplay" -import { SearchInput } from "@/page-components/SearchDisplay/SearchInput" +import { Container, SearchInput, styled, VisuallyHidden } from "ol-components" import { getFacetManifest } from "@/pages/SearchPage/SearchPage" import _ from "lodash" -import { styled, VisuallyHidden } from "ol-components" - -const SearchInputContainer = styled.div` - padding-bottom: 40px; - - ${({ theme }) => theme.breakpoints.down("md")} { - padding-bottom: 35px; - } -` - -const StyledSearchInput = styled(SearchInput)` - justify-content: center; - ${({ theme }) => theme.breakpoints.up("md")} { - .input-field { - height: 40px; - width: 450px; - } - - .button-field { - height: 40px; - padding: 12px 16px 12px 12px; - width: 20px; - } - } -` + +const SearchInputContainer = styled(Container)(({ theme }) => ({ + width: "100%", + display: "flex", + justifyContent: "center", + paddingBottom: "40px", + [theme.breakpoints.down("md")]: { + paddingBottom: "35px", + }, +})) + +const StyledSearchInput = styled(SearchInput)({ + width: "624px", +}) const FACETS_BY_CHANNEL_TYPE: Record = { [ChannelTypeEnum.Topic]: [ @@ -185,6 +174,7 @@ const ChannelSearch: React.FC = ({ setCurrentText(e.target.value)} onSubmit={(e) => { setCurrentTextAndQuery(e.target.value) @@ -192,9 +182,6 @@ const ChannelSearch: React.FC = ({ onClear={() => { setCurrentTextAndQuery("") }} - classNameInput="input-field" - classNameSearch="button-field" - placeholder="Search for courses, programs, and learning materials..." /> diff --git a/frontends/mit-learn/src/pages/HomePage/HeroSearch.tsx b/frontends/mit-learn/src/pages/HomePage/HeroSearch.tsx index 83befc5eda..efe922953b 100644 --- a/frontends/mit-learn/src/pages/HomePage/HeroSearch.tsx +++ b/frontends/mit-learn/src/pages/HomePage/HeroSearch.tsx @@ -1,8 +1,14 @@ import React, { useState, useCallback } from "react" import { useNavigate } from "react-router" -import { Typography, styled, ChipLink, Link } from "ol-components" +import { + Typography, + styled, + ChipLink, + Link, + SearchInput, + SearchInputProps, +} from "ol-components" import type { ChipLinkProps } from "ol-components" -import { SearchInput, SearchInputProps } from "./SearchInput" import { ABOUT, SEARCH_CERTIFICATE, @@ -231,7 +237,6 @@ const HeroSearch: React.FC = () => { theme.breakpoints.up("md")} { - width: 680px; - min-width: 680px; - } -` +const SearchFieldContainer = styled(Container)({ + display: "flex", + justifyContent: "center", +}) + +const SearchField = styled(SearchInput)(({ theme }) => ({ + [theme.breakpoints.down("sm")]: { + width: "100%", + }, + [theme.breakpoints.up("sm")]: { + width: "570px", + }, +})) const LEARNING_MATERIAL = "learning_material" @@ -216,24 +227,19 @@ const SearchPage: React.FC = () => {

Search

- - - - - setCurrentText(e.target.value)} - onSubmit={(e) => { - onSearchTermSubmit(e.target.value) - }} - onClear={() => { - onSearchTermSubmit("") - }} - placeholder="What do you want to learn?" - /> - - - + + setCurrentText(e.target.value)} + onSubmit={(e) => { + onSearchTermSubmit(e.target.value) + }} + onClear={() => { + onSearchTermSubmit("") + }} + /> +
{ return ( - + + + ) @@ -104,11 +111,11 @@ export const Adornments: Story = { }, ] return ( - + {Object.values(adornments).flatMap((props, i) => SIZES.map((size) => { return ( - + ) diff --git a/frontends/ol-components/src/components/Input/Input.tsx b/frontends/ol-components/src/components/Input/Input.tsx index cf03ade4f7..86d7c2f844 100644 --- a/frontends/ol-components/src/components/Input/Input.tsx +++ b/frontends/ol-components/src/components/Input/Input.tsx @@ -1,30 +1,106 @@ import React from "react" import styled from "@emotion/styled" -import { pxToRem } from "../ThemeProvider/typography" import InputBase from "@mui/material/InputBase" import type { InputBaseProps } from "@mui/material/InputBase" import type { Theme } from "@mui/material/styles" +type Size = NonNullable + const defaultProps = { size: "medium", multiline: false, +} as const + +const responsiveSize: Record = { + small: "small", + medium: "small", + large: "medium", + hero: "large", } -const buttonPadding = { - medium: 4, - hero: 6, - heroMobile: 4, +type SizeStyleProps = { + size: Size + theme: Theme + multiline?: boolean } +const sizeStyles = ({ size, theme, multiline }: SizeStyleProps) => [ + (size === "small" || size === "medium") && { + ...theme.typography.body2, + }, + (size === "large" || size === "hero") && { + ".remixicon": { + width: "24px", + height: "24px", + }, + ...theme.typography.body1, + }, + size === "medium" && { + paddingLeft: "12px", + paddingRight: "12px", + }, + size === "small" && + !multiline && { + height: "32px", + }, + size === "medium" && + !multiline && { + height: "40px", + }, + size === "large" && + !multiline && { + height: "48px", + }, + size === "hero" && + !multiline && { + height: "72px", + }, + size === "small" && { + padding: "0 8px", + ".Mit-AdornmentButton": { + width: "32px", + ".remixicon": { + width: "16px", + height: "16px", + }, + }, + }, + size === "medium" && { + padding: "0 12px", + ".Mit-AdornmentButton": { + width: "40px", + ".remixicon": { + width: "20px", + height: "20px", + }, + }, + }, + size === "large" && { + padding: "0 16px", + ".Mit-AdornmentButton": { + width: "48px", + }, + }, + size === "hero" && { + padding: "0 24px", + ".Mit-AdornmentButton": { + width: "72px", + }, + }, +] /** * Base styles for Input and Select components. Includes border, color, hover effects. */ const baseInputStyles = (theme: Theme) => ({ backgroundColor: "white", - color: theme.custom.colors.silverGrayDark, + color: theme.custom.colors.darkGray2, borderColor: theme.custom.colors.silverGrayLight, borderWidth: "1px", borderStyle: "solid", + borderRadius: "4px", + ".MuiInputBase-input": { + padding: "0", + }, "&.Mui-disabled": { backgroundColor: theme.custom.colors.lightGray1, }, @@ -59,69 +135,47 @@ const baseInputStyles = (theme: Theme) => ({ paddingTop: "6px", paddingBottom: "7px", }, + "&.MuiInputBase-adornedStart": { + paddingLeft: "0", + input: { + paddingLeft: "8px", + }, + }, + "&.MuiInputBase-adornedEnd": { + paddingRight: "0", + input: { + paddingRight: "8px", + }, + }, }) /** * A styled input that supports start and end adornments. In most cases, the * higher-level TextField component should be used instead of this component. */ -const Input = styled(InputBase)(({ +type CustomInputProps = { responsive?: true } +const noForward = Object.keys({ + responsive: true, +} satisfies { [key in keyof CustomInputProps]: boolean }) + +const Input = styled(InputBase, { + shouldForwardProp: (prop) => !noForward.includes(prop), +})(({ theme, size = defaultProps.size, multiline, + responsive, }) => { return [ baseInputStyles(theme), - size === "medium" && { - "& .MuiInputBase-input": { - ...theme.typography.body2, - }, - paddingLeft: "12px", - paddingRight: "12px", - borderRadius: "4px", - "&.MuiInputBase-adornedStart": { - paddingLeft: `${12 - buttonPadding.medium}px`, - }, - "&.MuiInputBase-adornedEnd": { - paddingRight: `${12 - buttonPadding.medium}px`, - }, - }, - size === "medium" && - !multiline && { - height: "40px", - }, - size === "hero" && { - "& .MuiInputBase-input": { - ...theme.typography.body1, - }, - paddingLeft: "16px", - paddingRight: "16px", - borderRadius: "8px", - "&.MuiInputBase-adornedStart": { - paddingLeft: `${16 - buttonPadding.hero}px`, - }, - "&.MuiInputBase-adornedEnd": { - paddingRight: `${16 - buttonPadding.hero}px`, - }, - [theme.breakpoints.down("sm")]: { - "& .MuiInputBase-input": { - ...theme.typography.body3, - }, - "&.MuiInputBase-adornedStart": { - paddingLeft: `${12 - buttonPadding.heroMobile}px`, - }, - "&.MuiInputBase-adornedEnd": { - paddingRight: `${12 - buttonPadding.heroMobile}px`, - }, - }, + ...sizeStyles({ size, theme, multiline }), + responsive && { + [theme.breakpoints.down("sm")]: sizeStyles({ + size: responsiveSize[size], + theme, + multiline, + }), }, - size === "hero" && - !multiline && { - height: "56px", - [theme.breakpoints.down("sm")]: { - height: "37px", - }, - }, ] }) @@ -130,6 +184,7 @@ const AdornmentButtonStyled = styled("button")(({ theme }) => ({ ...theme.typography.button, // display display: "flex", + flexShrink: 0, justifyContent: "center", alignItems: "center", // background and border @@ -144,40 +199,7 @@ const AdornmentButtonStyled = styled("button")(({ theme }) => ({ ":hover": { background: "rgba(0, 0, 0, 0.06)", }, - ".MuiInputBase-root &": { - // Extra padding to make button easier to click - width: pxToRem(20 + 2 * buttonPadding.medium), - height: pxToRem(20 + 2 * buttonPadding.medium), - ".MuiSvgIcon-root": { - fontSize: pxToRem(20), - }, - }, - ".MuiInputBase-sizeHero &": { - // Extra padding to make button easier to click - width: pxToRem(24 + 2 * buttonPadding.hero), - height: pxToRem(24 + 2 * buttonPadding.hero), - ".MuiSvgIcon-root": { - fontSize: pxToRem(24), - }, - [theme.breakpoints.down("sm")]: { - width: pxToRem(16 + 2 * buttonPadding.heroMobile), - height: pxToRem(16 + 2 * buttonPadding.heroMobile), - ".MuiSvgIcon-root": { - fontSize: pxToRem(16), - }, - }, - }, - - color: theme.custom.colors.silverGray, - ".MuiInputBase-root:hover &": { - color: "inherit", - }, - ".MuiInputBase-root.Mui-focused &": { - color: "inherit", - }, - ".MuiInputBase-root.Mui-disabled &": { - color: "inherit", - }, + height: "100%", })) const noFocus: React.MouseEventHandler = (e) => e.preventDefault() @@ -189,9 +211,9 @@ type AdornmentButtonProps = React.ComponentProps * styling concerns. * * NOTES: - * - It is generally expected that the content of the AdornmentButton is an - * Mui Icon component. https://mui.com/material-ui/material-icons/ - * - By defualt, the AdornmentButton calls `preventDefault` on `mouseDown` + * - It is generally expected that the content of the AdornmentButton is a + * Remix Icon component. https://remixicon.com/ + * - By default, the AdornmentButton calls `preventDefault` on `mouseDown` * events. This prevents the button from stealing focus from the input on * click. The button is still focusable via keyboard events. You can override * this behavior by passing your own `onMouseDown` handler. @@ -209,7 +231,7 @@ const AdornmentButton: React.FC = (props) => { ) } -type InputProps = Omit +type InputProps = Omit & CustomInputProps export { AdornmentButton, Input, baseInputStyles } export type { InputProps, AdornmentButtonProps } diff --git a/frontends/mit-learn/src/pages/HomePage/SearchInput.test.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx similarity index 96% rename from frontends/mit-learn/src/pages/HomePage/SearchInput.test.tsx rename to frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx index 7d8b757ab0..bac9a47464 100644 --- a/frontends/mit-learn/src/pages/HomePage/SearchInput.test.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.test.tsx @@ -1,8 +1,7 @@ import React from "react" import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" -import { SearchInput } from "./SearchInput" -import type { SearchInputProps } from "./SearchInput" +import { SearchInput, type SearchInputProps } from "./SearchInput" import invariant from "tiny-invariant" import { ThemeProvider } from "ol-components" const getSearchInput = () => { diff --git a/frontends/mit-learn/src/pages/HomePage/SearchInput.tsx b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx similarity index 72% rename from frontends/mit-learn/src/pages/HomePage/SearchInput.tsx rename to frontends/ol-components/src/components/SearchInput/SearchInput.tsx index 07ba11403c..58c6c0935a 100644 --- a/frontends/mit-learn/src/pages/HomePage/SearchInput.tsx +++ b/frontends/ol-components/src/components/SearchInput/SearchInput.tsx @@ -1,44 +1,25 @@ import React, { useCallback } from "react" import { RiSearch2Line, RiCloseLine } from "@remixicon/react" -import { Input, AdornmentButton, styled, pxToRem } from "ol-components" -import type { InputProps } from "ol-components" +import { Input, AdornmentButton } from "../Input/Input" +import type { InputProps } from "../Input/Input" +import styled from "@emotion/styled" const StyledInput = styled(Input)(({ theme }) => ({ - height: "72px", boxShadow: "0px 8px 20px 0px rgba(120, 147, 172, 0.10)", - "&.MuiInputBase-adornedEnd": { - paddingRight: "0 !important", - }, [theme.breakpoints.down("sm")]: { - height: "56px", gap: "8px", }, -})) - -const StyledAdornmentButton = styled(AdornmentButton)(({ theme }) => ({ - ".MuiInputBase-sizeHero &": { - width: "72px", - height: "100%", - flexShrink: 0, - ".MuiSvgIcon-root": { - fontSize: pxToRem(24), - }, - [theme.breakpoints.down("sm")]: { - width: "56px", - height: "100%", - ".MuiSvgIcon-root": { - fontSize: pxToRem(16), - }, + [theme.breakpoints.up("sm")]: { + "&.MuiInputBase-sizeHero": { + borderRadius: "8px !important", }, }, })) -const StyledClearButton = styled(StyledAdornmentButton)({ - ".MuiInputBase-sizeHero &": { - width: "32px", - ["&:hover"]: { - backgroundColor: "transparent", - }, +const StyledClearButton = styled(AdornmentButton)({ + width: "32px !important", + ["&:hover"]: { + backgroundColor: "transparent", }, }) @@ -97,7 +78,10 @@ const SearchInput: React.FC = (props) => { // eslint-disable-next-line jsx-a11y/no-autofocus autoFocus={props.autoFocus} className={props.className} - placeholder={props.placeholder} + placeholder={ + props.placeholder ?? + "Search for courses, programs, and learning materials..." + } value={props.value} onChange={props.onChange} onKeyDown={onInputKeyDown} @@ -112,15 +96,16 @@ const SearchInput: React.FC = (props) => { )} - - + } + responsive /> ) } diff --git a/frontends/ol-components/src/components/SelectField/SelectField.tsx b/frontends/ol-components/src/components/SelectField/SelectField.tsx index 2c74906538..055d9167a7 100644 --- a/frontends/ol-components/src/components/SelectField/SelectField.tsx +++ b/frontends/ol-components/src/components/SelectField/SelectField.tsx @@ -109,9 +109,6 @@ function Select({ size, ...props }: SelectProps) { } diff --git a/frontends/ol-components/src/index.ts b/frontends/ol-components/src/index.ts index 7c04f30eec..9c988a0768 100644 --- a/frontends/ol-components/src/index.ts +++ b/frontends/ol-components/src/index.ts @@ -199,6 +199,8 @@ export * from "./constants/imgConfigs" export { Input, AdornmentButton } from "./components/Input/Input" export type { InputProps, AdornmentButtonProps } from "./components/Input/Input" +export { SearchInput } from "./components/SearchInput/SearchInput" +export type { SearchInputProps } from "./components/SearchInput/SearchInput" export { TextField } from "./components/TextField/TextField" export { SimpleSelect, diff --git a/frontends/ol-components/src/types/theme.d.ts b/frontends/ol-components/src/types/theme.d.ts index ed1f53457f..e971ffe9af 100644 --- a/frontends/ol-components/src/types/theme.d.ts +++ b/frontends/ol-components/src/types/theme.d.ts @@ -71,7 +71,7 @@ declare module "@mui/material/Button" { declare module "@mui/material/InputBase" { interface InputBasePropsSizeOverrides { hero: true - small: false + large: true } }