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

440 lines
11 KiB
TypeScript

import { useCallback, useRef, useState, useMemo, useEffect } from 'react'
import Link from 'next/link'
import { Box, HStack, SpanBox, VStack } from '../../elements/LayoutPrimitives'
import { Button } from '../../elements/Button'
import { StyledText } from '../../elements/StyledText'
import { CrossIcon } from '../../elements/images/CrossIcon'
import { styled, theme } from '../../tokens/stitches.config'
import { Label } from '../../../lib/networking/fragments/labelFragment'
import { useGetLabelsQuery } from '../../../lib/networking/queries/useGetLabelsQuery'
import { Check, Circle, PencilSimple, Plus } from 'phosphor-react'
import { isTouchScreenDevice } from '../../../lib/deviceType'
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'
export interface LabelsProvider {
labels?: Label[]
}
type SetLabelsControlProps = {
provider: LabelsProvider
selectedLabels: Label[]
setSelectedLabels: (labels: Label[]) => void
onLabelsUpdated?: (labels: Label[]) => void
}
type HeaderProps = {
filterText: string
focused: boolean
resetFocusedIndex: () => void
setFilterText: (text: string) => void
}
const FormInput = styled('input', {
width: '100%',
fontSize: '16px',
fontFamily: 'inter',
fontWeight: 'normal',
lineHeight: '1.8',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
},
})
const StyledLabel = styled('label', {
display: 'flex',
justifyContent: 'flex-start',
})
function Header(props: HeaderProps): JSX.Element {
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isTouchScreenDevice() && props.focused && inputRef.current) {
inputRef.current.focus()
}
}, [props.focused])
return (
<VStack
css={{ width: '100%', my: '0px', borderBottom: '1px solid $grayBorder' }}
>
<Box
css={{
width: '100%',
my: '14px',
px: '14px',
}}
>
<FormInput
ref={inputRef}
type="text"
tabIndex={props.focused && !isTouchScreenDevice() ? 0 : -1}
autoFocus={!isTouchScreenDevice()}
value={props.filterText}
placeholder="Filter for label"
onChange={(event) => {
props.setFilterText(event.target.value)
}}
onFocus={() => {
props.resetFocusedIndex()
}}
css={{
border: '1px solid $grayBorder',
borderRadius: '8px',
width: '100%',
bg: 'transparent',
fontSize: '16px',
textIndent: '8px',
marginBottom: '2px',
color: '$grayTextContrast',
'&:focus': {
outline: 'none',
boxShadow: '0px 0px 2px 2px rgba(255, 234, 159, 0.56)',
},
}}
/>
</Box>
</VStack>
)
}
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',
borderBottom: '1px solid $grayBorder',
bg: props.focused ? '$grayBgActive' : 'unset',
}}
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={{
pl: '10px',
width: '32px',
display: 'flex',
alignItems: 'center',
}}
>
{selected && (
<Check
size={15}
color={theme.colors.grayText.toString()}
weight="bold"
/>
)}
</Box>
<Box
css={{
width: '30px',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<Circle width={22} height={22} color={label.color} weight="fill" />
</Box>
<Box
css={{
overflow: 'clip',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<StyledText style="caption">{label.name}</StyledText>
</Box>
<Box
css={{
pl: '10px',
width: '40px',
marginLeft: 'auto',
display: 'flex',
alignItems: 'center',
}}
>
{selected && (
<CrossIcon size={14} strokeColor={theme.colors.grayText.toString()} />
)}
</Box>
</StyledLabel>
)
}
type FooterProps = {
focused: boolean
}
function Footer(props: FooterProps): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (props.focused && ref.current) {
ref.current.focus()
}
}, [props.focused])
return (
<HStack
ref={ref}
distribution="start"
alignment="center"
css={{
width: '100%',
height: '42px',
bg: props.focused ? '$grayBgActive' : 'unset',
color: theme.colors.grayText.toString(),
'a:link': {
textDecoration: 'none',
},
'a:visited': {
color: theme.colors.grayText.toString(),
},
}}
>
<SpanBox
css={{ display: 'flex', fontSize: '12px', padding: '33px', gap: '8px' }}
>
<PencilSimple size={18} color={theme.colors.grayText.toString()} />
<Link href="/settings/labels">Edit labels</Link>
</SpanBox>
</HStack>
)
}
export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
const router = useRouter()
const [filterText, setFilterText] = useState('')
const { labels, revalidate } = useGetLabelsQuery()
useEffect(() => {
setFocusedIndex(undefined)
}, [filterText])
const isSelected = useCallback(
(label: Label): boolean => {
return props.selectedLabels.some((other) => {
return other.id === label.id
})
},
[props.selectedLabels]
)
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]
}
props.setSelectedLabels(newSelectedLabels)
props.provider.labels = newSelectedLabels
if (props.onLabelsUpdated) {
props.onLabelsUpdated(newSelectedLabels)
}
revalidate()
},
[isSelected, props, revalidate]
)
const filteredLabels = useMemo(() => {
if (!labels) {
return []
}
return labels
.filter((label) => {
return label.name.toLowerCase().includes(filterText.toLowerCase())
})
.sort((left: Label, right: Label) => {
return left.name.localeCompare(right.name)
})
}, [labels, filterText])
// Move focus through the labels list on tab or arrow up/down keys
const [focusedIndex, setFocusedIndex] = useState<number | undefined>(
undefined
)
const createLabelFromFilterText = useCallback(async () => {
const label = await createLabelMutation(
filterText,
randomLabelColorHex(),
''
)
if (label) {
showSuccessToast(`Created label ${label.name}`, {
position: 'bottom-right',
})
toggleLabel(label)
} else {
showErrorToast('Failed to create label', { position: 'bottom-right' })
}
}, [filterText, toggleLabel])
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
if (focusedIndex === maxIndex && !filterText) {
newIndex = maxIndex - 2
}
setFocusedIndex(newIndex)
}
if (event.key === 'ArrowDown' || event.key === 'Tab') {
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
if (focusedIndex === maxIndex - 2 && !filterText) {
newIndex = maxIndex
}
setFocusedIndex(newIndex)
}
if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex === maxIndex) {
router.push('/settings/labels')
return
}
if (focusedIndex === maxIndex - 1) {
await createLabelFromFilterText()
return
}
if (focusedIndex !== undefined) {
const label = filteredLabels[focusedIndex]
if (label) {
toggleLabel(label)
}
}
}
},
[
filterText,
filteredLabels,
focusedIndex,
createLabelFromFilterText,
router,
toggleLabel,
]
)
return (
<VStack
distribution="start"
onKeyDown={handleKeyDown}
css={{
p: '0',
width: '100%',
}}
>
<Header
focused={focusedIndex === undefined}
resetFocusedIndex={() => setFocusedIndex(undefined)}
setFilterText={setFilterText}
filterText={filterText}
/>
<VStack
distribution="start"
alignment="start"
css={{
flexGrow: '1',
width: '100%',
height: '294px',
overflowY: 'scroll',
}}
>
{filteredLabels &&
filteredLabels.map((label, idx) => (
<LabelListItem
key={label.id}
label={label}
focused={idx === focusedIndex}
selected={isSelected(label)}
toggleLabel={toggleLabel}
/>
))}
</VStack>
{filterText && (
<Button
style="modalOption"
css={{
pl: '26px',
color: theme.colors.grayText.toString(),
height: '42px',
borderBottom: '1px solid $grayBorder',
bg:
focusedIndex === filteredLabels.length
? '$grayBgActive'
: 'unset',
}}
onClick={createLabelFromFilterText}
>
<HStack alignment="center" distribution="start" css={{ gap: '8px' }}>
<Plus size={18} color={theme.colors.grayText.toString()} />
<SpanBox
css={{ fontSize: '12px' }}
>{`Create new label "${filterText}"`}</SpanBox>
</HStack>
</Button>
)}
<Footer focused={focusedIndex === filteredLabels.length + 1} />
</VStack>
)
}