diff --git a/packages/web/components/elements/EditLabelChip.tsx b/packages/web/components/elements/EditLabelChip.tsx new file mode 100644 index 000000000..bf6fefcfc --- /dev/null +++ b/packages/web/components/elements/EditLabelChip.tsx @@ -0,0 +1,62 @@ +import { Button } from './Button' +import { SpanBox, HStack } from './LayoutPrimitives' +import { Circle, X } from 'phosphor-react' +import { isDarkTheme } from '../../lib/themeUpdater' +import { theme } from '../tokens/stitches.config' + +type EditLabelChipProps = { + text: string + color: string + isSelected?: boolean + xAction: () => void +} + +export function EditLabelLabelChip(props: EditLabelChipProps): JSX.Element { + const isDark = isDarkTheme() + + const selectedBorder = '#FFEA9F' + const unSelectedBorder = 'transparent' + return ( + + + + {props.text} + + + + ) +} diff --git a/packages/web/components/elements/LabelChip.tsx b/packages/web/components/elements/LabelChip.tsx index 96b76e9cf..29c29e0cb 100644 --- a/packages/web/components/elements/LabelChip.tsx +++ b/packages/web/components/elements/LabelChip.tsx @@ -2,38 +2,26 @@ import { getLuminance, lighten, parseToRgba, toHsla } from 'color2k' import { useRouter } from 'next/router' import { Button } from './Button' import { SpanBox, HStack } from './LayoutPrimitives' -import { Circle } from 'phosphor-react' +import { Circle, X } from 'phosphor-react' import { isDarkTheme } from '../../lib/themeUpdater' +import { theme } from '../tokens/stitches.config' type LabelChipProps = { text: string color: string // expected to be a RGB hex color string + isSelected?: boolean useAppAppearance?: boolean + xAction?: () => void } export function LabelChip(props: LabelChipProps): JSX.Element { const router = useRouter() const isDark = isDarkTheme() - const hexToRgb = (hex: string) => { - const bigint = parseInt(hex.substring(1), 16) - const r = (bigint >> 16) & 255 - const g = (bigint >> 8) & 255 - const b = bigint & 255 - - return [r, g, b] - } - - function f(x: number) { - const channel = x / 255 - return channel <= 0.03928 - ? channel / 12.92 - : Math.pow((channel + 0.055) / 1.055, 2.4) - } - const luminance = getLuminance(props.color) - const backgroundColor = hexToRgb(props.color) const textColor = luminance > 0.5 ? '#000000' : '#ffffff' + const selectedBorder = isDark ? '#FFEA9F' : 'black' + const unSelectedBorder = isDark ? '#6A6968' : '#D9D9D9' if (props.useAppAppearance) { return ( @@ -52,13 +40,34 @@ export function LabelChip(props: LabelChipProps): JSX.Element { borderWidth: '1px', borderStyle: 'solid', color: isDark ? '#EBEBEB' : '#2A2A2A', - borderColor: isDark ? '#6A6968' : '#D9D9D9', + borderColor: props.isSelected ? selectedBorder : unSelectedBorder, backgroundColor: isDark ? '#2A2A2A' : '#F5F5F5', }} > - + {props.text} + {props.xAction && ( + + )} ) diff --git a/packages/web/components/elements/LabelColorDropdown.tsx b/packages/web/components/elements/LabelColorDropdown.tsx index 763ee7289..28beae07f 100644 --- a/packages/web/components/elements/LabelColorDropdown.tsx +++ b/packages/web/components/elements/LabelColorDropdown.tsx @@ -1,9 +1,6 @@ -import React, { useState } from 'react' +import React, { useMemo, useRef, useState } from 'react' import { styled } from '../tokens/stitches.config' -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' -import { HexColorPicker } from 'react-colorful' -import { HStack, SpanBox } from './LayoutPrimitives' -import { CaretDown } from 'phosphor-react' +import { Box, HStack, SpanBox } from './LayoutPrimitives' import { StyledText } from './StyledText' import { LabelColorDropdownProps, @@ -11,41 +8,14 @@ import { LabelOptionProps, } from '../../utils/settings-page/labels/types' import { labelColorObjects } from '../../utils/settings-page/labels/labelColorObjects' -import { DropdownOption } from './DropdownElements' -import { isDarkTheme } from '../../lib/themeUpdater' import { LabelColor } from '../../lib/networking/fragments/labelFragment' +import { TwitterPicker } from 'react-color' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' const DropdownMenuContent = styled(DropdownMenuPrimitive.Content, { borderRadius: 6, backgroundColor: '$grayBg', padding: 5, - boxShadow: - '0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)', -}) - -const itemStyles = { - all: 'unset', - fontSize: '$3', - lineHeight: 1, - borderRadius: 3, - display: 'flex', - alignItems: 'center', - height: 25, - position: 'relative', - userSelect: 'none', -} - -const DropdownMenuTriggerItem = styled(DropdownMenuPrimitive.TriggerItem, { - '&[data-state="open"]': { - outline: 'none', - backgroundColor: '$grayBgHover', - }, - ...itemStyles, - padding: '$2 0', - '&:focus': { - outline: 'none', - backgroundColor: '$grayBgHover', - }, }) const DropdownMenu = DropdownMenuPrimitive.Root @@ -56,67 +26,26 @@ const DropdownMenuTrigger = styled(DropdownMenuPrimitive.Trigger, { padding: 0, marginRight: '$2', }) -const Box = styled('div', {}) - -const MainContainer = styled(Box, { - fontFamily: 'inter', - fontSize: '$2', - lineHeight: '1.25', - color: '$grayText', - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - backgroundColor: '$grayBg', - border: '1px solid $grayBorder', - width: '180px', - borderRadius: '$3', - px: '$3', - py: '0px', - cursor: 'pointer', - '&:hover': { - border: '1px solid $grayBorderHover', - }, - '@mdDown': { - width: '100%', - }, -}) - -const CustomLabelWrapper = styled(Box, { - fontSize: 13, - padding: 24, - borderRadius: 3, - cursor: 'default', - color: '$grayText', - - '&:focus': { - outline: 'none', - backgroundColor: '$grayBgHover', - }, -}) export const LabelColorDropdown = (props: LabelColorDropdownProps) => { - const { - isCreateMode, - canEdit, - labelColorHexRowId, - labelId, - labelColor, - labelColorHexValue, - setLabelColorHex, - } = props - - const isDarkMode = isDarkTheme() - const iconColor = isDarkMode ? '#FFFFFF' : '#0A0806' + const pickerRef = useRef(null) + const { isCreateMode, canEdit } = props + const [triangle, setTriangle] = useState< + 'top-left' | 'hide' | 'top-right' | undefined + >('top-left') const [open, setOpen] = useState(false) - const handleCustomColorChange = (color: string) => { - setLabelColorHex({ - rowId: labelId, - value: color.toUpperCase() as LabelColor, - }) - } - const handleOpen = (open: boolean) => { + if ( + pickerRef.current && + window.innerHeight - pickerRef.current?.getBoundingClientRect().bottom < + 116 + ) { + setTriangle('hide') + } else { + setTriangle('top-left') + } + if (canEdit && open) setOpen(true) else if (isCreateMode && !canEdit && open) setOpen(true) else setOpen(false) @@ -125,6 +54,7 @@ export const LabelColorDropdown = (props: LabelColorDropdownProps) => { return ( { minWidth: '170px', width: 'auto', }, + borderRadius: '6px', + outlineStyle: 'solid', + outlineColor: open ? '$omnivoreYellow' : 'transparent', }} > - - - {labelId !== '' && labelId === labelColorHexRowId ? ( - - ) : ( - <> - {labelId ? ( - - ) : ( - - )} - - )} - - - - + - - {Object.keys(labelColorObjects) - .filter((labelColor) => labelColor !== 'custom color') - .map((labelColor) => ( - - setLabelColorHex({ - rowId: labelId, - value: labelColor as LabelColor, - }) - } - > - - - ))} - - - null}> - - - - - - - + { + switch (event.key) { + case 'Escape': + setOpen(false) + event.preventDefault() + break + case 'Enter': + setOpen(false) + event.preventDefault() + break + } + }} + > + { + props.setLabelColor(color.hex.toUpperCase()) + event.preventDefault() + }} + onChangeComplete={(color, event) => { + props.setLabelColor(color.hex.toUpperCase()) + event.preventDefault() + }} + styles={{ + default: { + input: { + color: '$grayText', + }, + }, + }} + /> ) @@ -218,64 +117,31 @@ export const LabelColorDropdown = (props: LabelColorDropdownProps) => { function LabelOption(props: LabelOptionProps): JSX.Element { const { color, isDropdownOption, isCreateMode, labelId } = props - // const colorDetails = getColorDetails( - // color as LabelColor, - // labelId, - // Boolean(isCreateMode) - // ) - const isCreating = isCreateMode && !labelId - const { text, border, colorName, background } = getLabelColorObject( - color as LabelColor - ) - - let colorNameText = colorName - if (!labelId && isCreateMode) { - colorNameText = 'Select Color' - colorNameText = isDropdownOption ? colorName : colorNameText - } - - colorNameText = color === 'custom color' ? colorNameText : colorName - - let colorHex = !labelId && isCreateMode && !isDropdownOption ? '' : text - - colorHex = - !labelId && isCreateMode && !isDropdownOption && color !== 'custom color' - ? text - : colorHex - return ( - + - {colorNameText} - - - {colorNameText === 'custom color' ? '' : colorHex} + {props.color} - {isDropdownOption ? : null} ) } @@ -304,10 +169,7 @@ function getLabelColorObject(color: LabelColor) { return colorObject } -function LabelColorIcon(props: { - fillColor: string - strokeColor: string -}): JSX.Element { +function LabelColorIcon(props: { color: string }): JSX.Element { return ( ) diff --git a/packages/web/components/elements/LabelsPicker.tsx b/packages/web/components/elements/LabelsPicker.tsx new file mode 100644 index 000000000..c76924e1c --- /dev/null +++ b/packages/web/components/elements/LabelsPicker.tsx @@ -0,0 +1,205 @@ +import AutosizeInput from 'react-input-autosize' +import { Box, SpanBox } from './LayoutPrimitives' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Label } from '../../lib/networking/fragments/labelFragment' +import { useGetLabelsQuery } from '../../lib/networking/queries/useGetLabelsQuery' +import { LabelChip } from './LabelChip' +import { isTouchScreenDevice } from '../../lib/deviceType' +import { EditLabelLabelChip } from './EditLabelChip' + +type LabelsPickerProps = { + selectedLabels: Label[] + focused: boolean + + inputValue: string + setInputValue: (value: string) => void + clearInputState: () => void + + onFocus?: () => void + setSelectedLabels: (labels: Label[]) => void + + deleteLastLabel: () => void + selectOrCreateLabel: (value: string) => void + + tabCount: number + setTabCount: (count: number) => void + tabStartValue: string + setTabStartValue: (value: string) => void + + highlightLastLabel: boolean + setHighlightLastLabel: (set: boolean) => void +} + +export const LabelsPicker = (props: LabelsPickerProps): JSX.Element => { + const inputRef = useRef() + const availableLabels = useGetLabelsQuery() + + useEffect(() => { + if (!isTouchScreenDevice() && props.focused && inputRef.current) { + inputRef.current.focus() + } + }, [props.focused]) + + const autoComplete = useCallback(() => { + const lowerCasedValue = props.inputValue.toLowerCase() + + if (lowerCasedValue.length < 1) { + return + } + + let _tabCount = props.tabCount + let _tabStartValue = props.tabStartValue.toLowerCase() + + if (_tabCount === -1) { + _tabCount = 0 + _tabStartValue = lowerCasedValue + + props.setTabCount(0) + props.setTabStartValue(lowerCasedValue) + } else { + _tabCount = props.tabCount + 1 + props.setTabCount(_tabCount) + } + + const matches = availableLabels.labels.filter((l) => + l.name.toLowerCase().startsWith(_tabStartValue) + ) + + if (_tabCount < matches.length) { + props.setInputValue(matches[_tabCount].name) + } else if (matches.length > 0) { + props.setTabCount(0) + props.setInputValue(matches[0].name) + } + }, [props.inputValue, availableLabels, props.tabCount, props.tabStartValue]) + + const clearTabState = useCallback(() => { + props.setTabCount(-1) + props.setTabStartValue('') + }, []) + + const isEmpty = useMemo(() => { + return props.selectedLabels.length === 0 && props.inputValue.length === 0 + }, [props.inputValue, props.selectedLabels]) + + return ( + span': { + marginTop: '0px', + marginBottom: '0px', + }, + }} + onMouseDown={(event) => { + inputRef.current?.focus() + inputRef.current?.setSelectionRange( + inputRef.current?.value.length, + inputRef.current?.value.length + ) + event.preventDefault() + }} + onDoubleClick={(event) => { + inputRef.current?.focus() + inputRef.current?.setSelectionRange(0, inputRef.current?.value.length) + }} + > + {props.selectedLabels.map((label, idx) => ( + { + const idx = props.selectedLabels.findIndex((l) => l.id == label.id) + if (idx !== -1) { + const _selectedLabels = props.selectedLabels + _selectedLabels.splice(idx, 1) + props.setSelectedLabels([..._selectedLabels]) + } + }} + /> + ))} + + { + inputRef.current = ref + }} + onFocus={() => { + if (props.onFocus) { + props.onFocus() + } + }} + minWidth="2px" + maxLength={48} + value={props.inputValue} + onClick={(event) => { + event.stopPropagation() + }} + onKeyUp={(event) => { + switch (event.key) { + case 'Escape': + props.clearInputState() + break + case 'Enter': + props.selectOrCreateLabel(props.inputValue) + event.preventDefault() + break + } + }} + onKeyDown={(event) => { + switch (event.key) { + case 'Tab': + autoComplete() + event.preventDefault() + break + case 'Delete': + case 'Backspace': + clearTabState() + if (props.inputValue.length === 0) { + props.deleteLastLabel() + event.preventDefault() + } + break + } + }} + onChange={function (event) { + props.setInputValue(event.target.value) + }} + /> + + + ) +} diff --git a/packages/web/components/templates/article/ArticleActionsMenu.tsx b/packages/web/components/templates/article/ArticleActionsMenu.tsx index f399b9c51..402cb7d62 100644 --- a/packages/web/components/templates/article/ArticleActionsMenu.tsx +++ b/packages/web/components/templates/article/ArticleActionsMenu.tsx @@ -6,6 +6,7 @@ import { TagSimple, Trash, Tray, + Tag, } from 'phosphor-react' import { ArticleAttributes } from '../../../lib/networking/queries/useGetArticleQuery' import { Button } from '../../elements/Button' @@ -79,7 +80,7 @@ export function ArticleActionsMenu( tooltipSide={props.layout == 'side' ? 'right' : 'bottom'} > - @@ -97,10 +98,7 @@ export function ArticleActionsMenu( }, }} > - + )} @@ -116,7 +114,7 @@ export function ArticleActionsMenu( }, }} > - + + ) : ( + + )} ) } export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { const router = useRouter() - const [filterText, setFilterText] = useState('') const { labels, revalidate } = useGetLabelsQuery() useEffect(() => { setFocusedIndex(undefined) - }, [filterText]) + }, [props.inputValue]) const isSelected = useCallback( (label: Label): boolean => { @@ -273,6 +286,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { props.onLabelsUpdated(newSelectedLabels) } + props.clearInputState() revalidate() }, [isSelected, props, revalidate] @@ -284,33 +298,32 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { } return labels .filter((label) => { - return label.name.toLowerCase().includes(filterText.toLowerCase()) + return label.name.toLowerCase().includes(props.inputValue.toLowerCase()) }) .sort((left: Label, right: Label) => { return left.name.localeCompare(right.name) }) - }, [labels, filterText]) + }, [labels, props.inputValue]) // Move focus through the labels list on tab or arrow up/down keys const [focusedIndex, setFocusedIndex] = useState( 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 createLabelFromFilterText = useCallback( + async (text: string) => { + const label = await createLabelMutation(text, randomLabelColorHex(), '') + if (label) { + showSuccessToast(`Created label ${label.name}`, { + position: 'bottom-right', + }) + toggleLabel(label) + } else { + showErrorToast('Failed to create label', { position: 'bottom-right' }) + } + }, + [props.inputValue, toggleLabel] + ) const handleKeyDown = useCallback( async (event: React.KeyboardEvent) => { @@ -325,12 +338,12 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { } // If the `Create New label` button isn't visible we skip it // when navigating with the arrow keys - if (focusedIndex === maxIndex && !filterText) { + if (focusedIndex === maxIndex && !props.inputValue) { newIndex = maxIndex - 2 } setFocusedIndex(newIndex) } - if (event.key === 'ArrowDown' || event.key === 'Tab') { + if (event.key === 'ArrowDown') { event.preventDefault() let newIndex = focusedIndex if (focusedIndex === undefined) { @@ -340,7 +353,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { } // If the `Create New label` button isn't visible we skip it // when navigating with the arrow keys - if (focusedIndex === maxIndex - 2 && !filterText) { + if (focusedIndex === maxIndex - 2 && !props.inputValue) { newIndex = maxIndex } setFocusedIndex(newIndex) @@ -352,7 +365,9 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { return } if (focusedIndex === maxIndex - 1) { - await createLabelFromFilterText() + const _filterText = props.inputValue + props.setInputValue('') + await createLabelFromFilterText(_filterText) return } if (focusedIndex !== undefined) { @@ -364,7 +379,7 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element { } }, [ - filterText, + props.inputValue, filteredLabels, focusedIndex, createLabelFromFilterText, @@ -385,55 +400,45 @@ export function SetLabelsControl(props: SetLabelsControlProps): JSX.Element {
setFocusedIndex(undefined)} - setFilterText={setFilterText} - filterText={filterText} + inputValue={props.inputValue} + setInputValue={props.setInputValue} + selectedLabels={props.selectedLabels} + setSelectedLabels={props.setSelectedLabels} + 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} /> - {filteredLabels && - filteredLabels.map((label, idx) => ( - - ))} + {filteredLabels.map((label, idx) => ( + + ))} - {filterText && ( - - )} - -