diff --git a/apps/cyberstorm-storybook/stories/components/SelectSearch.stories.tsx b/apps/cyberstorm-storybook/stories/components/SelectSearch.stories.tsx new file mode 100644 index 000000000..3dd711eaf --- /dev/null +++ b/apps/cyberstorm-storybook/stories/components/SelectSearch.stories.tsx @@ -0,0 +1,59 @@ +import { StoryFn, Meta } from "@storybook/react"; +import React, { useState } from "react"; +import { SelectSearch } from "@thunderstore/cyberstorm/src/components/SelectSearch/SelectSearch"; + +const meta = { + title: "Cyberstorm/Components/SelectSearch", + component: SelectSearch, +} as Meta; + +const options = [ + "Team 1", + "Team 2", + "Team 3", + "Team 4", + "Team 5", + "Team 6", + "Team 7", + "Team 8", + "Team 9", + "Team 10", + "Team 11", + "Team 12", + "Team 13", + "Team 14", +]; + +const defaultArgs = { + placeholder: "Select something", + options: options, +}; + +const Template: StoryFn = (args) => { + const [selected, setSelected] = useState(undefined); + const defaultProps = { + ...args, + onChange: setSelected, + value: selected, + }; + return ( +
+
Value in state: {selected}
+ +
+ ); +}; + +const GreenSelectSearch = Template.bind({}); +GreenSelectSearch.args = { + ...defaultArgs, + color: "green", +}; + +const RedSelectSearch = Template.bind({}); +RedSelectSearch.args = { + ...defaultArgs, + color: "red", +}; + +export { meta as default, GreenSelectSearch, RedSelectSearch }; diff --git a/packages/cyberstorm-forms/src/components/FormSelectSearch.tsx b/packages/cyberstorm-forms/src/components/FormSelectSearch.tsx new file mode 100644 index 000000000..5037b62e9 --- /dev/null +++ b/packages/cyberstorm-forms/src/components/FormSelectSearch.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { z, ZodObject } from "zod"; +import { ZodRawShape } from "zod/lib/types"; +import { Path, useController } from "react-hook-form"; +import { SelectSearch } from "@thunderstore/cyberstorm"; +import styles from "./FormTextInput.module.css"; + +export type FormSelectSearchProps< + Schema extends ZodObject, + Z extends ZodRawShape +> = { + // The schema is required to allow TS to infer valid values for the name field + schema: Schema; + name: Path>; + placeholder?: string; + options: string[]; +}; +export function FormSelectSearch< + Schema extends ZodObject, + Z extends ZodRawShape +>({ name, placeholder, options }: FormSelectSearchProps) { + const { + field, + fieldState: { isDirty, invalid, error }, + formState: { isSubmitting, disabled }, + } = useController({ name }); + + return ( + <> + + {error && {error.message}} + + ); +} + +FormSelectSearch.displayName = "FormSelectSearch"; diff --git a/packages/cyberstorm-forms/src/index.ts b/packages/cyberstorm-forms/src/index.ts index a97ce26b2..f5b33af50 100644 --- a/packages/cyberstorm-forms/src/index.ts +++ b/packages/cyberstorm-forms/src/index.ts @@ -1,4 +1,5 @@ export { useFormToaster } from "./useFormToaster"; export { FormSubmitButton } from "./components/FormSubmitButton"; +export { FormSelectSearch } from "./components/FormSelectSearch"; export { FormTextInput } from "./components/FormTextInput"; export { CreateTeamForm } from "./forms/CreateTeamForm"; diff --git a/packages/cyberstorm/src/components/SelectSearch/SelectSearch.module.css b/packages/cyberstorm/src/components/SelectSearch/SelectSearch.module.css new file mode 100644 index 000000000..7c863e952 --- /dev/null +++ b/packages/cyberstorm/src/components/SelectSearch/SelectSearch.module.css @@ -0,0 +1,141 @@ +.root { + position: relative; + display: flex; + flex-direction: column; + gap: var(--gap--16); + justify-content: flex-end; + width: auto; + min-height: 6rem; + color: var(--color-text--tertiary); +} + +.selected { + display: flex; + flex-flow: row wrap; + gap: var(--space--8); +} + +.search { + position: relative; + display: flex; + flex-direction: column; + width: auto; + height: 2.75rem; + color: var(--color-text--tertiary); +} + +.inputContainer { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + border: var(--border-width--2) solid var(--border-color); + + border-radius: var(--border-radius--8); + background-color: var(--color-surface--4); + + --border-color: transparent; + + transition: ease-out 300ms; +} + +.input { + width: 100%; + margin: var(--space--10) var(--space--14); + font-weight: var(--font-weight-medium); + font-size: var(--font-size--l); + line-height: normal; + background-color: transparent; +} + +.inputContainer:hover { + --border-color: var(--color-border--highlight); +} + +.inputContainer:focus-within { + color: var(--color-text--default); + background-color: var(--color-black); + + --border-color: var(--color-border--highlight); +} + +.input::placeholder { + color: var(--color-text--tertiary); +} + +.inputContainer[data-color="red"] { + --border-color: var(--color-red--5); +} + +.inputContainer[data-color="red"]:hover { + --border-color: var(--color-red--3); +} + +.inputContainer[data-color="green"] { + --border-color: var(--color-cyber-green--50); +} + +.inputContainer[data-color="green"]:hover { + --border-color: var(--color-cyber-green--80); +} + +.clearSearch { + width: 3rem; + height: 100%; + color: #c6c3ff; + background: transparent; + opacity: 0.5; +} + +.showMenuButton { + width: 3rem; + height: 100%; + color: #9c9cc4; + background: transparent; +} + +.inputButtonDivider { + width: 0.063rem; + height: 1.375rem; + background: #4343a3; +} + +.menu { + position: absolute; + top: 3.25rem; + z-index: 9999; + display: flex; + flex-direction: column; + gap: var(--gap--4); + width: 100%; + min-height: 1.5rem; + max-height: 12rem; + padding: var(--space--8) 0; + border: var(--space--px) var(--color-surface--6) solid; + border-radius: var(--border-radius--8); + overflow: hidden; + overflow-y: auto !important; + color: var(--text-color); + background-color: var(--color-surface--2); + box-shadow: var(--box-shadow-default); + visibility: hidden; + + --text-color: var(--color-white); + --bg-color: var(--color-surface--4); +} + +.menu:where(.visible) { + visibility: visible; +} + +.menuLabel { + font-weight: var(--font-weight-medium); +} + +.multiSelectItemWrapper { + padding: var(--space--12) var(--space--16); +} + +.multiSelectItemWrapper:focus { + background-color: var(--color-surface--6); +} diff --git a/packages/cyberstorm/src/components/SelectSearch/SelectSearch.tsx b/packages/cyberstorm/src/components/SelectSearch/SelectSearch.tsx new file mode 100644 index 000000000..904aa6567 --- /dev/null +++ b/packages/cyberstorm/src/components/SelectSearch/SelectSearch.tsx @@ -0,0 +1,164 @@ +"use client"; +import React from "react"; +import styles from "./SelectSearch.module.css"; +import { classnames } from "../../utils/utils"; +import { Button, Icon } from "../../index"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCaretDown, + faCircleXmark, + faXmark, +} from "@fortawesome/pro-solid-svg-icons"; +import { isNode } from "../../utils/type_guards"; + +type Props = { + options: string[]; + value?: string; + onChange: (v: string | undefined) => void; + onBlur: () => void; + // TODO: Implement disabled state + disabled?: boolean; + placeholder?: string; + color?: "red" | "green"; +}; + +/** + * Cyberstorm SelectSearch component + * TODO: Better keyboard navigation + */ +export const SelectSearch = React.forwardRef( + function SelectSearch(props, ref) { + const { options, value, onChange, onBlur, placeholder, color } = props; + const menuRef = React.useRef(null); + const inputRef = React.useRef(null); + + const [isVisible, setIsVisible] = React.useState(false); + const [search, setSearch] = React.useState(""); + + const hideMenu = React.useCallback( + (e: MouseEvent | TouchEvent) => { + if ( + menuRef.current && + isVisible && + !menuRef.current.contains(isNode(e.target) ? e.target : null) + ) { + setIsVisible(false); + onBlur(); + } + }, + [setIsVisible, isVisible, onBlur, menuRef] + ); + + // Event listeners for closing menu when clicking or touching outside. + React.useEffect(() => { + document.addEventListener("mousedown", hideMenu); + document.addEventListener("touchstart", hideMenu); + return () => { + document.removeEventListener("mousedown", hideMenu); + document.removeEventListener("touchstart", hideMenu); + }; + }); + + return ( +
+
+ {value ? ( + onChange(undefined)} + paddingSize="small" + style={{ gap: "0.5rem" }} + > + {value} + + + + + ) : null} +
+
{ + inputRef.current && inputRef.current.focus(); + e.stopPropagation(); + }} + role="button" + tabIndex={0} + ref={menuRef} + > +
+ setIsVisible(true)} + onChange={(e) => setSearch(e.currentTarget.value)} + ref={inputRef} + placeholder={placeholder} + /> + +
+ +
+
+ {options.map((option) => { + return ( + { + onChange(option); + e.stopPropagation(); + }} + option={option} + /> + ); + })} +
+
+
+ ); + } +); + +SelectSearch.displayName = "SelectSearch"; + +const SelectItem = (props: { + onClick: (e: React.MouseEvent | React.KeyboardEvent) => void; + option: string; +}) => { + return ( +
(e.code === "Enter" ? props.onClick(e) : null)} + tabIndex={0} + role="button" + > + {props.option} +
+ ); +}; diff --git a/packages/cyberstorm/src/index.ts b/packages/cyberstorm/src/index.ts index b5e488ef9..271492508 100644 --- a/packages/cyberstorm/src/index.ts +++ b/packages/cyberstorm/src/index.ts @@ -53,6 +53,7 @@ export type { MenuItemProps } from "./components/MenuItem/"; export { MetaItem, type MetaItemProps } from "./components/MetaItem/MetaItem"; export { MetaInfoItem } from "./components/MetaInfoItem/MetaInfoItem"; export { MetaInfoItemList } from "./components/MetaInfoItemList/MetaInfoItemList"; +export { SelectSearch } from "./components/SelectSearch/SelectSearch"; export { Switch, type SwitchProps } from "./components/Switch/Switch"; export { PackageCard } from "./components/PackageCard/PackageCard"; export { diff --git a/packages/cyberstorm/src/utils/type_guards.ts b/packages/cyberstorm/src/utils/type_guards.ts index 0a8e57242..d226ea430 100644 --- a/packages/cyberstorm/src/utils/type_guards.ts +++ b/packages/cyberstorm/src/utils/type_guards.ts @@ -3,3 +3,10 @@ export const isRecord = (obj: unknown): obj is Record => export const isStringArray = (arr: unknown): arr is string[] => Array.isArray(arr) && arr.every((s) => typeof s === "string"); + +export const isNode = (e: EventTarget | null): e is Node => { + if (!e || !("nodeType" in e)) { + return false; + } + return true; +};