From 6b562941c7f2aaa8abbac19ebaf5f0e7e418155d Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Mon, 21 Aug 2023 17:27:31 -0700 Subject: [PATCH] Implement MentionTypeahead This component implements react-bootstrap-typeahead to give us a typeahead for items. Originally this was all implemented in MentionTableField but we split it into its own component. --- .../common/MentionTypeahead/index.module.scss | 358 ++++++++++++++++++ components/common/MentionTypeahead/index.tsx | 177 +++++++++ 2 files changed, 535 insertions(+) create mode 100644 components/common/MentionTypeahead/index.module.scss create mode 100644 components/common/MentionTypeahead/index.tsx diff --git a/components/common/MentionTypeahead/index.module.scss b/components/common/MentionTypeahead/index.module.scss new file mode 100644 index 00000000..592b84d3 --- /dev/null +++ b/components/common/MentionTypeahead/index.module.scss @@ -0,0 +1,358 @@ +.menu { + background: #fff; + border-radius: $item-corner; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + box-sizing: border-box; + color: rgba(0, 0, 0, 0.8); + overflow: scroll; + padding: $unit-half; + pointer-events: all; + position: relative; + width: 200px; + z-index: 999; + + :global(.dropdown-item.disabled) { + align-items: center; + color: var(--text-tertiary); + display: flex; + font-size: $font-small; + min-height: $unit-5x; + padding-left: $unit; + } + + a:hover { + text-decoration: none !important; + } + + [aria-selected='true'] .item { + background: var(--menu-bg-item-hover); + color: var(--text-primary); + } + + .item { + align-items: center; + background: transparent; + border: 1px solid transparent; + border-radius: $item-corner-small; + color: var(--text-tertiary); + font-size: $font-small; + font-weight: $medium; + display: flex; + gap: $unit; + margin: 0; + padding: $unit-half $unit; + text-align: left; + + &:hover, + &.selected { + background: var(--menu-bg-item-hover); + color: var(--text-primary); + } + + .job { + display: flex; + align-items: center; + justify-content: center; + width: $unit-4x; + height: $unit-4x; + + img { + width: $unit-3x; + height: auto; + } + } + + img { + border-radius: $item-corner-small; + width: $unit-4x; + height: $unit-4x; + } + } +} + +.typeahead { + -webkit-font-smoothing: antialiased; + box-sizing: border-box; + display: block; + flex-grow: 1; + font-size: $font-regular; + white-space: pre-wrap; + max-width: 240px; + width: 100%; + + :global(.rbt-input) { + background-color: var(--input-bound-bg); + border-radius: $input-corner; + color: var(--text-primary); + padding: ($unit * 1.5) $unit-2x; + min-height: 26px; + + &:hover { + background-color: var(--input-bound-bg-hover); + } + + &:focus, + &:global(.focus) { + outline: 2px solid #275dc5; + } + } + + :global(.rbt-input-wrapper) { + align-items: center; + display: flex; + flex-wrap: wrap; + min-height: 30px; + margin-bottom: -4px; + margin-top: -1px; + overflow: hidden; + + :global(.rbt-input-hint) { + color: var(--text-tertiary) !important; + } + } +} + +.token { + border-radius: $item-corner-small; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--null-shadow); + background: var(--null-bg); + color: var(--text-primary); + display: inline-flex; + align-items: center; + font-weight: $medium; + font-size: 15px; + padding: 1px $unit-half 1px $unit; + margin: $unit-fourth; + transition: all 0.1s ease-out; + + white-space: nowrap; + overflow: hidden; + + :global(.rbt-token-label) { + text-overflow: ellipsis; + overflow: hidden; + max-width: 100%; + } + + :global(.rbt-token-remove-button) { + background: none; + border: none; + font-weight: $bold; + + &:hover { + cursor: pointer; + } + + :global(.visually-hidden) { + display: none; + } + } + + $outline: 2px solid rgba(255, 255, 255, 0.35); + + &:hover { + background: var(--null-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--null-shadow-hover); + text-decoration: none; + } + + &[data-element='fire'] { + background: var(--fire-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--fire-shadow); + color: var(--fire-text); + + :global(.rbt-token-remove-button) { + color: var(--fire-text); + + &:hover { + color: var(--fire-text-hover); + } + } + + &:hover { + background: var(--fire-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--fire-shadow-hover); + color: var(--fire-text-hover); + } + + &:focus { + background: var(--fire-text); + box-shadow: none; + color: var(--fire-bg); + outline: $outline; + + :global(.rbt-token-remove-button) { + color: var(--fire-bg); + } + } + } + + &[data-element='water'] { + background: var(--water-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--water-shadow); + color: var(--water-text); + + :global(.rbt-token-remove-button) { + color: var(--water-text); + + &:hover { + color: var(--water-text-hover); + } + } + + &:hover { + background: var(--water-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--water-shadow-hover); + color: var(--water-text-hover); + } + + &:focus { + background: var(--water-text); + box-shadow: none; + color: var(--water-bg); + outline: $outline; + + :global(.rbt-token-remove-button) { + color: var(--water-bg); + } + } + } + + &[data-element='earth'] { + background: var(--earth-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--earth-shadow); + color: var(--earth-text); + + :global(.rbt-token-remove-button) { + color: var(--earth-text); + + &:hover { + color: var(--earth-text-hover); + } + } + + &:hover { + background: var(--earth-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--earth-shadow-hover); + color: var(--earth-text-hover); + } + + &:focus { + background: var(--earth-text); + box-shadow: none; + color: var(--earth-bg); + outline: $outline; + + :global(.rbt-token-remove-button) { + color: var(--earth-bg); + } + } + } + + &[data-element='wind'] { + background: var(--wind-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--wind-shadow); + color: var(--wind-text); + + :global(.rbt-token-remove-button) { + color: var(--wind-text); + + &:hover { + color: var(--wind-text-hover); + } + } + + &:hover { + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--wind-shadow-hover); + color: var(--wind-text-hover); + } + + &:focus { + background: var(--wind-text); + box-shadow: none; + color: var(--wind-bg); + outline: $outline; + + :global(.rbt-token-remove-button) { + color: var(--wind-bg); + } + } + } + + &[data-element='dark'] { + background: var(--dark-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--dark-shadow); + color: var(--dark-text); + + :global(.rbt-token-remove-button) { + color: var(--dark-text); + + &:hover { + color: var(--dark-text-hover); + } + } + + &:hover { + background: var(--dark-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--dark-shadow-hover); + color: var(--dark-text-hover); + } + + &:focus { + background: var(--dark-text); + box-shadow: none; + color: var(--dark-bg); + outline: 2px solid rgba(255, 255, 255, 0.65); + + :global(.rbt-token-remove-button) { + color: var(--dark-bg); + } + } + } + + &[data-element='light'] { + background: var(--light-bg); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--light-shadow); + color: var(--light-text); + + :global(.rbt-token-remove-button) { + color: var(--light-text); + + &:hover { + color: var(--light-text-hover); + } + } + + &:hover { + background: var(--light-bg-hover); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), + 0 1px 0px var(--light-shadow-hover); + color: var(--light-text-hover); + } + + &:focus { + background: var(--light-text); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), + 0 1px 0px var(--light-shadow-hover); + color: var(--light-bg); + outline: $outline; + + :global(.rbt-token-remove-button) { + color: var(--light-bg); + } + } + } +} diff --git a/components/common/MentionTypeahead/index.tsx b/components/common/MentionTypeahead/index.tsx new file mode 100644 index 00000000..f0c85faa --- /dev/null +++ b/components/common/MentionTypeahead/index.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { getCookie } from 'cookies-next' +import type { + Option, + RenderTokenProps, +} from 'react-bootstrap-typeahead/types/types' + +import { + AsyncTypeahead, + Menu, + MenuItem, + RenderMenuProps, + Token, +} from 'react-bootstrap-typeahead' + +import api from '~utils/api' +import { numberToElement } from '~utils/elements' + +import styles from './index.module.scss' + +interface Props + extends React.DetailedHTMLProps< + React.InputHTMLAttributes, + HTMLInputElement + > { + label: string + description?: string + placeholder?: string + inclusions: MentionItem[] + exclusions: MentionItem[] + onUpdate: (content: MentionItem[]) => void +} + +interface RawSearchResponse { + searchable_type: string + granblue_id: string + name_en: string + name_jp: string + element: number +} + +const MentionTypeahead = ({ + label, + description, + placeholder, + inclusions, + exclusions, + ...props +}: Props) => { + const locale = getCookie('NEXT_LOCALE') + ? (getCookie('NEXT_LOCALE') as string) + : 'en' + + console.log(inclusions) + + const [isLoading, setIsLoading] = useState(false) + const [options, setOptions] = useState([]) + + async function handleSearch(query: string) { + setIsLoading(true) + + const exclude = [...inclusions, ...exclusions] + + const response = await api.searchAll(query, exclude, locale) + const results = response.data.results + + setIsLoading(false) + setOptions(mapResults(results)) + } + + function transform(object: RawSearchResponse) { + const result: MentionItem = { + name: { + en: object.name_en, + ja: object.name_jp, + }, + type: object.searchable_type.toLowerCase(), + granblue_id: object.granblue_id, + element: numberToElement(object.element), + } + return result + } + + function mapResults(results: RawSearchResponse[]) { + return results + .map((rawObject: RawSearchResponse) => { + const object = transform(rawObject) + return { + granblue_id: object.granblue_id, + element: object.element, + type: object.type, + name: { + en: object.name.en, + ja: object.name.ja, + }, + } + }) + .slice(0, 5) + } + + function renderMenu(results: Option[], menuProps: RenderMenuProps) { + return ( + + {results.map((option, index) => ( + + {renderMenuItemChild(option)} + + ))} + + ) + } + + function renderMenuItemChild(option: Option) { + const item = option as MentionItem + return ( +
+
+ {item.name[locale]} +
+ {item.name[locale]} +
+ ) + } + + function renderToken(option: Option, props: RenderTokenProps) { + const item = option as MentionItem + const { labelKey, ...tokenProps } = props + return ( + + {item.name[locale]} + + ) + } + + return ( + (option as MentionItem).name[locale]} + defaultSelected={inclusions} + filterBy={() => true} + minLength={3} + onSearch={handleSearch} + options={options} + useCache={false} + placeholder={placeholder} + positionFixed={true} + promptText={'Start typing...'} + searchText={'Searching...'} + renderMenu={renderMenu} + renderMenuItemChildren={renderMenuItemChild} + renderToken={renderToken} + highlightOnlyResult={false} + onChange={(selected) => props.onUpdate(selected as MentionItem[])} + /> + ) +} + +export default MentionTypeahead