diff --git a/browser/CHANGELOG.md b/browser/CHANGELOG.md index c1a3ba062..c4214d0c5 100644 --- a/browser/CHANGELOG.md +++ b/browser/CHANGELOG.md @@ -8,6 +8,7 @@ This changelog covers all three packages, as they are (for now) updated as a who - [#841](https://github.com/atomicdata-dev/atomic-server/issues/841) Add better inputs for `Timestamp` and `Date` datatypes. - [#842](https://github.com/atomicdata-dev/atomic-server/issues/842) Add media picker for properties with classtype file. +- [#850](https://github.com/atomicdata-dev/atomic-server/issues/850) Add drag & drop sorting to ResourceArray inputs. ## v0.37.0 diff --git a/browser/data-browser/src/components/forms/InputResourceArray.tsx b/browser/data-browser/src/components/forms/InputResourceArray.tsx index f03b754d1..cfcea521e 100644 --- a/browser/data-browser/src/components/forms/InputResourceArray.tsx +++ b/browser/data-browser/src/components/forms/InputResourceArray.tsx @@ -3,11 +3,21 @@ import { ArrayError, useArray, validateDatatype } from '@tomic/react'; import { Button } from '../Button'; import { InputProps } from './ResourceField'; import { ErrMessage } from './InputStyles'; -import { ResourceSelector } from './ResourceSelector'; -import { FaPlus, FaTrash } from 'react-icons/fa'; +import { ResourceSelector, ResourceSelectorProps } from './ResourceSelector'; import { Column, Row } from '../Row'; import { styled } from 'styled-components'; import { useIndexDependantCallback } from '../../hooks/useIndexDependantCallback'; +import { + DndContext, + DragEndEvent, + DragOverlay, + useDraggable, + useDroppable, +} from '@dnd-kit/core'; +import { transition } from '../../helpers/transition'; +import { FaGripVertical, FaPlus, FaTrash } from 'react-icons/fa6'; +import { createPortal } from 'react-dom'; +import { transparentize } from 'polished'; interface InputResourceArrayProps extends InputProps { isA?: string; @@ -20,22 +30,18 @@ export default function InputResourceArray({ ...props }: InputResourceArrayProps): JSX.Element { const [err, setErr] = useState(undefined); + const [draggingSubject, setDraggingSubject] = useState(); const [array, setArray] = useArray(resource, property.subject, { validate: false, commit, }); - /** Add focus to the last added item */ - const [lastIsNew, setLastIsNew] = useState(false); - function handleAddRow() { setArray([...array, undefined]); - setLastIsNew(true); } function handleClear() { setArray([]); - setLastIsNew(false); } const handleRemoveRowList = useIndexDependantCallback( @@ -57,7 +63,6 @@ export default function InputResourceArray({ try { validateDatatype(newArray, property.datatype); setArray(newArray); - setLastIsNew(false); setErr(undefined); } catch (e) { setErr(e); @@ -68,6 +73,21 @@ export default function InputResourceArray({ [property.datatype, setArray], ); + const handleDragEnd = ({ active, over }: DragEndEvent) => { + setDraggingSubject(undefined); + + if (!over) { + return; + } + + const oldPos = array.indexOf(active.id as string); + const newPos = over.id as number; + const newArray = [...array]; + const [removed] = newArray.splice(oldPos, 1); + newArray.splice(newPos > oldPos ? newPos - 1 : newPos, 0, removed); + setArray(newArray); + }; + const errMaybe = useCallback( (index: number) => { if (err && err.index === index) { @@ -82,21 +102,55 @@ export default function InputResourceArray({ return ( {array.length > 0 && ( -
- {array.map((subject, index) => ( - - ))} -
+ setDraggingSubject(event.active.id as string)} + onDragCancel={() => setDraggingSubject(undefined)} + onDragEnd={handleDragEnd} + > + + + {array.map((subject, index) => ( + <> + + {!(subject === undefined && index === array.length - 1) && ( + + )} + + ))} + {createPortal( + + {!!draggingSubject && ( + undefined} + isA={property.classType} + handleRemove={() => undefined} + hideClearButton + parent={resource.getSubject()} + {...props} + /> + )} + , + document.body, + )} + + )} {!props.disabled && ( @@ -129,6 +183,127 @@ export default function InputResourceArray({ ); } +interface DropEdgeProps { + index: number; + visible: boolean; +} + +const DropEdge = ({ index, visible }: DropEdgeProps) => { + const { setNodeRef, isOver } = useDroppable({ + id: index, + }); + + return ; +}; + +type DraggableResourceSelectorProps = ResourceSelectorProps & { + subject: string; +}; + +const DraggableResourceSelector = ({ + subject, + ...props +}: DraggableResourceSelectorProps) => { + const { attributes, listeners, setNodeRef, active } = useDraggable({ + id: subject, + disabled: props.disabled, + }); + + if (subject === undefined) { + return ; + } + + return ( + + + + + ) : null + } + /> + + ); +}; + +const DummySelector = (props: ResourceSelectorProps) => { + return ( + + + + + } + /> + + ); +}; + +const StyledDragOverlay = styled(DragOverlay)` + --search-box-bg: ${p => transparentize(0.5, p.theme.colors.bg)}; + backdrop-filter: blur(3px); +`; + +const RelativeContainer = styled.div` + position: relative; +`; + +const DragHandle = styled.button` + display: flex; + align-items: center; + cursor: grab; + appearance: none; + background: transparent; + border: none; + &:active { + cursor: grabbing; + svg { + color: ${p => p.theme.colors.textLight}; + } + } + + svg { + color: ${p => p.theme.colors.textLight2}; + } +`; + +const DragWrapper = styled(Row)<{ active: boolean }>` + position: relative; + opacity: ${p => (p.active ? 0.4 : 1)}; + width: 100%; + + &:hover { + ${DragHandle} svg { + color: ${p => p.theme.colors.textLight}; + } + } +`; + const StyledButton = styled(Button)` align-self: flex-start; `; + +const DropEdgeElement = styled.div<{ visible: boolean; active: boolean }>` + display: ${p => (p.visible ? 'block' : 'none')}; + position: absolute; + height: 3px; + border-radius: 1.5px; + transform: scaleX(${p => (p.active ? 1.1 : 1)}); + background: ${p => p.theme.colors.main}; + opacity: ${p => (p.active ? 1 : 0)}; + z-index: 2; + width: 100%; + + ${transition('opacity', 'transform')} +`; diff --git a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx index 3b87d39bc..ec8566179 100644 --- a/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx +++ b/browser/data-browser/src/components/forms/ResourceSelector/ResourceSelector.tsx @@ -2,15 +2,15 @@ import { useState, useMemo, memo } from 'react'; import { Dialog, useDialog } from '../../Dialog'; import { useDialogTreeContext } from '../../Dialog/dialogContext'; import { useSettings } from '../../../helpers/AppSettings'; -import { styled } from 'styled-components'; +import { css, styled } from 'styled-components'; import { NewFormDialog } from '../NewForm/NewFormDialog'; import { SearchBox } from '../SearchBox'; -import { SearchBoxButton } from '../SearchBox/SearchBox'; import { FaTrash } from 'react-icons/fa'; import { ErrorChip } from '../ErrorChip'; import { urls } from '@tomic/react'; +import { SearchBoxButton } from '../SearchBox/SearchBoxButton'; -interface ResourceSelectorProps { +export interface ResourceSelectorProps { /** * This callback is called when the Subject Changes. You can pass an Error * Handler as the second argument to set an error message. Take the second @@ -34,6 +34,15 @@ interface ResourceSelectorProps { /** Is used when a new item is created using the ResourceSelector */ parent?: string; hideCreateOption?: boolean; + hideClearButton?: boolean; + + /** If true, this is the first item in a list, default=true*/ + first?: boolean; + /** If true, this is the last item in a list, default=true*/ + last?: boolean; + + /** Some react node that is displayed in front of the text inside the input wrapper*/ + prefix?: React.ReactNode; } /** @@ -49,7 +58,11 @@ export const ResourceSelector = memo(function ResourceSelector({ isA, disabled, parent, + hideClearButton, hideCreateOption, + first = true, + last = true, + prefix, }: ResourceSelectorProps): JSX.Element { const [dialogProps, showDialog, closeDialog, isDialogOpen] = useDialog(); const [initialNewTitle, setInitialNewTitle] = useState(''); @@ -69,13 +82,15 @@ export const ResourceSelector = memo(function ResourceSelector({ }, [hideCreateOption, setSubject, showDialog, isA]); return ( - + {handleRemove && !disabled && ( @@ -107,26 +122,28 @@ export const ResourceSelector = memo(function ResourceSelector({ // We need Wrapper to be able to target this component. const StyledSearchBox = styled(SearchBox)``; -const Wrapper = styled.div` +const Wrapper = styled.div<{ first?: boolean; last?: boolean }>` + --top-radius: ${p => (p.first ? p.theme.radius : 0)}; + --bottom-radius: ${p => (p.last ? p.theme.radius : 0)}; + flex: 1; + max-width: 100%; position: relative; - --radius: ${props => props.theme.radius}; ${StyledSearchBox} { border-radius: 0; } - &:first-of-type ${StyledSearchBox} { - border-top-left-radius: var(--radius); - border-top-right-radius: var(--radius); - } - - &:last-of-type ${StyledSearchBox} { - border-bottom-left-radius: var(--radius); - border-bottom-right-radius: var(--radius); - } + & ${StyledSearchBox} { + border-top-left-radius: var(--top-radius); + border-top-right-radius: var(--top-radius); + border-bottom-left-radius: var(--bottom-radius); + border-bottom-right-radius: var(--bottom-radius); - &:not(:last-of-type) ${StyledSearchBox} { - border-bottom: none; + ${p => + !p.last && + css` + border-bottom: none; + `} } `; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx index db4977ead..c3c90d3c8 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBox.tsx @@ -16,6 +16,7 @@ import { ErrorChip } from '../ErrorChip'; import { useValidation } from '../formValidation/useValidation'; import { constructOpenURL } from '../../../helpers/navigation'; import { useNavigateWithTransition } from '../../../hooks/useNavigateWithTransition'; +import { SearchBoxButton } from './SearchBoxButton'; interface SearchBoxProps { autoFocus?: boolean; @@ -26,6 +27,8 @@ interface SearchBoxProps { disabled?: boolean; required?: boolean; className?: string; + prefix?: React.ReactNode; + hideClearButton?: boolean; onChange: (value: string | undefined) => void; onCreateItem?: (name: string) => void; onClose?: () => void; @@ -41,6 +44,8 @@ export function SearchBox({ required, className, children, + prefix, + hideClearButton, onChange, onCreateItem, onClose, @@ -144,6 +149,7 @@ export function SearchBox({ className={className} invalid={!!error} > + {prefix} {value && ( <> + {!disabled && !hideClearButton && ( + onChange(undefined)} + type='button' + > + + + )} - {!disabled && ( - onChange(undefined)} - type='button' - > - - - )} )} {children} @@ -220,7 +227,7 @@ const TriggerButton = styled.button<{ $empty: boolean }>` align-items: center; padding: 0.5rem; border-radius: ${props => props.theme.radius}; - background-color: ${props => props.theme.colors.bg}; + background: transparent; border: none; text-align: start; height: 2rem; @@ -229,10 +236,6 @@ const TriggerButton = styled.button<{ $empty: boolean }>` overflow: hidden; cursor: text; color: ${p => (p.$empty ? p.theme.colors.textLight : p.theme.colors.text)}; - - &:disabled { - background-color: ${props => props.theme.colors.bg1}; - } `; const TriggerButtonWrapper = styled.div<{ @@ -243,12 +246,21 @@ const TriggerButtonWrapper = styled.div<{ p.invalid ? p.theme.colors.alert : p.theme.colors.main}; display: flex; position: relative; + overflow: hidden; border: 1px solid ${props => props.theme.colors.bg2}; border-radius: ${props => props.theme.radius}; + background-color: var(--search-box-bg, ${props => props.theme.colors.bg}); + &:has(:disabled) { + background-color: ${props => props.theme.colors.bg1}; + } + + &:has(${TriggerButton}:hover(), ${TriggerButton}:focus-visible) { + } &:hover, - &:focus-within { - border-color: ${p => - p.disabled ? 'none' : 'var(--search-box-hightlight)'}; + &:focus-visible { + border-color: transparent; + box-shadow: 0 0 0 2px var(--search-box-hightlight); + z-index: 1000; } `; @@ -263,29 +275,6 @@ const PlaceholderText = styled.span` color: ${p => p.theme.colors.textLight}; `; -export const SearchBoxButton = styled.button` - background-color: ${p => p.theme.colors.bg}; - border: none; - border-left: 1px solid ${p => p.theme.colors.bg2}; - display: flex; - align-items: center; - padding: 0.5rem; - color: ${p => p.theme.colors.textLight}; - cursor: pointer; - - &:hover, - &:focus-visible { - color: var(--search-box-hightlight); - background-color: ${p => p.theme.colors.bg1}; - border-color: var(--search-box-hightlight); - } - - &:last-of-type { - border-top-right-radius: ${p => p.theme.radius}; - border-bottom-right-radius: ${p => p.theme.radius}; - } -`; - const PositionedErrorChip = styled(ErrorChip)` position: absolute; top: 2rem; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBoxButton.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBoxButton.tsx new file mode 100644 index 000000000..40526b7d2 --- /dev/null +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBoxButton.tsx @@ -0,0 +1,25 @@ +import { styled } from 'styled-components'; + +export const SearchBoxButton = styled.button<{ ephimeral?: boolean }>` + background-color: transparent; + border: none; + border-left: ${p => + p.ephimeral ? 'none' : '1px solid ' + p.theme.colors.bg2}; + display: flex; + align-items: center; + padding: 0.5rem; + color: ${p => p.theme.colors.textLight}; + cursor: pointer; + + &:hover, + &:focus-visible { + color: var(--search-box-hightlight); + background-color: ${p => p.theme.colors.bg1}; + border-color: var(--search-box-hightlight); + } + + visibility: ${p => (p.ephimeral ? 'hidden' : 'visible')}; + div:hover > & { + visibility: visible; + } +`; diff --git a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx index d944c150d..965ba2574 100644 --- a/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx +++ b/browser/data-browser/src/components/forms/SearchBox/SearchBoxWindow.tsx @@ -237,6 +237,7 @@ const SearchInputWrapper = styled.div` } &:focus-within { border-color: ${p => p.theme.colors.main}; + box-shadow: 0 0 0 1px ${p => p.theme.colors.main}; outline: none; } `;