react-vite-demo

react and vite demo

git clone https://9o.is/git/react-vite-demo.git

commit 155a0bcab9ed961846d9c18f46974ad6d722b0db
parent 8076d3136fd980b870d7ca6e27b0808bb34efe04
Author: Jul <jul@9o.is>
Date:   Wed, 14 Aug 2024 15:32:02 +0800

refactor code out of App.tsx

Diffstat:
Msrc/App.tsx | 313+------------------------------------------------------------------------------
Asrc/components/headless/Alert.tsx | 8++++++++
Asrc/components/headless/Checkbox.tsx | 13+++++++++++++
Asrc/components/headless/ComboBox.tsx | 23+++++++++++++++++++++++
Asrc/components/headless/Progress.tsx | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/headless/Select.tsx | 22++++++++++++++++++++++
Asrc/components/headless/index.ts | 6++++++
Asrc/components/hooks/fetch.ts | 38++++++++++++++++++++++++++++++++++++++
Asrc/components/hooks/index.ts | 3+++
Asrc/components/hooks/intl.ts | 5+++++
Asrc/components/page/EventsPage/EventsPage.tsx | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/page/EventsPage/index.ts | 2++
Asrc/components/page/EventsPage/useFilteredSHEvents.ts | 34++++++++++++++++++++++++++++++++++
Asrc/components/page/EventsPage/useSHEvents.ts | 34++++++++++++++++++++++++++++++++++
Asrc/components/page/index.ts | 2++
Asrc/lib/index.ts | 3+++
Asrc/lib/objects.ts | 44++++++++++++++++++++++++++++++++++++++++++++
Rsrc/pipe.ts -> src/lib/pipe.ts | 0
18 files changed, 362 insertions(+), 310 deletions(-)

diff --git a/src/App.tsx b/src/App.tsx @@ -1,313 +1,6 @@ -import { createContext, ReactNode, useContext, useEffect, useMemo, useState, useId } from 'react' -import { pipe } from './pipe' +import { EventsPage } from './components/page' import './App.css' -type SHEvent = { - id: string - city: string - price: number +export default function App() { + return <EventsPage /> } - -type SHNodeEventId = { - id: string -} - -type SHNodeEvent = SHNodeEventId & ({ - events: SHEvent[] - children: [] -} | { - events: [] - children: SHNodeEvent[] -}) - -function getEvents({ events, children }: SHNodeEvent): SHEvent[] { - if (events.length > 0) return events - return children.flatMap(getEvents) -} - -const useFetch = <T,>(url: string) => { - const [data, setData] = useState<T | undefined>() - const [error, setError] = useState<Error | undefined>() - const [loading, setLoading] = useState(false) - - useEffect(() => { - const controller = new AbortController() - const signal = controller.signal - - const fetchData = async () => { - setLoading(true) - - try { - const response = await fetch(url, { signal }) - - if (!response.ok) { - throw new Error("Bad request") - } - - setData(await response.json()) - setError(undefined) - } catch (error) { - setError(error as Error) - } finally { - setLoading(false) - } - } - - fetchData() - - return () => controller.abort() - }, [url, setData, setError, setLoading]) - - return { data, error, loading } -} - -const useSHEvents = (id: SHNodeEventId['id']) => { - const { data, ...rest } = useFetch<SHNodeEvent>(`http://localhost:3000/node-events/${id}`) - - return { - ...rest, - events: useMemo(() => data ? getEvents(data) : [], [data]), - } -} - -const useNumberFormatter = (options?: Intl.NumberFormatOptions) => { - const locale = navigator.language || 'en-US' - return new Intl.NumberFormat(locale, options) -} - -const Alert = ({ error }: { error?: Error }) => { - return ( - <div role="alert"> - {error?.message ?? <>&nbsp;</>} - </div> - ) -} - -type ProgressContext = { labelId: string, progressBarId: string, loading: boolean } -const ProgressContext = createContext<ProgressContext | undefined>(undefined) -const useProgressContext = () => { - const context = useContext(ProgressContext) - if (!context) throw new Error('useProgressContext must be used within a Progress component') - return context -} - -const Progress = ({ loading, children }: { loading: boolean, children: ReactNode }) => { - const labelId = useId() - const progressBarId = useId() - - return ( - <ProgressContext.Provider value={{ labelId, progressBarId, loading }}> - {children} - </ProgressContext.Provider> - ) -} - -const ProgressBar = ({ children }: { children: ReactNode }) => { - const { progressBarId, labelId, loading } = useProgressContext() - - if (!loading) return <>&nbsp;</> - - return ( - <div id={progressBarId} role="progressbar" aria-labelledby={labelId}> - {children} - </div> - ) -} - -const ProgressLabel = ({ children }: { children: ReactNode }) => { - const { labelId } = useProgressContext() - return ( - <span id={labelId}>{children}</span> - ) -} - -const ProgressContent = ({ children }: { children: ReactNode }) => { - const { loading, progressBarId } = useProgressContext() - return ( - <div aria-live="polite" aria-describedby={loading ? progressBarId : undefined}> - {children} - </div> - ) -} - -type ComboBoxProps = { - label: string - name: string - options: string[] -} - -const ComboBox = ({ label, name, options }: ComboBoxProps) => { - const inputId = useId() - const datalistId = useId() - - return ( - <> - <label htmlFor={inputId}>{label}</label> - <input id={inputId} name={name} type="text" list={datalistId} /> - <datalist id={datalistId}> - {options.map(option => <option key={option} value={option} />)} - </datalist> - </> - ) -} - -type SelectProps = { - label: string - name: string - options: string[] -} - -const Select = ({ label, name, options }: SelectProps) => { - const id = useId() - - return ( - <> - <label htmlFor={id}>{label}</label> - <select id={id} name={name}> - <option value="">Select...</option> - {options.map(option => <option key={option}>{option}</option>)} - </select> - </> - ) -} - -const Checkbox = ({ label, name }: { label: string, name: string }) => { - const id = useId() - - return ( - <> - <input id={id} name={name} type="checkbox" /> - <label htmlFor={id}>{label}</label> - </> - ) -} - - -type KeysOfType<T, U> = { - [K in keyof T]: T[K] extends U ? K : never -}[keyof T] - -type NumberKeysOf<T> = KeysOfType<T, number> - -type SortType = "ascending" | "descending" | undefined - -const sortByProp = <T,>(prop: NumberKeysOf<T>, order: SortType, objects: T[]): T[] => { - if (!order) return objects - - return [...objects].sort((a: T, b: T) => { - if (!order || typeof a[prop] !== "number" || typeof b[prop] !== "number") return 0 - if (order === "ascending") return a[prop] - b[prop] - return b[prop] - a[prop] - }) -} - -const minByProp = <T extends object>(prop: NumberKeysOf<T>, objects: T[]): T | null => { - if (objects.length === 0) return null - if (objects.length === 1) return objects[0] - - const [fst, snd, ...tail] = objects - const min = fst[prop] < snd[prop] ? fst : snd - return minByProp(prop, [min, ...tail]) -} -// Alternative implementation using reduce -// const minByProp = <T extends object>(prop: NumberKeysOf<T>, objects: T[]): T | undefined => { -// if (objects.length === 0) return undefined - -// return objects.reduce((minObj, currentObj) => { -// return currentObj[prop] < minObj[prop] ? currentObj : minObj -// }) -// } - -const asArray = <T,>(value: T | T[] | undefined | null): T[] => - Array.isArray(value) ? value : - value !== undefined && value !== null ? [value] : [] - -const filterByProp = <T extends object>(prop: keyof T, value: T[keyof T], objects: T[]): T[] => { - return objects.filter(object => object[prop] === value) -} - -type FilteredSHEventsInput = { - city: string - priceSort: SortType - cheapest: boolean -} - -const sortByPrice = (priceSort: SortType) => (events: SHEvent[]) => sortByProp('price', priceSort, events) -const filterByCity = (city: string) => (events: SHEvent[]) => city ? filterByProp("city", city, events) : events -const filterCheapest = (cheapest: boolean) => (events: SHEvent[]) => cheapest ? minByProp('price', events) : events - -const useFilteredSHEvents = (initialEvents: SHEvent[]) => { - const [events, setEvents] = useState<SHEvent[]>(initialEvents) - - useEffect(() => { - setEvents(initialEvents) - }, [initialEvents]) - - const filterSHEvents = ({ city, priceSort, cheapest }: FilteredSHEventsInput) => { - setEvents(pipe( - initialEvents, - filterByCity(city), - sortByPrice(priceSort), - filterCheapest(cheapest), - asArray - )) - } - - return { events, filterSHEvents } -} - -const EventsPage = () => { - const { events: loadedEvents, error, loading } = useSHEvents('node-a') - const { events, filterSHEvents } = useFilteredSHEvents(loadedEvents) - const priceFormatter = useNumberFormatter({ - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0 - }) - - const cities = [...new Set(loadedEvents.map(({ city }) => city))] - - const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { - event.preventDefault() - - const formData = new FormData(event.currentTarget) - const city = formData.get("city") - const priceSort = formData.get("price-sort") - const cheapest = formData.get("cheapest") === "on" - - if (typeof city !== 'string' || typeof priceSort !== 'string') return - if (priceSort !== 'ascending' && priceSort !== 'descending' && priceSort !== '') return - - filterSHEvents({ city, cheapest, priceSort: priceSort || undefined }) - } - - return ( - <> - <Alert error={error} /> - <form onSubmit={onSubmit} aria-label='Filter Events'> - <ComboBox label="City" name="city" options={cities} /> - <Select label="Sort by price" name="price-sort" options={['ascending', 'descending']} /> - <Checkbox label="Find Cheapest" name="cheapest" /> - <button type="submit" disabled={loading}>Search</button> - </form> - <Progress loading={loading}> - <ProgressBar> - <ProgressLabel>Loading events...</ProgressLabel> - </ProgressBar> - <ProgressContent> - <ul> - {events.map(({ id, city, price }) => ( - <li key={id}> - <div>City: {city}</div> - <div>Price: {priceFormatter.format(price)}</div> - </li> - ))} - </ul> - </ProgressContent> - </Progress> - </> - ) -} - -const App = () => <EventsPage /> -export default App diff --git a/src/components/headless/Alert.tsx b/src/components/headless/Alert.tsx @@ -0,0 +1,7 @@ +export const Alert = ({ error }: { error?: Error }) => { + return ( + <div role="alert"> + {error?.message ?? <>&nbsp;</>} + </div> + ) +} +\ No newline at end of file diff --git a/src/components/headless/Checkbox.tsx b/src/components/headless/Checkbox.tsx @@ -0,0 +1,12 @@ +import { useId } from "react" + +export function Checkbox({ label, name }: { label: string, name: string }) { + const id = useId() + + return ( + <> + <input id={id} name={name} type="checkbox" /> + <label htmlFor={id}>{label}</label> + </> + ) +} +\ No newline at end of file diff --git a/src/components/headless/ComboBox.tsx b/src/components/headless/ComboBox.tsx @@ -0,0 +1,22 @@ +import { useId } from "react" + +export type ComboBoxProps = { + label: string + name: string + options: string[] + } + +export function ComboBox({ label, name, options }: ComboBoxProps) { + const inputId = useId() + const datalistId = useId() + + return ( + <> + <label htmlFor={inputId}>{label}</label> + <input id={inputId} name={name} type="text" list={datalistId} /> + <datalist id={datalistId}> + {options.map(option => <option key={option} value={option} />)} + </datalist> + </> + ) +} +\ No newline at end of file diff --git a/src/components/headless/Progress.tsx b/src/components/headless/Progress.tsx @@ -0,0 +1,63 @@ +import { createContext, ReactNode, useContext, useId } from "react" + +type ProgressContext = { + labelId: string + progressBarId: string + loading: boolean +} + +type ProgressProps = { + loading: boolean + children: ReactNode +} + +const ProgressContext = createContext<ProgressContext | undefined>(undefined) + +export function useProgressContext() { + const context = useContext(ProgressContext) + if (!context) throw new Error('useProgressContext must be used within a Progress component') + return context +} + +export function Progress ({ loading, children }: ProgressProps) { + const labelId = useId() + const progressBarId = useId() + + return ( + <ProgressContext.Provider value={{ labelId, progressBarId, loading }}> + {children} + </ProgressContext.Provider> + ) +} + +function ProgressBar({ children }: { children: ReactNode }) { + const { progressBarId, labelId, loading } = useProgressContext() + + if (!loading) return <>&nbsp;</> + + return ( + <div id={progressBarId} role="progressbar" aria-labelledby={labelId}> + {children} + </div> + ) +} + +function ProgressLabel({ children }: { children: ReactNode }) { + const { labelId } = useProgressContext() + return ( + <span id={labelId}>{children}</span> + ) +} + +function ProgressContent({ children }: { children: ReactNode }) { + const { loading, progressBarId } = useProgressContext() + return ( + <div aria-live="polite" aria-describedby={loading ? progressBarId : undefined}> + {children} + </div> + ) +} + +Progress.Bar = ProgressBar +Progress.Label = ProgressLabel +Progress.Content = ProgressContent +\ No newline at end of file diff --git a/src/components/headless/Select.tsx b/src/components/headless/Select.tsx @@ -0,0 +1,21 @@ +import { useId } from "react" + +type SelectProps = { + label: string + name: string + options: string[] +} + +export function Select({ label, name, options }: SelectProps) { + const id = useId() + + return ( + <> + <label htmlFor={id}>{label}</label> + <select id={id} name={name}> + <option value="">Select...</option> + {options.map(option => <option key={option}>{option}</option>)} + </select> + </> + ) +} +\ No newline at end of file diff --git a/src/components/headless/index.ts b/src/components/headless/index.ts @@ -0,0 +1,5 @@ +export * from './Alert' +export * from './ComboBox' +export * from './Checkbox' +export * from './Progress' +export * from './Select' +\ No newline at end of file diff --git a/src/components/hooks/fetch.ts b/src/components/hooks/fetch.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react" + +export function useFetch<T>(url: string) { + const [data, setData] = useState<T | undefined>() + const [error, setError] = useState<Error | undefined>() + const [loading, setLoading] = useState(false) + + useEffect(() => { + const controller = new AbortController() + const signal = controller.signal + + const fetchData = async () => { + setLoading(true) + + try { + const response = await fetch(url, { signal }) + + if (!response.ok) { + throw new Error("Bad request") + } + + setData(await response.json()) + setError(undefined) + } catch (error) { + setError(error as Error) + } finally { + setLoading(false) + } + } + + fetchData() + + return () => controller.abort() + }, [url, setData, setError, setLoading]) + + return { data, error, loading } +} +\ No newline at end of file diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './fetch' +export * from './intl' +\ No newline at end of file diff --git a/src/components/hooks/intl.ts b/src/components/hooks/intl.ts @@ -0,0 +1,5 @@ + +export function useNumberFormatter(options?: Intl.NumberFormatOptions) { + const locale = navigator.language || 'en-US' + return new Intl.NumberFormat(locale, options) +} diff --git a/src/components/page/EventsPage/EventsPage.tsx b/src/components/page/EventsPage/EventsPage.tsx @@ -0,0 +1,57 @@ +import { Checkbox, ComboBox, Select, Progress, Alert } from '../../headless' +import { useNumberFormatter } from '../../hooks' +import { useSHEvents } from './useSHEvents' +import { useFilteredSHEvents } from './useFilteredSHEvents' + +export function EventsPage() { + const { events: loadedEvents, error, loading } = useSHEvents('node-a') + const { events, filterSHEvents } = useFilteredSHEvents(loadedEvents) + const priceFormatter = useNumberFormatter({ + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0 + }) + + const cities = [...new Set(loadedEvents.map(({ city }) => city))] + + const onSubmit = (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault() + + const formData = new FormData(event.currentTarget) + const city = formData.get("city") + const priceSort = formData.get("price-sort") + const cheapest = formData.get("cheapest") === "on" + + if (typeof city !== 'string' || typeof priceSort !== 'string') return + if (priceSort !== 'ascending' && priceSort !== 'descending' && priceSort !== '') return + + filterSHEvents({ city, cheapest, priceSort: priceSort || undefined }) + } + + return ( + <> + <Alert error={error} /> + <form onSubmit={onSubmit} aria-label='Filter Events'> + <ComboBox label="City" name="city" options={cities} /> + <Select label="Sort by price" name="price-sort" options={['ascending', 'descending']} /> + <Checkbox label="Find Cheapest" name="cheapest" /> + <button type="submit" disabled={loading}>Search</button> + </form> + <Progress loading={loading}> + <Progress.Bar> + <Progress.Label>Loading events...</Progress.Label> + </Progress.Bar> + <Progress.Content> + <ul> + {events.map(({ id, city, price }) => ( + <li key={id}> + <div>City: {city}</div> + <div>Price: {priceFormatter.format(price)}</div> + </li> + ))} + </ul> + </Progress.Content> + </Progress> + </> + ) + } +\ No newline at end of file diff --git a/src/components/page/EventsPage/index.ts b/src/components/page/EventsPage/index.ts @@ -0,0 +1 @@ +export * from "./EventsPage" +\ No newline at end of file diff --git a/src/components/page/EventsPage/useFilteredSHEvents.ts b/src/components/page/EventsPage/useFilteredSHEvents.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react" +import { asArray, filterByProp, minByProp, pipe, sortByProp, SortType } from "../../../lib" +import { SHEvent } from "./useSHEvents" + +type FilteredSHEventsInput = { + city: string + priceSort: SortType + cheapest: boolean +} + +const sortByPrice = (priceSort: SortType) => (events: SHEvent[]) => sortByProp('price', priceSort, events) +const filterByCity = (city: string) => (events: SHEvent[]) => city ? filterByProp("city", city, events) : events +const filterCheapest = (cheapest: boolean) => (events: SHEvent[]) => cheapest ? minByProp('price', events) : events + +export function useFilteredSHEvents(initialEvents: SHEvent[]) { + const [events, setEvents] = useState<SHEvent[]>(initialEvents) + + useEffect(() => { + setEvents(initialEvents) + }, [initialEvents]) + + const filterSHEvents = ({ city, priceSort, cheapest }: FilteredSHEventsInput) => { + setEvents(pipe( + initialEvents, + filterByCity(city), + sortByPrice(priceSort), + filterCheapest(cheapest), + asArray + )) + } + + return { events, filterSHEvents } +} +\ No newline at end of file diff --git a/src/components/page/EventsPage/useSHEvents.ts b/src/components/page/EventsPage/useSHEvents.ts @@ -0,0 +1,34 @@ +import { useMemo } from "react" +import { useFetch } from "../../hooks" + +export type SHEvent = { + id: string + city: string + price: number +} + +type SHNodeEventId = { + id: string +} + +type SHNodeEvent = SHNodeEventId & ({ + events: SHEvent[] + children: [] +} | { + events: [] + children: SHNodeEvent[] +}) + +export function useSHEvents(id: SHNodeEvent['id']) { + const { data, ...rest } = useFetch<SHNodeEvent>(`http://localhost:3000/node-events/${id}`) + + return { + ...rest, + events: useMemo(() => data ? getEvents(data) : [], [data]), + } +} + +function getEvents({ events, children }: SHNodeEvent): SHEvent[] { + if (events.length > 0) return events + return children.flatMap(getEvents) +} diff --git a/src/components/page/index.ts b/src/components/page/index.ts @@ -0,0 +1 @@ +export * from "./EventsPage" +\ No newline at end of file diff --git a/src/lib/index.ts b/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from './pipe' +export * from './objects' +\ No newline at end of file diff --git a/src/lib/objects.ts b/src/lib/objects.ts @@ -0,0 +1,44 @@ +type KeysOfType<T, U> = { + [K in keyof T]: T[K] extends U ? K : never +}[keyof T] + +type NumberKeysOf<T> = KeysOfType<T, number> + +export type SortType = "ascending" | "descending" | undefined + +export function asArray<T>(value: T | T[] | undefined | null): T[] { + return Array.isArray(value) ? value : + value !== undefined && value !== null ? [value] : [] +} + +export function filterByProp<T extends object>(prop: keyof T, value: T[keyof T], objects: T[]): T[] { + return objects.filter(object => object[prop] === value) +} + +export function sortByProp<T,>(prop: NumberKeysOf<T>, order: SortType, objects: T[]): T[] { + if (!order) return objects + + return [...objects].sort((a: T, b: T) => { + if (!order || typeof a[prop] !== "number" || typeof b[prop] !== "number") return 0 + if (order === "ascending") return a[prop] - b[prop] + return b[prop] - a[prop] + }) +} + +export function minByProp<T extends object>(prop: NumberKeysOf<T>, objects: T[]): T | null { + if (objects.length === 0) return null + if (objects.length === 1) return objects[0] + + const [fst, snd, ...tail] = objects + const min = fst[prop] < snd[prop] ? fst : snd + return minByProp(prop, [min, ...tail]) +} + +// Alternative implementation using reduce +// const minByProp = <T extends object>(prop: NumberKeysOf<T>, objects: T[]): T | undefined => { +// if (objects.length === 0) return undefined + +// return objects.reduce((minObj, currentObj) => { +// return currentObj[prop] < minObj[prop] ? currentObj : minObj +// }) +// } diff --git a/src/pipe.ts b/src/lib/pipe.ts