diff --git a/packages/sortable-list/package.json b/packages/sortable-list/package.json index 3815133f..452244d2 100644 --- a/packages/sortable-list/package.json +++ b/packages/sortable-list/package.json @@ -27,8 +27,9 @@ "@dnd-kit/core": "^5.0.1", "@dnd-kit/modifiers": "^5.0.0", "@dnd-kit/sortable": "^6.0.0", + "antd": "^4.18.9", "classnames": "^2.3.1", "lodash.isequal": "^4.5.0", - "use-merge-value": "^1.0.2" + "zustand": "^3.7.1" } } diff --git a/packages/sortable-list/src/components/Action/index.less b/packages/sortable-list/src/components/Action/index.less index 9bca7574..3dcc0075 100644 --- a/packages/sortable-list/src/components/Action/index.less +++ b/packages/sortable-list/src/components/Action/index.less @@ -1,5 +1,3 @@ -@focused-outline-color: #4c9ffe; - .Action { display: flex; width: 12px; @@ -41,10 +39,4 @@ fill: var(--fill, #788491); } } - - &:focus-visible { - outline: none; - box-shadow: 0 0 0 2px rgba(255, 255, 255, 0), - 0 0px 0px 2px @focused-outline-color; - } } diff --git a/packages/sortable-list/src/BaseItem/index.less b/packages/sortable-list/src/components/BaseItem/index.less similarity index 95% rename from packages/sortable-list/src/BaseItem/index.less rename to packages/sortable-list/src/components/BaseItem/index.less index f9a66ca3..cdc87464 100644 --- a/packages/sortable-list/src/BaseItem/index.less +++ b/packages/sortable-list/src/components/BaseItem/index.less @@ -1,6 +1,5 @@ -@background-color: #fff; -@border-color: #efefef; -@handle-color: rgba(0, 0, 0, 0.25); +@import (reference) '~antd/es/style/themes/default.less'; + @box-shadow-border: 0 0 0 calc(1px / var(--scale-x, 1)) hsla(240, 0%, 26%, 0.05); @box-shadow-common: 0 1px calc(3px / var(--scale-x, 1)) hsla(240, 0%, 22%, 0.15); @box-shadow: @box-shadow-border, @box-shadow-common; @@ -54,7 +53,7 @@ flex-grow: 1; align-items: center; padding: 16px 24px; - background-color: @background-color; + background-color: @component-background; box-shadow: @box-shadow; outline: none; border-radius: 4px; diff --git a/packages/sortable-list/src/BaseItem/index.tsx b/packages/sortable-list/src/components/BaseItem/index.tsx similarity index 96% rename from packages/sortable-list/src/BaseItem/index.tsx rename to packages/sortable-list/src/components/BaseItem/index.tsx index b565c0f9..468cedac 100644 --- a/packages/sortable-list/src/BaseItem/index.tsx +++ b/packages/sortable-list/src/components/BaseItem/index.tsx @@ -2,9 +2,10 @@ import type { CSSProperties } from 'react'; import React, { forwardRef, memo, useEffect } from 'react'; import classNames from 'classnames'; -import { Handle, Remove } from '../components'; +import { Handle } from '../Handle'; +import { Remove } from '../Remove'; -import type { BaseItemProps } from '../types'; +import type { BaseItemProps } from '../../types'; import './index.less'; diff --git a/packages/sortable-list/src/DraggingOverlay.tsx b/packages/sortable-list/src/components/DraggingOverlay.tsx similarity index 90% rename from packages/sortable-list/src/DraggingOverlay.tsx rename to packages/sortable-list/src/components/DraggingOverlay.tsx index 4a7627b8..7a641846 100644 --- a/packages/sortable-list/src/DraggingOverlay.tsx +++ b/packages/sortable-list/src/components/DraggingOverlay.tsx @@ -3,7 +3,7 @@ import { DragOverlay } from '@dnd-kit/core'; import { Item } from './BaseItem'; import type { FC } from 'react'; -import type { DraggingOverlayProps } from './types'; +import type { DraggingOverlayProps } from '../types'; const DraggingOverlay: FC = ({ dragging, @@ -15,7 +15,6 @@ const DraggingOverlay: FC = ({ getItemStyles, getWrapperStyle, activeIndex, - activeId, }) => { return ( @@ -32,7 +31,7 @@ const DraggingOverlay: FC = ({ style={getItemStyles({ id: item.id, index: activeIndex, - isSorting: activeId !== null, + isSorting: true, isDragging: true, overIndex: -1, isDragOverlay: true, diff --git a/packages/sortable-list/src/SortableItem.tsx b/packages/sortable-list/src/components/SortableItem.tsx similarity index 94% rename from packages/sortable-list/src/SortableItem.tsx rename to packages/sortable-list/src/components/SortableItem.tsx index d7ecc507..eaea2f06 100644 --- a/packages/sortable-list/src/SortableItem.tsx +++ b/packages/sortable-list/src/components/SortableItem.tsx @@ -1,10 +1,11 @@ +/* istanbul ignore file */ import type { FC } from 'react'; import React from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { Item } from './BaseItem'; -import type { SortableItemProps } from './types'; +import type { SortableItemProps } from '../types'; const SortableItem: FC = ({ //数据 diff --git a/packages/sortable-list/src/components/index.ts b/packages/sortable-list/src/components/index.ts index fb2af46f..e9b33dd9 100644 --- a/packages/sortable-list/src/components/index.ts +++ b/packages/sortable-list/src/components/index.ts @@ -1,3 +1,4 @@ export { Action } from './Action'; export { Handle } from './Handle'; export { Remove } from './Remove'; +export { Item } from './BaseItem'; diff --git a/packages/sortable-list/src/Sortable.tsx b/packages/sortable-list/src/container/App.tsx similarity index 79% rename from packages/sortable-list/src/Sortable.tsx rename to packages/sortable-list/src/container/App.tsx index cd502884..61375f4a 100644 --- a/packages/sortable-list/src/Sortable.tsx +++ b/packages/sortable-list/src/container/App.tsx @@ -20,14 +20,28 @@ import { } from '@dnd-kit/sortable'; import { Flexbox } from '@arvinxu/layout-kit'; -import SortableItem from './SortableItem'; -import DraggingOverlay from './DraggingOverlay'; +import SortableItem from '../components/SortableItem'; +import DraggingOverlay from '../components/DraggingOverlay'; -import type { SortableProps } from './types'; +import type { SortableProps, SortableListStore } from '../types'; -import { useSortableList } from './hooks/useSortableList'; -import { useActiveItem } from './hooks/useActiveItem'; -import { getIndexOfActiveItem } from './utils'; +import { useStore } from '../store'; +import { getIndexOfActiveItem } from '../store/utils'; + +const dataSelector = (s: SortableListStore) => s.data; + +const activeSelector = (s: SortableListStore) => ({ + isDragging: s.activeId !== null, + activeIndex: getIndexOfActiveItem(s.data, s.activeId), +}); + +const actionSelector = (s: SortableListStore) => ({ + deactivateItem: s.deactivateItem, + activateItem: s.activateItem, + reorder: s.reorder, + removeItem: s.removeItem, + addItem: s.addItem, +}); const Wrapper: FC<{ style?: CSSProperties; @@ -44,9 +58,6 @@ const Wrapper: FC<{ ); export const Sortable: FC = ({ - //数据 - items: controlledItems, - onItemChange, // 方法 strategy, renderItem, @@ -85,24 +96,19 @@ export const Sortable: FC = ({ }), ); - const { items, dispatchSortable } = useSortableList({ - value: controlledItems, - onChange: onItemChange, - }); - - const { deactivateItem, activateItem, activeId, isDragging } = - useActiveItem(); - - const activeIndex = getIndexOfActiveItem(items, activeId); + const items = useStore(dataSelector); + const { activeIndex, isDragging } = useStore(activeSelector); + const { deactivateItem, activateItem, reorder, addItem, removeItem } = + useStore(actionSelector); const handleRemove = (id: string) => { if (!removable) return; - dispatchSortable({ type: 'removeItem', id }); + removeItem(id); }; const handleAddItem = (index: number, item: any) => { - dispatchSortable({ type: 'addItem', item, addIndex: index }); + addItem(item, index); }; return ( // 最外层的 DndContext @@ -118,11 +124,7 @@ export const Sortable: FC = ({ if (over) { const endIndex = getIndexOfActiveItem(items, over.id); - dispatchSortable({ - type: 'reorder', - startIndex: activeIndex, - endIndex, - }); + reorder(activeIndex, endIndex); } deactivateItem(); }} @@ -168,7 +170,6 @@ export const Sortable: FC = ({ adjustScale={adjustScale} dropAnimation={dropAnimation} dragging={isDragging} - activeId={activeId} activeIndex={activeIndex} item={items[activeIndex]} getItemStyles={getItemStyles} diff --git a/packages/sortable-list/src/container/StoreUpdater.tsx b/packages/sortable-list/src/container/StoreUpdater.tsx new file mode 100644 index 00000000..6a66a476 --- /dev/null +++ b/packages/sortable-list/src/container/StoreUpdater.tsx @@ -0,0 +1,10 @@ +import { useStoreUpdater } from '../hooks/useStoreUpdater'; +import type { StoreUpdaterProps } from '../types'; + +const StoreUpdater = (props: StoreUpdaterProps) => { + useStoreUpdater(props); + + return null; +}; + +export default StoreUpdater; diff --git a/packages/sortable-list/src/container/Wrapper.tsx b/packages/sortable-list/src/container/Wrapper.tsx new file mode 100644 index 00000000..411b70dc --- /dev/null +++ b/packages/sortable-list/src/container/Wrapper.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type { FC } from 'react'; + +import { Provider, createStore, useStoreApi } from '../store'; + +const StoreWrapper: FC = ({ children }) => { + let isWrapped = true; + + try { + useStoreApi(); + } catch (e) { + isWrapped = false; + } + + if (isWrapped) { + // we need to wrap it with a fragment because it's not allowed for children to be a ReactNode + // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051 + return <>{children}; + } + + return {children}; +}; + +export default StoreWrapper; diff --git a/packages/sortable-list/src/index.tsx b/packages/sortable-list/src/container/index.tsx similarity index 74% rename from packages/sortable-list/src/index.tsx rename to packages/sortable-list/src/container/index.tsx index 6219fbc1..b5d48fe6 100644 --- a/packages/sortable-list/src/index.tsx +++ b/packages/sortable-list/src/container/index.tsx @@ -14,8 +14,10 @@ import { restrictToWindowEdges, } from '@dnd-kit/modifiers'; -import { Sortable } from './Sortable'; -import type { SortableItemList, SortableListProps } from './types'; +import { Sortable } from './App'; +import type { SortableItemList, SortableListProps } from '../types'; +import StoreWrapper from './Wrapper'; +import StoreUpdater from './StoreUpdater'; const defaultDropAnimationConfig: DropAnimation = { ...defaultDropAnimation, @@ -75,26 +77,21 @@ function SortableList({ }, [direction, getModifiers]); return ( - + + + + ); } export default SortableList; - -export type { - SortableBaseItem, - SortableItemList, - SortableListProps, -} from './types'; diff --git a/packages/sortable-list/src/hooks/useActiveItem.ts b/packages/sortable-list/src/hooks/useActiveItem.ts deleted file mode 100644 index dc83e012..00000000 --- a/packages/sortable-list/src/hooks/useActiveItem.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useCallback, useState } from 'react'; - -export const useActiveItem = () => { - const [activeId, setActiveId] = useState(null); - - const activateItem = useCallback((id: string) => { - setActiveId(id); - }, []); - - const deactivateItem = useCallback(() => { - setActiveId(null); - }, []); - - const isDragging = activeId !== null; - - return { isDragging, activeId, activateItem, deactivateItem }; -}; diff --git a/packages/sortable-list/src/hooks/useSortableList.test.ts b/packages/sortable-list/src/hooks/useSortableList.test.ts deleted file mode 100644 index 3b568b03..00000000 --- a/packages/sortable-list/src/hooks/useSortableList.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { useState } from 'react'; -import { act, renderHook } from '@testing-library/react-hooks'; - -import { useSortableList } from './useSortableList'; - -import type { SortableItemList } from '../types'; - -describe('useSortableList', () => { - it('默认值', () => { - const { result } = renderHook(() => useSortableList()); - - expect(result.current.items).toEqual([]); - }); - it('外部传入初始值', () => { - const { result } = renderHook(() => - useSortableList({ defaultValue: [{ id: '123' }] }), - ); - expect(result.current.items).toEqual([{ id: '123' }]); - }); - - describe('updateTableStore', () => { - describe('添加item', () => { - it('基础添加', () => { - const { result } = renderHook(() => useSortableList()); - - act(() => { - result.current.dispatchSortable({ - type: 'addItem', - item: { id: '333' }, - }); - }); - - expect(result.current.items).toEqual([{ id: '333' }]); - }); - - it('携带索引的添加', () => { - const { result } = renderHook(() => - useSortableList({ defaultValue: [{ id: '1' }] }), - ); - - act(() => { - result.current.dispatchSortable({ - type: 'addItem', - item: { id: '2' }, - addIndex: 0, - }); - }); - - expect(result.current.items).toEqual([{ id: '2' }, { id: '1' }]); - }); - }); - it('删除item', () => { - const { result } = renderHook(() => - useSortableList({ - defaultValue: [{ id: '1' }, { id: '2' }, { id: '3' }], - }), - ); - - act(() => { - result.current.dispatchSortable({ - type: 'removeItem', - id: '2', - }); - }); - - expect(result.current.items).toEqual([{ id: '1' }, { id: '3' }]); - }); - it('重排序item', () => { - const { result } = renderHook(() => - useSortableList({ - defaultValue: [{ id: '1' }, { id: '2' }, { id: '3' }], - }), - ); - - act(() => { - result.current.dispatchSortable({ - type: 'reorder', - startIndex: 2, - endIndex: 0, - }); - }); - - expect(result.current.items).toEqual([ - { id: '3' }, - { id: '1' }, - { id: '2' }, - ]); - }); - }); - - describe('受控模式', () => { - it('没有 onChange 时不更改', () => { - const { result } = renderHook(() => { - const [value] = useState([{ id: '1' }]); - const { dispatchSortable } = useSortableList({ - value: [{ id: '1' }], - }); - return { value, dispatchSortable }; - }); - act(() => { - result.current.dispatchSortable({ - type: 'addItem', - item: { id: '333' }, - }); - }); - - expect(result.current.value).toEqual([{ id: '1' }]); - }); - - it('外部控制 value', () => { - const controlledValue: SortableItemList = [ - { id: '1' }, - { id: '2' }, - { id: '3' }, - ]; - - const { result } = renderHook(() => { - const [value, setConfig] = useState(); - const { items } = useSortableList({ - value, - }); - - return { items, setConfig }; - }); - - act(() => { - result.current.setConfig(controlledValue); - }); - - expect(result.current.items).toEqual([ - { id: '1' }, - { id: '2' }, - { id: '3' }, - ]); - }); - it('内部修改值 外部 value 更新', () => { - const { result } = renderHook(() => { - const [value, setConfig] = useState([ - { id: '1' }, - { id: '2' }, - { id: '3' }, - ]); - - const { dispatchSortable } = useSortableList({ - value, - onChange: setConfig, - }); - - return { value, dispatchSortable }; - }); - - expect(result.current.value).toEqual([ - { id: '1' }, - { id: '2' }, - { id: '3' }, - ]); - - act(() => { - result.current.dispatchSortable({ - type: 'reorder', - startIndex: 2, - endIndex: 0, - }); - }); - - expect(result.current.value).toEqual([ - { id: '3' }, - { id: '1' }, - { id: '2' }, - ]); - - act(() => { - result.current.dispatchSortable({ - type: 'reorder', - startIndex: 2, - endIndex: 0, - }); - }); - - expect(result.current.value).toEqual([ - { id: '2' }, - { id: '3' }, - { id: '1' }, - ]); - }); - }); -}); diff --git a/packages/sortable-list/src/hooks/useSortableList.ts b/packages/sortable-list/src/hooks/useSortableList.ts deleted file mode 100644 index 106d02b0..00000000 --- a/packages/sortable-list/src/hooks/useSortableList.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useEffect, useReducer } from 'react'; -import { arrayMove } from '@dnd-kit/sortable'; -import produce from 'immer'; -import isEqual from 'lodash.isequal'; - -import type { SortableItem, SortableItemList } from '../types'; - -interface SortSyncOutSource { - type: 'syncOutSource'; - state: SortableItem[]; -} -interface SortAddItem { - type: 'addItem'; - item: SortableItem; - addIndex?: number; -} -interface SortRemoveItem { - type: 'removeItem'; - id: string; -} -interface SortReorderItem { - type: 'reorder'; - endIndex: number; - startIndex: number; -} - -type ReducerDispatch = - | SortRemoveItem - | SortReorderItem - | SortAddItem - | SortSyncOutSource; - -export const useSortableList = ( - options: { - defaultValue?: SortableItemList; - value?: SortableItemList; - onChange?: (value: SortableItemList) => void; - } = {}, -) => { - const sortableReducer = ( - innerState: SortableItemList, - payload: ReducerDispatch, - ) => { - const state = options?.value ?? innerState; - - switch (payload.type) { - // 移除对象 - case 'syncOutSource': - if (isEqual(payload.state, innerState)) return innerState; - - return payload.state; - case 'removeItem': - return state.filter((item) => item.id !== payload.id); - // 重新排序 - case 'reorder': - if (payload.startIndex !== payload.endIndex) { - return arrayMove(state, payload.startIndex, payload.endIndex); - } - return state; - case 'addItem': { - if (typeof payload.addIndex !== 'number') - // 如果没有提供添加位的 index 值,默认添加在最后 - return [...state, payload.item]; - - return produce(state, (draft) => { - draft.splice(payload.addIndex, 0, payload.item); - }); - } - } - }; - - const [items, dispatchSortable] = useReducer( - sortableReducer, - options?.value ?? (options?.defaultValue || []), - ); - - useEffect(() => { - if (!options.onChange) return; - - options.onChange(items); - }, [items]); - - // 同步外部数据 - useEffect(() => { - if (!options.value) return; - - dispatchSortable({ - type: 'syncOutSource', - state: options.value, - }); - }, [options.value]); - - return { - items, - dispatchSortable, - }; -}; diff --git a/packages/sortable-list/src/hooks/useStoreUpdater.test.tsx b/packages/sortable-list/src/hooks/useStoreUpdater.test.tsx new file mode 100644 index 00000000..7ee22a61 --- /dev/null +++ b/packages/sortable-list/src/hooks/useStoreUpdater.test.tsx @@ -0,0 +1,137 @@ +import React, { useCallback, useState } from 'react'; +import { act, renderHook } from '@testing-library/react-hooks'; + +import { useStoreUpdater } from './useStoreUpdater'; +import type { SortableItemList } from '../types'; +import { createStore, Provider, useStore } from '../store'; + +const renderOptions = { + wrapper: ({ children }) => ( + {children} + ), +}; +describe('useStoreUpdater', () => { + it('外部控制初始值', () => { + const { result } = renderHook(() => { + useStoreUpdater({ defaultData: [{ id: '123' }] }); + + return useStore((s) => s.data); + }, renderOptions); + expect(result.current).toEqual([{ id: '123' }]); + }); + + it('外部设置值', () => { + const { result } = renderHook(() => { + useStoreUpdater({ data: [{ id: '123' }] }); + return useStore((s) => s.data); + }, renderOptions); + + expect(result.current).toEqual([{ id: '123' }]); + }); + + it('外部 setValue', () => { + const controlledValue: SortableItemList = [ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]; + + const { result } = renderHook(() => { + const [value, setConfig] = useState(); + useStoreUpdater({ + data: value, + }); + + const data = useStore((s) => s.data); + return { data, setConfig }; + }, renderOptions); + + act(() => { + result.current.setConfig(controlledValue); + }); + + expect(result.current.data).toEqual([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + }); + + it('没有 onChange 时不更改', () => { + const useNoOnChange = () => { + const [data] = useState([{ id: '1' }]); + useStoreUpdater({ data: data }); + + const store = useStore(); + return { store, data }; + }; + const { result } = renderHook(() => useNoOnChange(), renderOptions); + // + // + act(() => { + result.current.store.addItem({ id: '333' }); + }); + + expect(result.current.data).toEqual([{ id: '1' }]); + }); + + it('内部修改值 外部 value 更新', () => { + const { result } = renderHook(() => { + const [value, setConfig] = useState([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + + const func = useCallback((data) => { + console.log('xuga', data); + setConfig(data); + }, []); + + useStoreUpdater({ + data: value, + onDataChange: func, + }); + + const reorder = useStore((s) => s.reorder); + + return { value, reorder }; + }, renderOptions); + + expect(result.current.value).toEqual([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + + act(() => { + result.current.reorder(2, 0); + }); + + expect(result.current.value).toEqual([ + { id: '3' }, + { id: '1' }, + { id: '2' }, + ]); + + act(() => { + result.current.reorder(1, 0); + }); + + expect(result.current.value).toEqual([ + { id: '1' }, + { id: '3' }, + { id: '2' }, + ]); + + act(() => { + result.current.reorder(1, 1); + }); + + expect(result.current.value).toEqual([ + { id: '1' }, + { id: '3' }, + { id: '2' }, + ]); + }); +}); diff --git a/packages/sortable-list/src/hooks/useStoreUpdater.ts b/packages/sortable-list/src/hooks/useStoreUpdater.ts new file mode 100644 index 00000000..57f4c19f --- /dev/null +++ b/packages/sortable-list/src/hooks/useStoreUpdater.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import type { StoreUpdaterProps } from '../types'; +import { useStore } from '../store'; +import shallow from 'zustand/shallow'; + +export const useStoreUpdater = ({ + data, + defaultData, + onDataChange, +}: StoreUpdaterProps) => { + const { syncOnDataChange, syncOutsideData } = useStore( + (s) => ({ + syncOutsideData: s.syncOutsideData, + syncOnDataChange: s.syncOnDataChange, + }), + shallow, + ); + + useEffect(() => { + if (defaultData) { + syncOutsideData(defaultData); + } + }, []); + + useEffect(() => { + if (!data) return; + syncOutsideData(data); + }, [data]); + + useEffect(() => { + if (!onDataChange) return; + + syncOnDataChange(onDataChange); + }, [onDataChange]); +}; diff --git a/packages/sortable-list/src/index.ts b/packages/sortable-list/src/index.ts new file mode 100644 index 00000000..50d0fc32 --- /dev/null +++ b/packages/sortable-list/src/index.ts @@ -0,0 +1,9 @@ +import SortableList from './container'; + +export default SortableList; + +export type { + SortableBaseItem, + SortableItemList, + SortableListProps, +} from './types'; diff --git a/packages/sortable-list/src/store/index.test.ts b/packages/sortable-list/src/store/index.test.ts new file mode 100644 index 00000000..429ea897 --- /dev/null +++ b/packages/sortable-list/src/store/index.test.ts @@ -0,0 +1,104 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import { createStore } from './index'; + +const useStore = createStore(); +describe('useStore', () => { + it('默认值', () => { + const { result } = renderHook(() => useStore()); + + expect(result.current.data).toEqual([]); + }); + + describe('添加item', () => { + it('基础添加', () => { + const { result } = renderHook(() => useStore()); + + act(() => { + result.current.addItem({ id: '333' }); + }); + + expect(result.current.data).toEqual([{ id: '333' }]); + }); + + it('携带索引的添加', () => { + const { result } = renderHook(() => useStore()); + + act(() => { + result.current.internalUpdateData([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + + result.current.addItem({ id: '4' }, 0); + }); + + expect(result.current.data).toEqual([ + { id: '4' }, + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + }); + }); + it('删除item', () => { + const { result } = renderHook(() => useStore()); + + act(() => { + result.current.internalUpdateData([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + result.current.removeItem('2'); + }); + + expect(result.current.data).toEqual([{ id: '1' }, { id: '3' }]); + }); + it('重排序item', () => { + const { result } = renderHook(() => useStore()); + + act(() => { + result.current.internalUpdateData([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + result.current.reorder(2, 0); + }); + + expect(result.current.data).toEqual([ + { id: '3' }, + { id: '1' }, + { id: '2' }, + ]); + }); + + it('激活/取消激活 item', () => { + const { result } = renderHook(() => useStore()); + + expect(result.current.activeId).toBeNull(); + + // 设置 + act(() => { + result.current.internalUpdateData([ + { id: '1' }, + { id: '2' }, + { id: '3' }, + ]); + }); + // 激活 + act(() => { + result.current.activateItem('2'); + }); + expect(result.current.activeId).toEqual('2'); + + // 取消激活 + + act(() => { + result.current.deactivateItem(); + }); + expect(result.current.activeId).toEqual(null); + }); +}); diff --git a/packages/sortable-list/src/store/index.ts b/packages/sortable-list/src/store/index.ts new file mode 100644 index 00000000..6fbfccf1 --- /dev/null +++ b/packages/sortable-list/src/store/index.ts @@ -0,0 +1,76 @@ +import create from 'zustand'; +import createContext from 'zustand/context'; + +import { arrayMove } from '@dnd-kit/sortable'; +import isEqual from 'lodash.isequal'; +import produce from 'immer'; +import initialState from './initialState'; +import type { SortableListStore } from '../types'; +const createStore = () => + create((set, get) => ({ + // 内部值 + ...initialState, + activateItem: (id) => { + set({ activeId: id }); + }, + deactivateItem: () => { + set({ activeId: null }); + }, + // 内部更新 data 方法 + internalUpdateData: (data) => { + const { onDataChange } = get(); + set({ data }); + + if (onDataChange) { + onDataChange(data); + } + }, + + syncOnDataChange: (onDataChange) => { + set({ onDataChange }); + }, + + syncOutsideData: (data) => { + if (isEqual(get().data, data)) return; + + set({ data }); + }, + + // 重新排序 + reorder: (startIndex, endIndex) => { + const { data, internalUpdateData } = get(); + const nextData = produce(data, (state) => { + if (startIndex !== endIndex) { + return arrayMove(state, startIndex, endIndex); + } + + return state; + }); + + internalUpdateData(nextData); + }, + // 添加元素 + addItem: (item, addIndex) => { + const { data, internalUpdateData } = get(); + const nextData = produce(data, (state) => { + if (typeof addIndex !== 'number') { + // 如果没有提供添加位的 index 值,默认添加在最后 + state.push(item); + } else { + state.splice(addIndex, 0, item); + } + }); + + internalUpdateData(nextData); + }, + removeItem: (id) => { + const { data, internalUpdateData } = get(); + const nextData = data.filter((item) => item.id !== id); + + internalUpdateData(nextData); + }, + })); + +const { Provider, useStore, useStoreApi } = createContext(); + +export { Provider, useStore, createStore, useStoreApi }; diff --git a/packages/sortable-list/src/store/initialState.ts b/packages/sortable-list/src/store/initialState.ts new file mode 100644 index 00000000..2997ab51 --- /dev/null +++ b/packages/sortable-list/src/store/initialState.ts @@ -0,0 +1,9 @@ +import type { SortableListState } from '../types'; + +const initialState: SortableListState = { + data: [], + onDataChange: null, + activeId: null, +}; + +export default initialState; diff --git a/packages/sortable-list/src/store/utils.test.ts b/packages/sortable-list/src/store/utils.test.ts new file mode 100644 index 00000000..b62acc1d --- /dev/null +++ b/packages/sortable-list/src/store/utils.test.ts @@ -0,0 +1,15 @@ +import { getIndexOfActiveItem } from './utils'; + +const list = [{ id: '123' }, { id: '3245' }]; + +describe('getIndexOfActiveItem', () => { + it('找到 index', () => { + const index = getIndexOfActiveItem(list, '123'); + expect(index).toEqual(0); + }); + it('没找到 index', () => { + const index = getIndexOfActiveItem(list, '135'); + + expect(index).toEqual(-1); + }); +}); diff --git a/packages/sortable-list/src/utils.ts b/packages/sortable-list/src/store/utils.ts similarity index 78% rename from packages/sortable-list/src/utils.ts rename to packages/sortable-list/src/store/utils.ts index 8937f097..9834f574 100644 --- a/packages/sortable-list/src/utils.ts +++ b/packages/sortable-list/src/store/utils.ts @@ -1,4 +1,4 @@ -import type { SortableItemList } from './types'; +import type { SortableItemList } from '../types'; export const getIndexOfActiveItem = < T extends SortableItemList = SortableItemList, diff --git a/packages/sortable-list/src/types/common.ts b/packages/sortable-list/src/types/common.ts index f58b3d48..99357937 100644 --- a/packages/sortable-list/src/types/common.ts +++ b/packages/sortable-list/src/types/common.ts @@ -3,7 +3,7 @@ import type { UniqueIdentifier, } from '@dnd-kit/core'; import type { CSSProperties, ReactElement, Ref } from 'react'; -import type { SortableItem } from './data'; +import type { SortableItem } from './store'; export type RenderItem = ( item: T, diff --git a/packages/sortable-list/src/types/components.ts b/packages/sortable-list/src/types/components.ts index e7fa44e8..1bd7055c 100644 --- a/packages/sortable-list/src/types/components.ts +++ b/packages/sortable-list/src/types/components.ts @@ -14,7 +14,7 @@ import type { SortingStrategy } from '@dnd-kit/sortable'; import type { Transform } from '@dnd-kit/utilities'; import type { GetItemStyles, GetWrapperStyle, RenderItem } from './common'; -import type { SortableItem, SortableItemList } from './data'; +import type { SortableItem } from './store'; export interface BaseItemProps extends Pick< @@ -69,7 +69,6 @@ export interface DraggingOverlayProps dragging: boolean; item: SortableItem; activeIndex: number; - activeId: string; } export interface SortableProps { @@ -84,8 +83,6 @@ export interface SortableProps { dropAnimation?: DropAnimation; getNewIndex?: NewIndexGetter; handle?: boolean; - items?: SortableItemList; - onItemChange?: (item: SortableItemList) => void; measuring?: MeasuringConfiguration; modifiers?: Modifiers; /** diff --git a/packages/sortable-list/src/types/data.ts b/packages/sortable-list/src/types/data.ts deleted file mode 100644 index f46458e6..00000000 --- a/packages/sortable-list/src/types/data.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { UniqueIdentifier } from '@dnd-kit/core'; - -export interface SortableBaseItem { - id: UniqueIdentifier; -} - -export type SortableItem> = SortableBaseItem & T; - -export type SortableItemList> = SortableItem[]; diff --git a/packages/sortable-list/src/types/index.ts b/packages/sortable-list/src/types/index.ts index d3c74246..06989a50 100644 --- a/packages/sortable-list/src/types/index.ts +++ b/packages/sortable-list/src/types/index.ts @@ -1,2 +1,2 @@ export * from './components'; -export * from './data'; +export * from './store'; diff --git a/packages/sortable-list/src/types/store.ts b/packages/sortable-list/src/types/store.ts new file mode 100644 index 00000000..b761d037 --- /dev/null +++ b/packages/sortable-list/src/types/store.ts @@ -0,0 +1,51 @@ +import type { UniqueIdentifier } from '@dnd-kit/core'; + +/** + * 基础项 + */ +export interface SortableBaseItem { + id: UniqueIdentifier; +} + +export type SortableItem> = SortableBaseItem & T; + +export type SortableItemList> = SortableItem[]; + +/** + * 状态 + */ +export interface SortableListState { + data: SortableItemList; + onDataChange?: (data: SortableItemList) => void; + activeId: string; +} + +/** + * 动作 + */ +export interface SortableListAction { + syncOnDataChange: (onDataChange: OnDataChange) => void; + /** + * 同步外部数据源 + */ + syncOutsideData: (data: SortableItemList) => void; + internalUpdateData: (data: SortableItemList) => void; + removeItem: (id: string) => void; + reorder: (startIndex: number, endIndex: number) => void; + addItem: (item: SortableItem, addIndex?: number) => void; + + // 激活 + activateItem: (id: string) => void; + deactivateItem: () => void; +} + +export type SortableListStore = SortableListState & SortableListAction; + +export type OnDataChange = (data: SortableItemList) => void; + +// 外部值更新 +export interface StoreUpdaterProps { + data?: SortableItemList; + defaultData?: SortableItemList; + onDataChange?: OnDataChange; +} diff --git a/packages/sortable-list/tests/__snapshots__/index.test.tsx.snap b/packages/sortable-list/tests/__snapshots__/index.test.tsx.snap index 943f23fb..a2106d32 100644 --- a/packages/sortable-list/tests/__snapshots__/index.test.tsx.snap +++ b/packages/sortable-list/tests/__snapshots__/index.test.tsx.snap @@ -1,5 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`SortableList 有数据 1`] = ` +
+
+
  • +
    + 1 + + + +
    +
  • +
  • +
    + 2 + + + +
    +
  • +
    +
    +`; + exports[`SortableList 默认状态 1`] = `
    { const { container } = render(); expect(container).toMatchSnapshot(); }); + it('有数据', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); }); diff --git a/packages/sortable-list/webpack.config.js b/packages/sortable-list/webpack.config.js index b5e8bf52..7e99e10a 100644 --- a/packages/sortable-list/webpack.config.js +++ b/packages/sortable-list/webpack.config.js @@ -3,6 +3,10 @@ const config = require('../../webpack.config'); module.exports = { ...config, + entry: { + index: './src/index.ts', + 'index.min': './src/index.ts', + }, output: { ...config.output, library: 'SortableList', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8cdd8f95..3c1bf413 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,17 +293,19 @@ importers: '@dnd-kit/core': ^5.0.1 '@dnd-kit/modifiers': ^5.0.0 '@dnd-kit/sortable': ^6.0.0 + antd: ^4.18.9 classnames: ^2.3.1 lodash.isequal: ^4.5.0 - use-merge-value: ^1.0.2 + zustand: ^3.7.1 dependencies: '@arvinxu/layout-kit': link:../layout-kit '@dnd-kit/core': 5.0.1 '@dnd-kit/modifiers': 5.0.0_@dnd-kit+core@5.0.1 '@dnd-kit/sortable': 6.0.0_@dnd-kit+core@5.0.1 + antd: 4.18.9 classnames: 2.3.1 lodash.isequal: 4.5.0 - use-merge-value: 1.0.2 + zustand: 3.7.1 packages/tag-selector: specifiers: @@ -7923,6 +7925,56 @@ packages: rc-util: 5.18.1 scroll-into-view-if-needed: 2.2.29 + /antd/4.18.9: + resolution: {integrity: sha512-MbtFY2J8LvXUnxYH2QehdhP9qMEpHvOp7PmiTIHc7v6aSb+LILCibskRIMGNEKvvhBvsTdq0cnjanR9/IbqEAw==} + peerDependencies: + react: '>=16.9.0' + react-dom: '>=16.9.0' + dependencies: + '@ant-design/colors': 6.0.0 + '@ant-design/icons': 4.7.0 + '@ant-design/react-slick': 0.28.4 + '@babel/runtime': 7.17.2 + '@ctrl/tinycolor': 3.4.0 + classnames: 2.3.1 + copy-to-clipboard: 3.3.1 + lodash: 4.17.21 + memoize-one: 6.0.0 + moment: 2.29.1 + rc-cascader: 3.2.6 + rc-checkbox: 2.3.2 + rc-collapse: 3.1.2 + rc-dialog: 8.6.0 + rc-drawer: 4.4.3 + rc-dropdown: 3.2.5 + rc-field-form: 1.23.0 + rc-image: 5.2.5 + rc-input-number: 7.3.4 + rc-mentions: 1.6.2 + rc-menu: 9.2.1 + rc-motion: 2.4.5 + rc-notification: 4.5.7 + rc-pagination: 3.1.15 + rc-picker: 2.5.19 + rc-progress: 3.2.4 + rc-rate: 2.9.1 + rc-resize-observer: 1.2.0 + rc-select: 14.0.0-alpha.27 + rc-slider: 9.7.5 + rc-steps: 4.1.4 + rc-switch: 3.2.2 + rc-table: 7.23.0 + rc-tabs: 11.10.5 + rc-textarea: 0.3.7 + rc-tooltip: 5.1.1 + rc-tree: 5.4.3 + rc-tree-select: 5.1.3 + rc-trigger: 5.2.10 + rc-upload: 4.3.3 + rc-util: 5.18.1 + scroll-into-view-if-needed: 2.2.29 + dev: false + /anymatch/2.0.0: resolution: {integrity: sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==} dependencies: @@ -23901,6 +23953,16 @@ packages: babel-runtime: 6.26.0 dev: true + /zustand/3.7.1: + resolution: {integrity: sha512-wHBCZlKj+bg03/hP+Tzv24YhnqqP8MCeN9ECPDXoF01062SIbnfl3j9O0znkDw1lNTY0a8WN3F///a0UhhaEqg==} + engines: {node: '>=12.7.0'} + peerDependencies: + react: '>=16.8' + peerDependenciesMeta: + react: + optional: true + dev: false + /zwitch/1.0.5: resolution: {integrity: sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==} dev: true