Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: migrate disclosure & combobox from reach-ui #2316

Merged
merged 4 commits into from Apr 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 0 additions & 1 deletion packages/web/package.json
Expand Up @@ -30,7 +30,6 @@
"@babel/preset-typescript": "^7.18.6",
"@lexical/react": "0.9.2",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.10",
"@reach/disclosure": "^0.18.0",
"@simplewebauthn/browser": "^7.1.0",
"@standardnotes/authenticator": "^2.3.9",
"@standardnotes/autobiography-theme": "^1.2.7",
Expand Down
@@ -1,13 +1,10 @@
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { doesItemMatchSearchQuery } from '@/Utils/Items/Search/doesItemMatchSearchQuery'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { classNames, ContentType, DecryptedItem, naturalSort } from '@standardnotes/snjs'
import { Combobox, ComboboxItem, ComboboxPopover, useComboboxStore, VisuallyHidden } from '@ariakit/react'
import { ContentType, DecryptedItem, naturalSort } from '@standardnotes/snjs'
import { observer } from 'mobx-react-lite'
import { ChangeEventHandler, FocusEventHandler, useCallback, useEffect, useRef, useState } from 'react'
import { useDeferredValue, useEffect, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import LinkedItemMeta from '../LinkedItems/LinkedItemMeta'
import Menu from '../Menu/Menu'

type Props = {
contentTypes: ContentType[]
Expand All @@ -18,41 +15,11 @@ type Props = {
const ItemSelectionDropdown = ({ contentTypes, placeholder, onSelection }: Props) => {
const application = useApplication()

const [searchQuery, setSearchQuery] = useState('')
const [dropdownVisible, setDropdownVisible] = useState(false)

const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)
const combobox = useComboboxStore()
const value = combobox.useState('value')
const searchQuery = useDeferredValue(value)
const [items, setItems] = useState<DecryptedItem[]>([])

const showDropdown = () => {
const { clientHeight } = document.documentElement
const inputRect = inputRef.current?.getBoundingClientRect()
if (inputRect) {
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
setDropdownVisible(true)
}
}

const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
setDropdownVisible(visible)
setSearchQuery('')
})

const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
}

const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setSearchQuery(event.currentTarget.value)
}

const handleFocus = () => {
showDropdown()
}

useEffect(() => {
const searchableItems = naturalSort(application.items.getItems(contentTypes), 'title')
const filteredItems = searchableItems.filter((item) => {
Expand All @@ -61,70 +28,38 @@ const ItemSelectionDropdown = ({ contentTypes, placeholder, onSelection }: Props
setItems(filteredItems)
}, [searchQuery, application, contentTypes])

const onSelectItem = useCallback(
(item: DecryptedItem) => {
onSelection(item)
setSearchQuery('')
setDropdownVisible(false)
},
[onSelection],
)

return (
<div className="relative" ref={containerRef}>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
className={classNames(
'mr-10 w-70',
'bg-transparent text-sm text-text focus:border-b-2 focus:border-solid focus:border-info lg:text-xs',
'no-border h-7 focus:shadow-none focus:outline-none',
)}
value={searchQuery}
onChange={onSearchQueryChange}
type="text"
<div>
<label>
<VisuallyHidden>Select an item</VisuallyHidden>
<Combobox
store={combobox}
placeholder={placeholder}
onFocus={handleFocus}
onBlur={onBlur}
autoComplete="off"
className="h-7 w-70 bg-transparent text-sm text-text focus:border-b-2 focus:border-info focus:shadow-none focus:outline-none lg:text-xs"
/>

{dropdownVisible && (
<DisclosurePanel
className={classNames(
'mr-10 w-70',
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
)}
style={{
maxHeight: dropdownMaxHeight,
}}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
onBlur={closeOnBlur}
>
<Menu
isOpen={dropdownVisible}
a11yLabel="Tag search results"
ref={searchResultsMenuRef}
shouldAutoFocus={false}
</label>
<ComboboxPopover
store={combobox}
className="z-dropdown-menu max-h-[var(--popover-available-height)] w-[var(--popover-anchor-width)] overflow-y-auto rounded bg-default py-2 shadow-main"
>
{items.length > 0 ? (
items.map((item) => (
<ComboboxItem
key={item.uuid}
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground [&[data-active-item]]:bg-info-backdrop"
hideOnClick
onClick={() => {
combobox.setValue('')
onSelection(item)
}}
>
{items.map((item) => {
return (
<button
key={item.uuid}
className={classNames(
'flex w-full items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast',
'hover:text-foreground focus:bg-info-backdrop',
)}
onClick={() => onSelectItem(item)}
>
<LinkedItemMeta item={item} searchQuery={searchQuery} />
</button>
)
})}
</Menu>
</DisclosurePanel>
<LinkedItemMeta item={item} searchQuery={searchQuery} />
</ComboboxItem>
))
) : (
<div className="px-2">No results found</div>
)}
</Disclosure>
</ComboboxPopover>
</div>
)
}
Expand Down
@@ -1,26 +1,18 @@
import {
ChangeEventHandler,
FocusEventHandler,
FormEventHandler,
KeyboardEventHandler,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { Disclosure, DisclosurePanel } from '@reach/disclosure'
import { useCloseOnBlur } from '@/Hooks/useCloseOnBlur'
import { FormEventHandler, KeyboardEventHandler, useDeferredValue, useEffect, useRef } from 'react'
import { observer } from 'mobx-react-lite'
import { classNames } from '@standardnotes/utils'
import { FOCUSABLE_BUT_NOT_TABBABLE } from '@/Constants/Constants'
import LinkedItemSearchResults from './LinkedItemSearchResults'
import { LinkingController } from '@/Controllers/LinkingController'
import { KeyboardKey } from '@standardnotes/ui-services'
import { ElementIds } from '@/Constants/ElementIDs'
import Menu from '../Menu/Menu'
import { getLinkingSearchResults } from '@/Utils/Items/Search/getSearchResults'
import { useApplication } from '../ApplicationProvider'
import { DecryptedItem } from '@standardnotes/snjs'
import { DecryptedItem, SNNote } from '@standardnotes/snjs'
import { Combobox, ComboboxItem, ComboboxPopover, useComboboxStore, VisuallyHidden } from '@ariakit/react'
import LinkedItemMeta from './LinkedItemMeta'
import { LinkedItemSearchResultsAddTagOption } from './LinkedItemSearchResultsAddTagOption'
import { Slot } from '@radix-ui/react-slot'
import Icon from '../Icon/Icon'
import { PremiumFeatureIconName } from '../Icon/PremiumFeatureIcon'
import { KeyboardKey } from '@standardnotes/ui-services'

type Props = {
linkingController: LinkingController
Expand All @@ -45,33 +37,13 @@ const ItemLinkAutocompleteInput = ({

const tagsLinkedToItem = getLinkedTagsForItem(item) || []

const [searchQuery, setSearchQuery] = useState('')
const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, item)

const [dropdownVisible, setDropdownVisible] = useState(false)
const [dropdownMaxHeight, setDropdownMaxHeight] = useState<number | 'auto'>('auto')
const combobox = useComboboxStore()
const value = combobox.useState('value')
const searchQuery = useDeferredValue(value)

const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const searchResultsMenuRef = useRef<HTMLMenuElement>(null)

const [closeOnBlur] = useCloseOnBlur(containerRef, (visible: boolean) => {
setDropdownVisible(visible)
setSearchQuery('')
})

const showDropdown = () => {
const { clientHeight } = document.documentElement
const inputRect = inputRef.current?.getBoundingClientRect()
if (inputRect) {
setDropdownMaxHeight(clientHeight - inputRect.bottom - 32 * 2)
setDropdownVisible(true)
}
}
const { unlinkedItems, shouldShowCreateTag } = getLinkingSearchResults(searchQuery, application, item)

const onSearchQueryChange: ChangeEventHandler<HTMLInputElement> = (event) => {
setSearchQuery(event.currentTarget.value)
}
const inputRef = useRef<HTMLInputElement | null>(null)

const onFormSubmit: FormEventHandler = async (event) => {
event.preventDefault()
Expand All @@ -84,11 +56,6 @@ const ItemLinkAutocompleteInput = ({
if (focusedId !== ElementIds.ItemLinkAutocompleteInput) {
setFocusedId(ElementIds.ItemLinkAutocompleteInput)
}
showDropdown()
}

const onBlur: FocusEventHandler = (event) => {
closeOnBlur(event)
}

const onKeyDown: KeyboardEventHandler = (event) => {
Expand All @@ -98,12 +65,6 @@ const ItemLinkAutocompleteInput = ({
focusPreviousItem()
}
break
case KeyboardKey.Down:
if (searchQuery.length > 0) {
event.preventDefault()
searchResultsMenuRef.current?.focus()
}
break
}
}

Expand All @@ -113,70 +74,63 @@ const ItemLinkAutocompleteInput = ({
}
}, [focusedId])

const areSearchResultsVisible = dropdownVisible && (unlinkedItems.length > 0 || shouldShowCreateTag)

const handleMenuKeyDown: KeyboardEventHandler<HTMLMenuElement> = useCallback((event) => {
if (event.key === KeyboardKey.Escape) {
inputRef.current?.focus()
}
}, [])

return (
<div ref={containerRef}>
<div>
<form onSubmit={onFormSubmit}>
<Disclosure open={dropdownVisible} onChange={showDropdown}>
<input
ref={inputRef}
<label>
<VisuallyHidden>Link tags, notes or files</VisuallyHidden>
<Combobox
store={combobox}
placeholder="Link tags, notes, files..."
className={classNames(
`${tagsLinkedToItem.length > 0 ? 'w-80' : 'mr-10 w-70'}`,
'bg-transparent text-sm text-text focus:border-b-2 focus:border-solid focus:border-info lg:text-xs',
'no-border h-7 focus:shadow-none focus:outline-none',
'h-7 w-70 bg-transparent text-sm text-text focus:border-b-2 focus:border-info focus:shadow-none focus:outline-none lg:text-xs',
)}
value={searchQuery}
onChange={onSearchQueryChange}
type="text"
placeholder="Link tags, notes, files..."
onBlur={onBlur}
title={hoverLabel}
id={ElementIds.ItemLinkAutocompleteInput}
ref={inputRef}
onFocus={handleFocus}
onKeyDown={onKeyDown}
id={ElementIds.ItemLinkAutocompleteInput}
autoComplete="off"
title={hoverLabel}
aria-label={hoverLabel}
/>
{areSearchResultsVisible && (
<DisclosurePanel
className={classNames(
tagsLinkedToItem.length > 0 ? 'w-80' : 'mr-10 w-70',
'absolute z-dropdown-menu flex flex-col overflow-y-auto rounded bg-default py-2 shadow-main',
)}
style={{
maxHeight: dropdownMaxHeight,
</label>
<ComboboxPopover
store={combobox}
className={classNames(
'z-dropdown-menu max-h-[var(--popover-available-height)] w-[var(--popover-anchor-width)] overflow-y-auto rounded bg-default py-2 shadow-main',
unlinkedItems.length === 0 && !shouldShowCreateTag && 'hidden',
)}
>
{unlinkedItems.map((result) => {
const cannotLinkItem = !isEntitledToNoteLinking && result instanceof SNNote

return (
<ComboboxItem
key={result.uuid}
className="flex w-full cursor-pointer items-center justify-between gap-4 overflow-hidden py-2 px-3 hover:bg-contrast hover:text-foreground [&[data-active-item]]:bg-info-backdrop"
hideOnClick
onClick={() => {
linkItems(item, result).catch(console.error)
combobox.setValue('')
}}
>
<LinkedItemMeta item={result} searchQuery={searchQuery} />
{cannotLinkItem && <Icon type={PremiumFeatureIconName} className="ml-auto flex-shrink-0 text-info" />}
</ComboboxItem>
)
})}
{shouldShowCreateTag && (
<ComboboxItem
hideOnClick
as={Slot}
onClick={() => {
void createAndAddNewTag(searchQuery)
combobox.setValue('')
}}
onBlur={closeOnBlur}
tabIndex={FOCUSABLE_BUT_NOT_TABBABLE}
>
<Menu
isOpen={areSearchResultsVisible}
a11yLabel="Unlinked items search results"
onKeyDown={handleMenuKeyDown}
ref={searchResultsMenuRef}
shouldAutoFocus={false}
>
<LinkedItemSearchResults
createAndAddNewTag={createAndAddNewTag}
linkItems={linkItems}
item={item}
results={unlinkedItems}
searchQuery={searchQuery}
shouldShowCreateTag={shouldShowCreateTag}
onClickCallback={() => setSearchQuery('')}
isEntitledToNoteLinking={isEntitledToNoteLinking}
/>
</Menu>
</DisclosurePanel>
<LinkedItemSearchResultsAddTagOption searchQuery={searchQuery} />
</ComboboxItem>
)}
</Disclosure>
</ComboboxPopover>
</form>
</div>
)
Expand Down