omnivore/packages/web/components/templates/article/SetLabelsControl.tsx

521 lines
14 KiB
TypeScript
Raw Normal View History

2022-04-11 01:10:03 +00:00
import { useCallback, useRef, useState, useMemo, useEffect } from 'react'
2022-04-10 22:43:14 +00:00
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { styled, theme } from '../../tokens/stitches.config'
2022-04-11 01:10:03 +00:00
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
2023-06-20 05:36:36 +00:00
import { Check, Circle, Plus, WarningCircle } from 'phosphor-react'
2022-04-11 18:31:28 +00:00
import { createLabelMutation } from '../../../lib/networking/mutations/createLabelMutation'
import { showErrorToast, showSuccessToast } from '../../../lib/toastHelpers'
import { randomLabelColorHex } from '../../../utils/settings-page/labels/labelColorObjects'
import { useRouter } from 'next/router'
2023-06-16 04:51:21 +00:00
import { LabelsPicker } from '../../elements/LabelsPicker'
2023-06-20 05:36:36 +00:00
import { LabelsDispatcher } from '../../../lib/hooks/useSetPageLabels'
export interface LabelsProvider {
labels?: Label[]
}
2022-04-10 22:43:14 +00:00
type SetLabelsControlProps = {
inputValue: string
setInputValue: (value: string) => void
clearInputState: () => void
selectedLabels: Label[]
2023-06-20 05:36:36 +00:00
dispatchLabels: LabelsDispatcher
tabCount: number
setTabCount: (count: number) => void
tabStartValue: string
setTabStartValue: (value: string) => void
highlightLastLabel: boolean
setHighlightLastLabel: (set: boolean) => void
deleteLastLabel: () => void
selectOrCreateLabel: (value: string) => void
2023-06-20 02:19:40 +00:00
errorMessage?: string
2023-06-21 05:55:20 +00:00
footer?: React.ReactNode
2022-04-10 22:43:14 +00:00
}
2023-06-20 05:36:36 +00:00
type HeaderProps = SetLabelsControlProps & {
2022-04-10 22:43:14 +00:00
focused: boolean
resetFocusedIndex: () => void
2022-04-10 22:43:14 +00:00
}
const StyledLabel = styled('label', {
display: 'flex',
justifyContent: 'flex-start',
})
function Header(props: HeaderProps): JSX.Element {
return (
2023-06-16 04:51:21 +00:00
<VStack css={{ width: '100%', my: '0px' }}>
<Box
css={{
width: '100%',
2023-06-20 02:19:40 +00:00
mt: '10px',
mb: '5px',
px: '14px',
}}
>
2023-06-16 04:51:21 +00:00
<LabelsPicker
focused={props.focused}
inputValue={props.inputValue}
setInputValue={props.setInputValue}
2023-06-16 04:51:21 +00:00
selectedLabels={props.selectedLabels}
2023-06-20 05:36:36 +00:00
dispatchLabels={props.dispatchLabels}
tabCount={props.tabCount}
setTabCount={props.setTabCount}
tabStartValue={props.tabStartValue}
setTabStartValue={props.setTabStartValue}
highlightLastLabel={props.highlightLastLabel}
setHighlightLastLabel={props.setHighlightLastLabel}
onFocus={() => {
props.resetFocusedIndex()
}}
clearInputState={props.clearInputState}
deleteLastLabel={props.deleteLastLabel}
selectOrCreateLabel={props.selectOrCreateLabel}
2022-04-10 22:43:14 +00:00
/>
</Box>
</VStack>
)
2022-04-10 22:43:14 +00:00
}
type LabelListItemProps = {
label: Label
focused: boolean
selected: boolean
toggleLabel: (label: Label) => void
}
function LabelListItem(props: LabelListItemProps): JSX.Element {
const ref = useRef<HTMLLabelElement>(null)
const { label, focused, selected } = props
useEffect(() => {
if (props.focused && ref.current) {
ref.current.focus()
}
}, [props.focused])
return (
<StyledLabel
ref={ref}
css={{
width: '100%',
height: '42px',
2023-06-16 04:51:21 +00:00
p: '15px',
2022-04-10 22:43:14 +00:00
bg: props.focused ? '$grayBgActive' : 'unset',
2023-06-16 04:51:21 +00:00
'&:focus-visible': {
outline: 'none',
},
2022-04-10 22:43:14 +00:00
}}
tabIndex={props.focused ? 0 : -1}
onClick={(event) => {
event.preventDefault()
props.toggleLabel(label)
ref.current?.blur()
}}
>
<input
autoFocus={focused}
hidden={true}
type="checkbox"
checked={selected}
readOnly
/>
<Box
css={{
width: '30px',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<Circle width={22} height={22} color={label.color} weight="fill" />
2022-04-10 22:43:14 +00:00
</Box>
<Box
css={{
overflow: 'clip',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
2022-04-10 22:43:14 +00:00
<StyledText style="caption">{label.name}</StyledText>
</Box>
<Box
css={{
pl: '10px',
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
}}
>
{selected && (
2023-06-16 04:51:21 +00:00
<Check
size={15}
color={theme.colors.grayText.toString()}
weight="bold"
/>
)}
2022-04-10 22:43:14 +00:00
</Box>
</StyledLabel>
)
}
type FooterProps = {
focused: boolean
2023-06-16 04:51:21 +00:00
filterText: string
selectedLabels: Label[]
availableLabels: Label[]
createEnteredLabel: () => Promise<void>
selectEnteredLabel: () => Promise<void>
}
function Footer(props: FooterProps): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (props.focused && ref.current) {
ref.current.focus()
}
}, [props.focused])
const textMatch: 'selected' | 'available' | 'none' = useMemo(() => {
const findLabel = (l: Label) =>
l.name.toLowerCase() == props.filterText.toLowerCase()
const available = props.availableLabels.find(findLabel)
const selected = props.selectedLabels.find(findLabel)
if (available && !selected) {
return 'available'
}
if (selected) {
return 'selected'
}
return 'none'
}, [props])
2023-08-11 02:42:58 +00:00
const trimmedLabelName = useMemo(() => {
return props.filterText.trim()
}, [props])
return (
<HStack
ref={ref}
distribution="start"
alignment="center"
css={{
width: '100%',
height: '42px',
bg: props.focused ? '$grayBgActive' : 'unset',
2022-04-13 03:09:03 +00:00
color: theme.colors.grayText.toString(),
'a:link': {
textDecoration: 'none',
},
'a:visited': {
color: theme.colors.grayText.toString(),
},
}}
>
2023-08-11 02:42:58 +00:00
{trimmedLabelName.length > 0 ? (
2023-06-16 04:51:21 +00:00
<Button
style="modalOption"
css={{
pl: '26px',
position: 'relative',
color: theme.colors.grayText.toString(),
height: '42px',
borderBottom: '1px solid $grayBorder',
bg: props.focused ? '$grayBgActive' : 'unset',
}}
// onClick={createLabelFromFilterText}
>
<HStack
alignment="center"
distribution="start"
css={{ gap: '8px', fontSize: '12px', pointer: 'cursor' }}
2024-02-16 04:25:45 +00:00
onClick={async () => {
switch (textMatch) {
case 'available':
await props.selectEnteredLabel()
return
case 'none':
await props.createEnteredLabel()
return
}
}}
>
{textMatch === 'available' && (
<>
<Check size={18} color={theme.colors.grayText.toString()} />
2023-08-11 02:42:58 +00:00
Use Enter to add label &quot;{trimmedLabelName}&quot;
</>
)}
{textMatch === 'none' && (
<>
<Plus size={18} color={theme.colors.grayText.toString()} />
2023-08-11 02:42:58 +00:00
Use Enter to create new label &quot;{trimmedLabelName}&quot;
</>
)}
2023-06-16 04:51:21 +00:00
</HStack>
</Button>
) : (
<SpanBox
css={{
display: 'flex',
fontSize: '12px',
padding: '33px',
gap: '8px',
}}
2023-06-19 12:04:51 +00:00
></SpanBox>
2023-06-16 04:51:21 +00:00
)}
</HStack>
)
}
export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const router = useRouter()
2023-06-20 05:36:36 +00:00
const { inputValue, setInputValue, selectedLabels, setHighlightLastLabel } =
props
2022-04-11 20:58:38 +00:00
const { labels, revalidate } = useGetLabelsQuery()
// Move focus through the labels list on tab or arrow up/down keys
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(0)
2022-04-10 22:43:14 +00:00
2022-04-11 20:58:38 +00:00
useEffect(() => {
setFocusedIndex(undefined)
2023-06-20 05:36:36 +00:00
}, [inputValue])
2022-04-11 20:58:38 +00:00
const isSelected = useCallback(
(label: Label): boolean => {
2023-06-20 05:36:36 +00:00
return selectedLabels.some((other) => {
return other.id === label.id
2022-04-11 03:25:17 +00:00
})
},
2023-06-20 05:36:36 +00:00
[selectedLabels]
)
2022-04-11 03:25:17 +00:00
useEffect(() => {
if (focusedIndex === 0) {
2023-06-20 05:36:36 +00:00
setHighlightLastLabel(false)
}
2023-06-20 05:36:36 +00:00
}, [setHighlightLastLabel, focusedIndex])
const toggleLabel = useCallback(
async (label: Label) => {
let newSelectedLabels = [...props.selectedLabels]
if (isSelected(label)) {
newSelectedLabels = props.selectedLabels.filter((other) => {
return other.id !== label.id
})
} else {
newSelectedLabels = [...props.selectedLabels, label]
}
2023-06-20 05:36:36 +00:00
props.dispatchLabels({ type: 'SAVE', labels: newSelectedLabels })
2022-04-11 20:58:38 +00:00
props.clearInputState()
revalidate()
},
2023-02-27 03:12:29 +00:00
[isSelected, props, revalidate]
)
2022-04-10 22:43:14 +00:00
const filteredLabels = useMemo(() => {
if (!labels) {
return []
}
return labels
.filter((label) => {
2023-06-20 05:36:36 +00:00
return label.name.toLowerCase().includes(inputValue.toLowerCase())
})
.sort((left: Label, right: Label) => {
return left.name.localeCompare(right.name)
})
2023-06-20 05:36:36 +00:00
}, [labels, inputValue])
2022-04-10 22:43:14 +00:00
2023-06-16 04:51:21 +00:00
const createLabelFromFilterText = useCallback(
async (text: string) => {
2023-08-11 02:42:58 +00:00
const trimmedLabelName = text.trim()
const label = await createLabelMutation(
trimmedLabelName,
randomLabelColorHex(),
''
)
2023-06-16 04:51:21 +00:00
if (label) {
showSuccessToast(`Created label ${label.name}`, {
position: 'bottom-right',
})
toggleLabel(label)
} else {
showErrorToast('Failed to create label', { position: 'bottom-right' })
}
},
2023-06-20 05:36:36 +00:00
[toggleLabel]
2023-06-16 04:51:21 +00:00
)
2023-02-27 03:12:29 +00:00
const handleKeyDown = useCallback(
async (event: React.KeyboardEvent<HTMLInputElement>) => {
const maxIndex = filteredLabels.length + 1
if (event.key === 'ArrowUp') {
event.preventDefault()
let newIndex = focusedIndex
if (focusedIndex) {
newIndex = Math.max(0, focusedIndex - 1)
} else {
newIndex = undefined
}
// If the `Create New label` button isn't visible we skip it
// when navigating with the arrow keys
2023-06-20 05:36:36 +00:00
if (focusedIndex === maxIndex && !inputValue) {
newIndex = maxIndex - 2
}
setFocusedIndex(newIndex)
}
2023-06-16 04:51:21 +00:00
if (event.key === 'ArrowDown') {
event.preventDefault()
let newIndex = focusedIndex
if (focusedIndex === undefined) {
newIndex = 0
} else {
newIndex = Math.min(maxIndex, focusedIndex + 1)
}
// If the `Create New label` button isn't visible we skip it
// when navigating with the arrow keys
2023-06-20 05:36:36 +00:00
if (focusedIndex === maxIndex - 2 && !inputValue) {
newIndex = maxIndex
}
setFocusedIndex(newIndex)
2022-04-11 20:58:38 +00:00
}
if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex === maxIndex) {
2023-06-20 05:36:36 +00:00
const _filterText = inputValue
setInputValue('')
2023-06-16 04:51:21 +00:00
await createLabelFromFilterText(_filterText)
return
}
if (focusedIndex !== undefined) {
const label = filteredLabels[focusedIndex]
if (label) {
toggleLabel(label)
}
2022-04-10 22:43:14 +00:00
}
}
},
2023-02-27 03:12:29 +00:00
[
2023-06-20 05:36:36 +00:00
inputValue,
setInputValue,
2023-02-27 03:12:29 +00:00
filteredLabels,
focusedIndex,
createLabelFromFilterText,
toggleLabel,
]
)
2022-04-10 22:43:14 +00:00
const createEnteredLabel = useCallback(() => {
const _filterText = inputValue
setInputValue('')
return createLabelFromFilterText(_filterText)
2024-02-16 04:25:45 +00:00
}, [inputValue, setInputValue, createLabelFromFilterText])
const selectEnteredLabel = useCallback(() => {
const label = labels.find(
(l: Label) => l.name.toLowerCase() == inputValue.toLowerCase()
)
if (!label) {
return Promise.resolve()
}
return toggleLabel(label)
2024-02-16 04:25:45 +00:00
}, [labels, inputValue, toggleLabel])
2022-04-10 22:43:14 +00:00
return (
<VStack
distribution="start"
onKeyDown={handleKeyDown}
css={{
2022-04-10 22:43:14 +00:00
p: '0',
width: '100%',
}}
>
2022-04-10 22:43:14 +00:00
<Header
focused={focusedIndex === undefined}
resetFocusedIndex={() => setFocusedIndex(undefined)}
2023-06-20 05:36:36 +00:00
inputValue={inputValue}
setInputValue={setInputValue}
2023-06-16 04:51:21 +00:00
selectedLabels={props.selectedLabels}
2023-06-20 05:36:36 +00:00
dispatchLabels={props.dispatchLabels}
tabCount={props.tabCount}
setTabCount={props.setTabCount}
tabStartValue={props.tabStartValue}
setTabStartValue={props.setTabStartValue}
highlightLastLabel={props.highlightLastLabel}
setHighlightLastLabel={props.setHighlightLastLabel}
deleteLastLabel={props.deleteLastLabel}
selectOrCreateLabel={props.selectOrCreateLabel}
clearInputState={props.clearInputState}
2022-04-10 22:43:14 +00:00
/>
2023-06-20 02:19:40 +00:00
<Box
css={{
width: '100%',
height: '15px',
color: '#FF3B30',
fontSize: '12px',
fontFamily: '$inter',
gap: '5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'end',
paddingRight: '15px',
m: '0px',
}}
>
{props.errorMessage && (
<>
{props.errorMessage}
<WarningCircle color="#FF3B30" size={15} />
</>
)}
</Box>
<VStack
distribution="start"
alignment="start"
css={{
2023-06-16 04:51:21 +00:00
mt: '10px',
flexGrow: '1',
width: '100%',
2023-06-16 04:51:21 +00:00
height: '200px',
overflowY: 'scroll',
}}
>
2023-06-16 04:51:21 +00:00
{filteredLabels.map((label, idx) => (
<LabelListItem
key={label.id}
label={label}
focused={idx === focusedIndex}
selected={isSelected(label)}
toggleLabel={toggleLabel}
/>
))}
2022-04-10 22:43:14 +00:00
</VStack>
2023-06-21 05:55:20 +00:00
{props.footer ? (
props.footer
) : (
<Footer
filterText={inputValue}
selectedLabels={props.selectedLabels}
availableLabels={labels}
2023-06-21 05:55:20 +00:00
focused={focusedIndex === filteredLabels.length + 1}
createEnteredLabel={createEnteredLabel}
selectEnteredLabel={selectEnteredLabel}
2023-06-21 05:55:20 +00:00
/>
)}
2022-04-10 22:43:14 +00:00
</VStack>
)
}