diff --git a/build.washingtonpost.com/docs/components/input-search.mdx b/build.washingtonpost.com/docs/components/input-search.mdx index 6aa037e19..a443e95dd 100644 --- a/build.washingtonpost.com/docs/components/input-search.mdx +++ b/build.washingtonpost.com/docs/components/input-search.mdx @@ -1169,8 +1169,8 @@ export default function Example() { {results.length > 0 ? ( <> - - {results.slice(0, 5).map((result) => ( + + {results.map((result) => ( ))} @@ -1185,7 +1185,11 @@ export default function Example() { }} > - + - Recommended + Recommended? @@ -1247,7 +1250,6 @@ export default function Example() { css={{ display: "block", marginBlockEnd: "$075", - marginInline: "$050", }} /> + let results1 = searchBooks(term1); + if (results1 && ebooksChecked) { + results1 = results1.filter((book) => book.format === "ebook" ? true : false ); } + const [term2, setTerm2] = useState(""); + const results2 = searchBooks(term2); + return ( - - - { - setTerm(event.target.value); - }} - label="Search Library" - /> - {results && ( - - {results.length > 0 ? ( - <> - - setEbooksChecked(!ebooksChecked)} + + + + { + setTerm1(event.target.value); + }} + label="Search Library" + /> + {results1 && ( + + {results1.length > 0 ? ( + <> + - - - - - Show only ebooks - - - - - {results.map((book) => - book.format === "paper" ? ( - - ) : ( - - setEbooksChecked(!ebooksChecked)} + > + + + + + Show only ebooks + + + + + {results1.map((book) => + book.format === "paper" ? ( + + ) : ( + - - {" "} - {`${book.title} by ${book.author}`} - - ) - )} - - - ) : ( - - )} - - )} - + + + {" "} + {`${book.title} by ${book.author}`} + + ) + )} + + + ) : ( + + )} + + )} + + + + + { + setTerm2(event.target.value); + }} + label="Search Library" + /> + {results2 && ( + + + + All Results + Ebooks + Paperback + + + + {results2.map((book) => + book.format === "paper" ? ( + + ) : ( + + + + {" "} + {`${book.title} by ${book.author}`} + + ) + )} + + + + + {results2.map((book) => + book.format === "ebook" ? ( + + + + {" "} + {`${book.title} by ${book.author}`} + + ) : null + )} + + + + + {results2.map((book) => + book.format === "paper" ? ( + + ) : null + )} + + + + + )} + + ); } @@ -1721,8 +1819,111 @@ export default function Example() { There might be instances where a dedicated search view might be more appropriate. Utilizing the pattern of our drawer can be an effective way to do this. -```jsx withPreview isGuide="error" +```jsx withPreview demoHeight="430" isGuide="information" +export default function Example() { + const cities = [ + { name: "Atlanta", state: "Georgia" }, + { name: "Austin", state: "Texas" }, + { name: "Boston", state: "Massachusetts" }, + { name: "Charlotte", state: "North Carolina" }, + { name: "Chicago", state: "Illinois" }, + { name: "Dallas", state: "Texas" }, + { name: "Denver", state: "Colorado" }, + { name: "Detroit", state: "Michigan" }, + { name: "Houston", state: "Texas" }, + { name: "Las Vegas", state: "Nevada" }, + { name: "Miami", state: "Florida" }, + { name: "Nashville", state: "Tennessee" }, + { name: "New Orleans", state: "Louisiana" }, + { name: "New York", state: "New York" }, + { name: "Philadelphia", state: "Pennsylvania" }, + { name: "Phoenix", state: "Arizona" }, + { name: "Portland", state: "Oregon" }, + { name: "San Diego", state: "California" }, + { name: "San Francisco", state: "California" }, + { name: "Seattle", state: "Washington" }, + { name: "Washington", state: "D.C." }, + ]; + function searchCities(term) { + if (term === "") { + return; + } + return cities.filter( + (city) => + city.name.toLowerCase().includes(term.toLowerCase()) || + city.state.toLowerCase().includes(term.toLowerCase()) + ); + } + + const [term, setTerm] = useState(""); + const results = searchCities(term); + + const [open, setOpen] = useState(false); + + return ( + setOpen(open)} + > + + + + + {`Find your city`} + + + + + + { + setTerm(event.target.value); + }} + autocomplete={false} + /> + {results && ( + + {results.length > 0 ? ( + + {results.map((city) => ( + + ))} + + ) : ( + + )} + + )} + + + + ); +} ``` --- diff --git a/build.washingtonpost.com/pages/resources/working-examples.tsx b/build.washingtonpost.com/pages/resources/working-examples.tsx index 8a829b9c5..8ffca953b 100644 --- a/build.washingtonpost.com/pages/resources/working-examples.tsx +++ b/build.washingtonpost.com/pages/resources/working-examples.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useState } from "react"; import { useForm, Controller } from "react-hook-form"; import { isPossiblePhoneNumber } from "react-phone-number-input"; +import { matchSorter } from "match-sorter"; import { Box, Button, @@ -17,6 +18,8 @@ import { styled, } from "@washingtonpost/wpds-ui-kit"; +import { cities } from "@washingtonpost/wpds-input-search/src/cities"; + const STATES = [ "Alabama", "Alaska", @@ -128,6 +131,7 @@ const Form = () => { handleSubmit, register, reset, + clearErrors, watch, } = useForm({}); @@ -137,6 +141,22 @@ const Form = () => { const [checked, setChecked] = useState(false); + const useCityMatch = (term: string) => { + return React.useMemo( + () => + term.trim() === "" + ? null + : matchSorter(cities, term, { + keys: [(item) => `${item.city}, ${item.state}`], + }), + [term] + ); + }; + + const [term, setTerm] = React.useState(""); + + const results: { city: string; state: string }[] | null = useCityMatch(term); + return ( <> Form Example @@ -147,21 +167,6 @@ const Form = () => {

- - - - - - - - - - - - - - - { - + { + console.log("select?", value); + if (value) { + clearErrors("city"); + } + }} + > + { + setTerm(event.target.value); + }, + })} + /> + {results && ( + + {results.length > 0 ? ( + + {results.slice(0, 20).map((result) => ( + + ))} + + ) : ( + + )} + + )} + diff --git a/package-lock.json b/package-lock.json index 0925bd864..6f6f315ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50345,11 +50345,12 @@ "license": "MIT", "dependencies": { "@reach/combobox": "^0.18.0", + "@reach/popover": "^0.18.0", "@washingtonpost/wpds-assets": "^1.18.0", - "@washingtonpost/wpds-icon": "1.8.2", - "@washingtonpost/wpds-input-label": "1.8.2", - "@washingtonpost/wpds-input-text": "1.8.2", - "@washingtonpost/wpds-theme": "1.8.2", + "@washingtonpost/wpds-icon": "*", + "@washingtonpost/wpds-input-label": "*", + "@washingtonpost/wpds-input-text": "*", + "@washingtonpost/wpds-theme": "*", "match-sorter": "6.3.1" }, "devDependencies": { @@ -64531,11 +64532,12 @@ "version": "file:ui/input-search", "requires": { "@reach/combobox": "^0.18.0", + "@reach/popover": "^0.18.0", "@washingtonpost/wpds-assets": "^1.18.0", - "@washingtonpost/wpds-icon": "1.8.2", - "@washingtonpost/wpds-input-label": "1.8.2", - "@washingtonpost/wpds-input-text": "1.8.2", - "@washingtonpost/wpds-theme": "1.8.2", + "@washingtonpost/wpds-icon": "*", + "@washingtonpost/wpds-input-label": "*", + "@washingtonpost/wpds-input-text": "*", + "@washingtonpost/wpds-theme": "*", "match-sorter": "6.3.1", "tsup": "5.11.13", "typescript": "4.5.5" diff --git a/ui/input-search/package.json b/ui/input-search/package.json index e0db61af8..752db38cf 100644 --- a/ui/input-search/package.json +++ b/ui/input-search/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@reach/combobox": "^0.18.0", + "@reach/popover": "^0.18.0", "@washingtonpost/wpds-assets": "^1.18.0", "@washingtonpost/wpds-icon": "1.8.5", "@washingtonpost/wpds-input-label": "1.8.5", diff --git a/ui/input-search/src/InputSearchEmptyState.test.tsx b/ui/input-search/src/InputSearchEmptyState.test.tsx new file mode 100644 index 000000000..9c9fe7fae --- /dev/null +++ b/ui/input-search/src/InputSearchEmptyState.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchEmptyState } from "./InputSearchEmptyState"; + +describe("InputSearchEmptyState", () => { + test("renders visibly into the document", () => { + render(); + expect(screen.getByText("No results found")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchInput.test.tsx b/ui/input-search/src/InputSearchInput.test.tsx new file mode 100644 index 000000000..aa5682911 --- /dev/null +++ b/ui/input-search/src/InputSearchInput.test.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchInput } from "./InputSearchInput"; +import { InputSearchRoot } from "./InputSearchRoot"; +import { InputSearchPopover } from "./InputSearchPopover"; + +describe("InputSearchInput", () => { + const customRender = (ui, contextProps) => { + return render( + + {ui} + + + ); + }; + test("renders visibly into the document", () => { + customRender(, {}); + expect(screen.getByLabelText("Test")).toBeInTheDocument(); + }); + test("uses contexts portal prop", () => { + customRender(, { + portal: false, + }); + expect(screen.getByTestId("border-style-override")).toHaveStyle( + "--wpds-colors-signal: var(--wpds-colors-subtle)" + ); + }); +}); diff --git a/ui/input-search/src/InputSearchInput.tsx b/ui/input-search/src/InputSearchInput.tsx index 886a41736..098180166 100644 --- a/ui/input-search/src/InputSearchInput.tsx +++ b/ui/input-search/src/InputSearchInput.tsx @@ -27,6 +27,7 @@ export const InputSearchInput = React.forwardRef< InputSearchInputProps >(({ label = "Search", name, id, ...rest }: InputSearchInputProps, ref) => { const { disabled, usePortal } = React.useContext(InputSearchContext); + console.log("usePortal", usePortal); return (
{ + const customRender = (ui, contextProps) => { + return render( + + + {ui} + + + ); + }; + test("renders visibly into the document", () => { + customRender(, { + value: "test", + }); + expect(screen.getByText("test")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchList.test.tsx b/ui/input-search/src/InputSearchList.test.tsx new file mode 100644 index 000000000..a6bae46ce --- /dev/null +++ b/ui/input-search/src/InputSearchList.test.tsx @@ -0,0 +1,15 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchList } from "./InputSearchList"; +import { InputSearchRoot } from "./InputSearchRoot"; + +describe("InputSearchList", () => { + const customRender = (ui, contextProps) => { + return render({ui}); + }; + + test("renders visibly into the document", () => { + customRender(, {}); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchList.tsx b/ui/input-search/src/InputSearchList.tsx index e7ac2e79f..81b2d1710 100644 --- a/ui/input-search/src/InputSearchList.tsx +++ b/ui/input-search/src/InputSearchList.tsx @@ -4,6 +4,8 @@ import { styled } from "@washingtonpost/wpds-theme"; const StyledList = styled(ComboboxList, { marginBlock: 0, + maxHeight: "300px", + overflowY: "auto", paddingInlineStart: 0, position: "relative", listStyleType: "none", @@ -32,19 +34,16 @@ export const InputSearchList = ({ ) as HTMLElement; if (!selectedEl) return; - const parentEl = listEl.parentElement; - if (!parentEl) return; - - const listTop = parentEl.scrollTop; - const listBottom = listTop + parentEl.clientHeight; + const listTop = listEl.scrollTop; + const listBottom = listTop + listEl.clientHeight; const selectedTop = selectedEl.offsetTop; const selectedBottom = selectedTop + selectedEl.clientHeight; if (selectedTop < listTop) { - parentEl.scrollTop -= listTop - selectedTop; + listEl.scrollTop -= listTop - selectedTop; } else if (selectedBottom > listBottom) { - parentEl.scrollTop += selectedBottom - listBottom; + listEl.scrollTop += selectedBottom - listBottom; } } }, [navigationValue, state]); diff --git a/ui/input-search/src/InputSearchListHeading.test.tsx b/ui/input-search/src/InputSearchListHeading.test.tsx new file mode 100644 index 000000000..f98ad1125 --- /dev/null +++ b/ui/input-search/src/InputSearchListHeading.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchListHeading } from "./InputSearchListHeading"; + +describe("InputSearchListHeading", () => { + test("renders visibly into the document", () => { + render(test); + expect(screen.getByText("test")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchListItem.test.tsx b/ui/input-search/src/InputSearchListItem.test.tsx new file mode 100644 index 000000000..c2ff268a9 --- /dev/null +++ b/ui/input-search/src/InputSearchListItem.test.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchListItem } from "./InputSearchListItem"; +import { InputSearchRoot } from "./InputSearchRoot"; +import { InputSearchList } from "./InputSearchList"; + +describe("InputSearchItemText", () => { + const customRender = (ui, contextProps) => { + return render( + + {ui} + + ); + }; + test("renders visibly into the document", () => { + customRender(, {}); + expect(screen.getByRole("option")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchListItem.tsx b/ui/input-search/src/InputSearchListItem.tsx index a632c0269..a7bea66b5 100644 --- a/ui/input-search/src/InputSearchListItem.tsx +++ b/ui/input-search/src/InputSearchListItem.tsx @@ -4,6 +4,7 @@ import { styled, theme } from "@washingtonpost/wpds-theme"; const StyledListItem = styled(ComboboxOption, { color: theme.colors.primary, + fontFamily: theme.fonts.meta, fontSize: theme.fontSizes["100"], fontWeight: theme.fontWeights.light, paddingBlock: "$050", @@ -19,8 +20,9 @@ const StyledListItem = styled(ComboboxOption, { }, }); -export type InputSearchListItemProps = React.ComponentPropsWithRef< - typeof StyledListItem +export type InputSearchListItemProps = Omit< + React.ComponentPropsWithRef, + "index" >; export const InputSearchListItem = ({ diff --git a/ui/input-search/src/InputSearchLoadingState.test.tsx b/ui/input-search/src/InputSearchLoadingState.test.tsx new file mode 100644 index 000000000..fbbdd9491 --- /dev/null +++ b/ui/input-search/src/InputSearchLoadingState.test.tsx @@ -0,0 +1,10 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchLoadingState } from "./InputSearchLoadingState"; + +describe("InputSearchLoadingState", () => { + test("renders visibly into the document", () => { + render(); + expect(screen.getByText("Loading")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchOtherState.test.tsx b/ui/input-search/src/InputSearchOtherState.test.tsx new file mode 100644 index 000000000..9f4e808d8 --- /dev/null +++ b/ui/input-search/src/InputSearchOtherState.test.tsx @@ -0,0 +1,20 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchOtherState } from "./InputSearchOtherState"; +import { Icon } from "@washingtonpost/wpds-icon"; +import { Settings } from "@washingtonpost/wpds-assets"; + +describe("InputSearchOtherState", () => { + test("renders visibly into the document", () => { + render( + + + + } + /> + ); + expect(screen.getByText("test")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchPopover.test.tsx b/ui/input-search/src/InputSearchPopover.test.tsx new file mode 100644 index 000000000..70647b335 --- /dev/null +++ b/ui/input-search/src/InputSearchPopover.test.tsx @@ -0,0 +1,16 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchPopover } from "./InputSearchPopover"; +import { InputSearchRoot } from "./InputSearchRoot"; + +describe("InputSearchPopover", () => { + const customRender = (ui, contextProps) => { + return render({ui}); + }; + test("renders invisibly into the document", () => { + customRender(test, {}); + const popover = screen.getByText("test"); + expect(popover).toBeInTheDocument(); + expect(popover).not.toBeVisible(); + }); +}); diff --git a/ui/input-search/src/InputSearchPopover.tsx b/ui/input-search/src/InputSearchPopover.tsx index 5cc460bbc..08c9fb427 100644 --- a/ui/input-search/src/InputSearchPopover.tsx +++ b/ui/input-search/src/InputSearchPopover.tsx @@ -1,28 +1,30 @@ import * as React from "react"; import { ComboboxPopover } from "@reach/combobox"; +import { positionMatchWidth } from "@reach/popover"; import { styled, theme } from "@washingtonpost/wpds-theme"; import { InputSearchContext } from "./InputSearchRoot"; -const StyledPopover = styled(ComboboxPopover, { +const Background = styled("div", { backgroundColor: theme.colors.secondary, - maxHeight: "300px", - overflowY: "auto", variants: { floating: { true: { border: `1px solid ${theme.colors.subtle}`, boxShadow: theme.shadows["200"], - marginTop: theme.space["025"], + marginBlock: theme.space["025"], }, }, }, }); -export type InputSearchPopoverProps = React.ComponentPropsWithRef< - typeof StyledPopover ->; +export type InputSearchPopoverProps = Omit< + React.ComponentPropsWithRef, + "unstable_observableRefs" | "unstable_skipInitialPortalRender" +> & + React.ComponentProps; export const InputSearchPopover = ({ + css, children, portal = true, ...rest @@ -34,18 +36,17 @@ export const InputSearchPopover = ({ }, []); return ( - ({ - top: `${rootRect?.bottom}px`, - left: `${rootRect?.left}px`, - width: `${rootRect?.width}px`, - })} + position={(targetRect, popoverRect) => { + return positionMatchWidth(rootRect, popoverRect); + }} {...rest} > - {children} - + + {children} + + ); }; diff --git a/ui/input-search/src/InputSearchRoot.test.tsx b/ui/input-search/src/InputSearchRoot.test.tsx new file mode 100644 index 000000000..087c9e78a --- /dev/null +++ b/ui/input-search/src/InputSearchRoot.test.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { render, screen } from "@testing-library/react"; +import { InputSearchRoot, InputSearchContext } from "./InputSearchRoot"; + +describe("InputSearchRoot", () => { + const TestComponent = () => { + const { rootRect } = React.useContext(InputSearchContext); + return
{rootRect && rootRect.top}
; + }; + + test("renders visibly into the document", () => { + render(Test); + expect(screen.getByText("Test")).toBeInTheDocument(); + }); + + test("provides root rect in context", () => { + render( + + + + ); + expect(screen.getByText("0")).toBeInTheDocument(); + }); +}); diff --git a/ui/input-search/src/InputSearchRoot.tsx b/ui/input-search/src/InputSearchRoot.tsx index 14b27a29e..37bb5081c 100644 --- a/ui/input-search/src/InputSearchRoot.tsx +++ b/ui/input-search/src/InputSearchRoot.tsx @@ -49,7 +49,7 @@ export const InputSearchRoot = ({ const comboboxRef = React.useRef(null); const [rootRect, setRootRect] = React.useState(); const [term, setTerm] = React.useState(""); - const [usePortal, setUsePortal] = React.useState(false); + const [usePortal, setUsePortal] = React.useState(true); React.useEffect(() => { if (comboboxRef.current) { diff --git a/ui/input-search/src/play.stories.tsx b/ui/input-search/src/play.stories.tsx index 1ca0b8953..cddaafbf7 100644 --- a/ui/input-search/src/play.stories.tsx +++ b/ui/input-search/src/play.stories.tsx @@ -1,10 +1,12 @@ import * as React from "react"; +import { screen, userEvent } from "@storybook/testing-library"; +import { expect } from "@storybook/jest"; import { Box } from "@washingtonpost/wpds-box"; +import { matchSorter } from "match-sorter"; import { InputSearch } from "./"; +import { cities } from "./cities"; import type { ComponentStory } from "@storybook/react"; -import { matchSorter } from "match-sorter"; -import { cities } from "./cities"; export default { title: "InputSearch", @@ -185,3 +187,65 @@ Grouping.args = {}; Grouping.parameters = { chromatic: { disableSnapshot: true }, }; + +const ScrollTemplate: ComponentStory = (args) => { + return ( + + + + + + + + + + + + + + + ); +}; + +export const Scroll = ScrollTemplate.bind({}); + +Scroll.args = {}; + +Scroll.parameters = { + chromatic: { disableSnapshot: true }, +}; + +const InteractionsTemplate: ComponentStory = () => ( + + + + + + + + + + + + + + +); + +export const Interactions = InteractionsTemplate.bind({}); + +// Function to emulate pausing between interactions +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +Interactions.play = async () => { + // radix Label needs a tick to associate labels with inputs + await sleep(0); + const input = screen.getByLabelText("Search"); + await userEvent.type(input, "app", { + delay: 100, + }); + await userEvent.keyboard("[ArrowDown]"); + await expect(input).toHaveDisplayValue("Apple"); +}; diff --git a/ui/input-shared/src/InputShared.tsx b/ui/input-shared/src/InputShared.tsx index 4e179242b..ffc204c0c 100644 --- a/ui/input-shared/src/InputShared.tsx +++ b/ui/input-shared/src/InputShared.tsx @@ -66,6 +66,7 @@ export const unstyledInputStyles = { paddingInline: theme.space["050"], textOverflow: "ellipsis", width: "100%", + "-webkit-appearance": "none", "&:focus": { outline: "none", diff --git a/ui/kitchen-sink/src/KitchenSink.tsx b/ui/kitchen-sink/src/KitchenSink.tsx index 33e7d6866..7c90cacf8 100644 --- a/ui/kitchen-sink/src/KitchenSink.tsx +++ b/ui/kitchen-sink/src/KitchenSink.tsx @@ -26,6 +26,7 @@ import { Switch, Tabs, NavigationMenu, + InputSearch, } from "@washingtonpost/wpds-ui-kit"; import { Chart, Settings, Info, Menu } from "@washingtonpost/wpds-assets"; @@ -472,6 +473,19 @@ export const KitchenSink = () => { +

InputSearch

+ + + + + + + + + + + + ); };