From 2f53edca72e4ab3c117f08daca03f04178d50996 Mon Sep 17 00:00:00 2001 From: xiejay97 Date: Thu, 23 Dec 2021 19:11:19 +0800 Subject: [PATCH] feat(ui): add `form` component --- .eslintrc.json | 14 +- .../src/app/components/route/RouteArticle.tsx | 45 +- packages/site/src/app/configs/icons.json | 12 + .../src/app/routes/components/Interface.md | 8 +- .../routes/components/Interface.zh-Hant.md | 8 +- packages/ui/src/components/_dialog/Dialog.tsx | 117 +-- packages/ui/src/components/_dialog/Mask.tsx | 42 +- packages/ui/src/components/_popup/Popup.tsx | 199 ++--- .../src/components/_select-box/SelectBox.tsx | 14 +- packages/ui/src/components/affix/README.md | 2 +- .../ui/src/components/affix/README.zh-Hant.md | 2 +- packages/ui/src/components/anchor/Anchor.tsx | 83 +- .../ui/src/components/anchor/AnchorLink.tsx | 19 +- packages/ui/src/components/button/Button.tsx | 90 ++- .../ui/src/components/button/ButtonGroup.tsx | 31 +- packages/ui/src/components/button/README.md | 2 +- .../src/components/button/README.zh-Hant.md | 2 +- .../ui/src/components/compose/Compose.tsx | 26 +- .../ui/src/components/compose/ComposeItem.tsx | 9 +- packages/ui/src/components/compose/index.ts | 1 - packages/ui/src/components/compose/utils.ts | 11 - packages/ui/src/components/drag-drop/Drag.tsx | 30 +- packages/ui/src/components/drag-drop/Drop.tsx | 72 +- .../ui/src/components/drag-drop/README.md | 2 +- .../components/drag-drop/README.zh-Hant.md | 2 +- packages/ui/src/components/drawer/Drawer.tsx | 175 +++-- .../ui/src/components/dropdown/Dropdown.tsx | 28 +- .../{DropdownCroup.tsx => DropdownGroup.tsx} | 0 .../src/components/dropdown/DropdownSub.tsx | 60 +- packages/ui/src/components/dropdown/index.ts | 2 +- packages/ui/src/components/form/Error.tsx | 47 ++ packages/ui/src/components/form/Form.tsx | 137 ++++ packages/ui/src/components/form/FormGroup.tsx | 39 + packages/ui/src/components/form/FormItem.tsx | 388 +++++++++- packages/ui/src/components/form/README.md | 278 +++++++ .../ui/src/components/form/README.zh-Hant.md | 272 +++++++ .../ui/src/components/form/demos/1.Basic.md | 45 ++ .../src/components/form/demos/10.FormGroup.md | 54 ++ .../ui/src/components/form/demos/11.Size.md | 80 ++ .../form/demos/12.DynamicallySetting.md | 131 ++++ .../form/demos/13.SupportComponents.md | 99 +++ .../ui/src/components/form/demos/2.Layout.md | 71 ++ .../form/demos/3.IrregularLayout.md | 67 ++ .../form/demos/4.ResponsiveLayout.md | 67 ++ .../components/form/demos/5.CustomLabel.md | 60 ++ .../components/form/demos/6.ValidityResult.md | 75 ++ .../components/form/demos/7.FeedbackIcon.md | 77 ++ .../form/demos/8.AsynchronousVerification.md | 58 ++ .../src/components/form/demos/9.Disabled.md | 84 +++ packages/ui/src/components/form/form.ts | 709 ++++++++++++++++++ packages/ui/src/components/form/hooks.ts | 35 + packages/ui/src/components/form/index.ts | 5 +- packages/ui/src/components/form/interface.ts | 3 - packages/ui/src/components/grid/Col.tsx | 11 +- packages/ui/src/components/grid/README.md | 6 +- .../ui/src/components/grid/README.zh-Hant.md | 6 +- packages/ui/src/components/grid/Row.tsx | 10 +- .../grid/demos/4.ResponsiveLayout.md | 5 +- .../src/components/grid/demos/5.FreePlay.md | 32 +- packages/ui/src/components/index.ts | 3 + packages/ui/src/components/input/Input.tsx | 65 +- .../ui/src/components/input/InputAffix.tsx | 273 ++++--- packages/ui/src/components/input/README.md | 4 +- .../ui/src/components/input/README.zh-Hant.md | 4 +- packages/ui/src/components/menu/Menu.tsx | 140 ++-- packages/ui/src/components/menu/MenuItem.tsx | 15 +- packages/ui/src/components/menu/MenuSub.tsx | 259 ++++--- .../ui/src/components/menu/demos/1.Basic.md | 2 +- packages/ui/src/components/menu/utils.ts | 6 + .../src/components/pagination/Pagination.tsx | 4 +- .../ui/src/components/pagination/README.md | 2 +- .../components/pagination/README.zh-Hant.md | 2 +- .../pagination/demos/2.PageSizeOptions.md | 2 +- packages/ui/src/components/radio/README.md | 8 +- .../ui/src/components/radio/README.zh-Hant.md | 8 +- packages/ui/src/components/radio/Radio.tsx | 50 +- .../ui/src/components/radio/RadioGroup.tsx | 37 +- packages/ui/src/components/select/README.md | 6 +- .../src/components/select/README.zh-Hant.md | 6 +- packages/ui/src/components/select/Select.tsx | 41 +- packages/ui/src/components/tabs/Tab.tsx | 10 +- packages/ui/src/components/tabs/Tabs.tsx | 288 +++---- packages/ui/src/components/tag/Tag.tsx | 7 +- packages/ui/src/components/textarea/README.md | 4 +- .../src/components/textarea/README.zh-Hant.md | 4 +- .../ui/src/components/textarea/Textarea.tsx | 31 +- packages/ui/src/components/tooltip/README.md | 2 +- .../src/components/tooltip/README.zh-Hant.md | 2 +- .../src/components/virtual-scroll/README.md | 4 +- .../virtual-scroll/README.zh-Hant.md | 4 +- packages/ui/src/hooks/context.ts | 6 +- packages/ui/src/hooks/general-state.ts | 13 + packages/ui/src/hooks/i18n/i18n.ts | 6 +- packages/ui/src/hooks/i18n/resources.json | 6 + packages/ui/src/hooks/index.ts | 7 +- packages/ui/src/hooks/max-index.ts | 36 + packages/ui/src/hooks/notification.ts | 48 ++ packages/ui/src/hooks/ref.ts | 8 +- packages/ui/src/hooks/state-backflow.ts | 47 ++ .../_transition => hooks/transition}/index.ts | 0 .../transition/transition.ts} | 34 +- .../_transition => hooks/transition}/utils.ts | 0 packages/ui/src/hooks/two-way-binding.ts | 108 ++- packages/ui/src/styles/_components.scss | 1 + packages/ui/src/styles/_mixins.scss | 2 +- .../ui/src/styles/components/_button.scss | 2 - packages/ui/src/styles/components/_form.scss | 291 +++++++ packages/ui/src/styles/components/_icon.scss | 2 + packages/ui/src/styles/components/_input.scss | 4 + .../ui/src/styles/components/_select-box.scss | 2 + .../ui/src/styles/components/_textarea.scss | 2 + packages/ui/src/styles/mixins/_compose.scss | 17 - packages/ui/src/styles/mixins/_form.scss | 33 + packages/ui/src/utils/index.ts | 1 - packages/ui/src/utils/max-index.ts | 29 - 115 files changed, 4811 insertions(+), 1246 deletions(-) delete mode 100644 packages/ui/src/components/compose/utils.ts rename packages/ui/src/components/dropdown/{DropdownCroup.tsx => DropdownGroup.tsx} (100%) create mode 100644 packages/ui/src/components/form/Error.tsx create mode 100644 packages/ui/src/components/form/Form.tsx create mode 100644 packages/ui/src/components/form/FormGroup.tsx create mode 100644 packages/ui/src/components/form/README.md create mode 100644 packages/ui/src/components/form/README.zh-Hant.md create mode 100644 packages/ui/src/components/form/demos/1.Basic.md create mode 100644 packages/ui/src/components/form/demos/10.FormGroup.md create mode 100644 packages/ui/src/components/form/demos/11.Size.md create mode 100644 packages/ui/src/components/form/demos/12.DynamicallySetting.md create mode 100644 packages/ui/src/components/form/demos/13.SupportComponents.md create mode 100644 packages/ui/src/components/form/demos/2.Layout.md create mode 100644 packages/ui/src/components/form/demos/3.IrregularLayout.md create mode 100644 packages/ui/src/components/form/demos/4.ResponsiveLayout.md create mode 100644 packages/ui/src/components/form/demos/5.CustomLabel.md create mode 100644 packages/ui/src/components/form/demos/6.ValidityResult.md create mode 100644 packages/ui/src/components/form/demos/7.FeedbackIcon.md create mode 100644 packages/ui/src/components/form/demos/8.AsynchronousVerification.md create mode 100644 packages/ui/src/components/form/demos/9.Disabled.md create mode 100644 packages/ui/src/components/form/form.ts create mode 100644 packages/ui/src/components/form/hooks.ts delete mode 100644 packages/ui/src/components/form/interface.ts create mode 100644 packages/ui/src/hooks/general-state.ts create mode 100644 packages/ui/src/hooks/max-index.ts create mode 100644 packages/ui/src/hooks/notification.ts create mode 100644 packages/ui/src/hooks/state-backflow.ts rename packages/ui/src/{components/_transition => hooks/transition}/index.ts (100%) rename packages/ui/src/{components/_transition/transition.tsx => hooks/transition/transition.ts} (87%) rename packages/ui/src/{components/_transition => hooks/transition}/utils.ts (100%) create mode 100644 packages/ui/src/styles/components/_form.scss delete mode 100644 packages/ui/src/styles/mixins/_compose.scss create mode 100644 packages/ui/src/styles/mixins/_form.scss delete mode 100644 packages/ui/src/utils/max-index.ts diff --git a/.eslintrc.json b/.eslintrc.json index de39d809..3e30ba34 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -9,7 +9,7 @@ "processor": "markdown/markdown" }, { - "files": ["*.md", "*.ts", "*.tsx", "*.js", "*.jsx"], + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], "rules": { "@nrwl/nx/enforce-module-boundaries": [ "error", @@ -47,7 +47,7 @@ } }, { - "files": ["*.md", "*.ts", "*.tsx"], + "files": ["*.ts", "*.tsx"], "extends": ["plugin:@nrwl/nx/typescript"], "rules": { "no-unreachable": "error", @@ -116,9 +116,17 @@ } }, { - "files": ["*.md", "*.js", "*.jsx"], + "files": ["*.js", "*.jsx"], "extends": ["plugin:@nrwl/nx/javascript"], "rules": {} + }, + { + "files": ["**/*.md/*.ts", "**/*.md/*.tsx"], + "rules": { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-explicit-any": "off" + } } ] } diff --git a/packages/site/src/app/components/route/RouteArticle.tsx b/packages/site/src/app/components/route/RouteArticle.tsx index 57c762d9..4e84a482 100644 --- a/packages/site/src/app/components/route/RouteArticle.tsx +++ b/packages/site/src/app/components/route/RouteArticle.tsx @@ -2,8 +2,7 @@ import { isString, isUndefined } from 'lodash'; import { useEffect, useState } from 'react'; import { DIcon, DAnchor, DAnchorLink, DRow } from '@react-devui/ui'; -import { DTransition } from '@react-devui/ui/components/_transition'; -import { useImmer, useRefCallback } from '@react-devui/ui/hooks'; +import { useImmer, useDTransition, useRefCallback } from '@react-devui/ui/hooks'; import './RouteArticle.scss'; import marked, { toString } from './utils'; @@ -79,6 +78,15 @@ m -673.67664,1221.6502 -231.2455,-231.24803 55.6165, } }, [html]); + const hidden = useDTransition({ + dEl: el, + dVisible: menuOpen, + dCallbackList: { + beforeEnter: () => transitionState, + beforeLeave: () => transitionState, + }, + }); + return ( - transitionState, - beforeLeave: () => transitionState, - }} - dRender={(hidden) => - links.length > 0 && ( - - ) - } - > - + {links.length > 0 && ( + + )}
setMenuOpen(!menuOpen)}> {icon(true)} {icon(false)} diff --git a/packages/site/src/app/configs/icons.json b/packages/site/src/app/configs/icons.json index 959507ee..eb395dda 100644 --- a/packages/site/src/app/configs/icons.json +++ b/packages/site/src/app/configs/icons.json @@ -120,5 +120,17 @@ ] } ] + }, + { + "name": "minus", + "list": [ + { + "viewBox": "64 64 896 896", + "paths": [ + "M696 480H328c-4.4 0-8 3.6-8 8v48c0 4.4 3.6 8 8 8h368c4.4 0 8-3.6 8-8v-48c0-4.4-3.6-8-8-8z", + "M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z" + ] + } + ] } ] diff --git a/packages/site/src/app/routes/components/Interface.md b/packages/site/src/app/routes/components/Interface.md index f827ce88..762643e5 100644 --- a/packages/site/src/app/routes/components/Interface.md +++ b/packages/site/src/app/routes/components/Interface.md @@ -3,7 +3,7 @@ ## DElementSelector ```tsx -export type DElementSelector = HTMLElement | null | string | (() => HTMLElement | null); +type DElementSelector = HTMLElement | null | string | (() => HTMLElement | null); ``` ## DPopupProps @@ -36,7 +36,7 @@ Extend `React.HTMLAttributes`. ## DPopupRef ```tsx -export interface DPopupRef { +interface DPopupRef { el: HTMLDivElement | null; triggerEl: HTMLElement | null; updatePosition: () => void; @@ -46,7 +46,7 @@ export interface DPopupRef { ## DTriggerRenderProps ```tsx -export interface DTriggerRenderProps { +interface DTriggerRenderProps { [key: `data-${string}popup-trigger`]: string; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; @@ -59,7 +59,7 @@ export interface DTriggerRenderProps { ## DTransitionStateList ```tsx -export interface DTransitionStateList { +interface DTransitionStateList { 'enter-from'?: Partial; 'enter-active'?: Partial; 'enter-to'?: Partial; diff --git a/packages/site/src/app/routes/components/Interface.zh-Hant.md b/packages/site/src/app/routes/components/Interface.zh-Hant.md index ce204aa9..4c3aaf78 100644 --- a/packages/site/src/app/routes/components/Interface.zh-Hant.md +++ b/packages/site/src/app/routes/components/Interface.zh-Hant.md @@ -3,7 +3,7 @@ ## DElementSelector ```tsx -export type DElementSelector = HTMLElement | null | string | (() => HTMLElement | null); +type DElementSelector = HTMLElement | null | string | (() => HTMLElement | null); ``` ## DPopupProps @@ -36,7 +36,7 @@ export type DElementSelector = HTMLElement | null | string | (() => HTMLElement ## DPopupRef ```tsx -export interface DPopupRef { +interface DPopupRef { el: HTMLDivElement | null; triggerEl: HTMLElement | null; updatePosition: () => void; @@ -46,7 +46,7 @@ export interface DPopupRef { ## DTriggerRenderProps ```tsx -export interface DTriggerRenderProps { +interface DTriggerRenderProps { [key: `data-${string}popup-trigger`]: string; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; @@ -59,7 +59,7 @@ export interface DTriggerRenderProps { ## DTransitionStateList ```tsx -export interface DTransitionStateList { +interface DTransitionStateList { 'enter-from'?: Partial; 'enter-active'?: Partial; 'enter-to'?: Partial; diff --git a/packages/ui/src/components/_dialog/Dialog.tsx b/packages/ui/src/components/_dialog/Dialog.tsx index dc946c56..026852da 100644 --- a/packages/ui/src/components/_dialog/Dialog.tsx +++ b/packages/ui/src/components/_dialog/Dialog.tsx @@ -1,39 +1,34 @@ -import type { DTransitionProps } from '../_transition'; +import { useEffect, useCallback } from 'react'; -import React, { useId, useEffect, useCallback, useRef, useImperativeHandle } from 'react'; - -import { usePrefixConfig, useAsync, useRefCallback } from '../../hooks'; +import { usePrefixConfig, useAsync } from '../../hooks'; import { getClassName, mergeStyle } from '../../utils'; -import { DTransition } from '../_transition'; import { DMask } from './Mask'; -export interface DDialogRef { - uniqueId: string; - el: HTMLDivElement | null; - contentEl: HTMLDivElement | null; -} - export interface DDialogProps extends React.HTMLAttributes { + dId: string; dVisible: boolean; - dCallbackList?: NonNullable; + dHidden: boolean; dContentProps?: React.HTMLAttributes; dMask?: boolean; dMaskClosable?: boolean; dDestroy?: boolean; + dDialogRef?: React.LegacyRef; + dDialogContentRef?: React.LegacyRef; onClose?: () => void; - afterVisibleChange?: (visible: boolean) => void; } -const Dialog: React.ForwardRefRenderFunction = (props, ref) => { +export function DDialog(props: DDialogProps) { const { + dId, dVisible, - dCallbackList, + dHidden, dContentProps, dMask = true, dMaskClosable = true, dDestroy = false, + dDialogRef, + dDialogContentRef, onClose, - afterVisibleChange, className, style, children, @@ -44,17 +39,7 @@ const Dialog: React.ForwardRefRenderFunction = (props, const dPrefix = usePrefixConfig(); //#endregion - //#region Ref - const [dialogEl, dialogRef] = useRefCallback(); - const [dialogContentEl, dialogContentRef] = useRefCallback(); - //#endregion - - const dataRef = useRef<{ preActiveEl: HTMLElement | null }>({ - preActiveEl: null, - }); - const asyncCapture = useAsync(); - const uniqueId = useId(); const handleMaskClose = useCallback(() => { if (dMaskClosable) { @@ -74,65 +59,31 @@ const Dialog: React.ForwardRefRenderFunction = (props, }, [asyncCapture, dVisible, onClose]); //#endregion - useImperativeHandle( - ref, - () => ({ - uniqueId, - el: dialogEl, - contentEl: dialogContentEl, - }), - [dialogContentEl, dialogEl, uniqueId] - ); - return ( - dCallbackList?.beforeEnter(el), - afterEnter: (el) => { - dCallbackList?.afterEnter?.(el); - dataRef.current.preActiveEl = document.activeElement as HTMLElement | null; - el.focus({ preventScroll: true }); - }, - beforeLeave: (el) => { - dataRef.current.preActiveEl?.focus({ preventScroll: true }); - return dCallbackList?.beforeLeave(el); - }, - }} - dRender={(hidden) => - !(dDestroy && hidden) && ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <> + {!(dDestroy && dHidden) && ( +
+ {dMask && } - ) - } - afterEnter={() => { - afterVisibleChange?.(true); - }} - afterLeave={() => { - afterVisibleChange?.(false); - }} - /> +
+ )} + ); -}; - -export const DDialog = React.forwardRef(Dialog); +} diff --git a/packages/ui/src/components/_dialog/Mask.tsx b/packages/ui/src/components/_dialog/Mask.tsx index ae123a77..90dbdd1a 100644 --- a/packages/ui/src/components/_dialog/Mask.tsx +++ b/packages/ui/src/components/_dialog/Mask.tsx @@ -1,10 +1,9 @@ -import type { DTransitionProps } from '../_transition'; +import type { DTransitionProps } from '../../hooks/transition'; import React, { useCallback } from 'react'; -import { usePrefixConfig, useRefCallback } from '../../hooks'; +import { usePrefixConfig, useDTransition, useRefCallback } from '../../hooks'; import { getClassName } from '../../utils'; -import { DTransition } from '../_transition'; export interface DMaskProps extends React.HTMLAttributes { dVisible?: boolean; @@ -37,26 +36,25 @@ export function DMask(props: DMaskProps) { 'enter-to': { transition: 'opacity 0.1s linear' }, 'leave-to': { opacity: '0', transition: 'opacity 0.1s linear' }, }; + const hidden = useDTransition({ + dEl: el, + dVisible, + dCallbackList: { + beforeEnter: () => transitionState, + beforeLeave: () => transitionState, + }, + dSkipFirst: false, + afterEnter: () => { + afterVisibleChange?.(true); + }, + afterLeave: () => { + afterVisibleChange?.(false); + }, + ...dTransitionProps, + }); return ( - transitionState, - beforeLeave: () => transitionState, - }} - dRender={(hidden) => - !hidden &&
- } - dSkipFirst={false} - afterEnter={() => { - afterVisibleChange?.(true); - }} - afterLeave={() => { - afterVisibleChange?.(false); - }} - {...dTransitionProps} - /> + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{!hidden &&
} ); } diff --git a/packages/ui/src/components/_popup/Popup.tsx b/packages/ui/src/components/_popup/Popup.tsx index 427a4fb9..266e79d1 100644 --- a/packages/ui/src/components/_popup/Popup.tsx +++ b/packages/ui/src/components/_popup/Popup.tsx @@ -1,15 +1,23 @@ import type { DElementSelector } from '../../hooks/element-ref'; +import type { DTransitionStateList } from '../../hooks/transition'; import type { DPlacement } from '../../utils/position'; -import type { DTransitionStateList } from '../_transition'; import { isUndefined, toNumber } from 'lodash'; import React, { useId, useCallback, useEffect, useMemo, useImperativeHandle, useRef, useState } from 'react'; import ReactDOM, { flushSync } from 'react-dom'; import { filter } from 'rxjs'; -import { usePrefixConfig, useAsync, useRefSelector, useImmer, useRefCallback, useRootContentConfig } from '../../hooks'; -import { getClassName, getPopupPlacementStyle, mergeStyle, MAX_INDEX_MANAGER } from '../../utils'; -import { DTransition } from '../_transition'; +import { + usePrefixConfig, + useAsync, + useRefSelector, + useImmer, + useRefCallback, + useRootContentConfig, + useDTransition, + useMaxIndex, +} from '../../hooks'; +import { getClassName, getPopupPlacementStyle, mergeStyle } from '../../utils'; import { checkOutEl } from './utils'; export interface DTriggerRenderProps { @@ -91,25 +99,21 @@ const Popup: React.ForwardRefRenderFunction = (props, re //#endregion const dataRef = useRef<{ - inTransition: boolean; clearTid: (() => void) | null; - clearResize: [(() => void) | null, (() => void) | null]; hasCancelLeave: boolean; transitionState?: DTransitionStateList; }>({ - inTransition: false, clearTid: null, - clearResize: [null, null], hasCancelLeave: false, }); const asyncCapture = useAsync(); const [popupPositionStyle, setPopupPositionStyle] = useImmer({}); const [arrowPosition, setArrowStyle] = useImmer(undefined); - const [zIndex, setZIndex] = useState(1000); const uniqueId = useId(); const [autoPlacement, setAutoPlacement] = useState(dPlacement); + const [afterEnter, setAfterEnter] = useState(false); const triggerRef = useRefSelector(isUndefined(dTriggerEl) ? `[data-${dPrefix}popup-trigger="${uniqueId}"]` : dTriggerEl); @@ -146,7 +150,7 @@ const Popup: React.ForwardRefRenderFunction = (props, re ); const updatePosition = useCallback(() => { - if (!dataRef.current.inTransition && popupEl && triggerRef.current && containerRef.current) { + if (popupEl && triggerRef.current && containerRef.current) { if (isUndefined(dCustomPopup)) { let currentPlacement = dAutoPlace ? dPlacement : autoPlacement; @@ -246,7 +250,7 @@ const Popup: React.ForwardRefRenderFunction = (props, re 'leave-to': { transform: 'scale(0.3)', opacity: '0', - transition: 'transform 116ms ease-in, opacity 116ms ease-in', + transition: 'transform 0.1s ease-in, opacity 0.1s ease-in', transformOrigin, }, }; @@ -276,6 +280,51 @@ const Popup: React.ForwardRefRenderFunction = (props, re triggerRef, ]); + const hidden = useDTransition({ + dEl: popupEl, + dVisible, + dCallbackList: { + beforeEnter: () => { + dataRef.current.hasCancelLeave = false; + updatePosition(); + + return dataRef.current.transitionState; + }, + afterEnter: () => { + updatePosition(); + setAfterEnter(true); + }, + beforeLeave: () => dataRef.current.transitionState, + afterLeave: () => { + setAfterEnter(false); + }, + }, + afterEnter: () => { + if (dataRef.current.hasCancelLeave && checkMouseLeave()) { + changeVisible(false); + } + afterVisibleChange?.(true); + }, + afterLeave: () => { + afterVisibleChange?.(false); + }, + }); + + const maxZIndex = useMaxIndex(!hidden); + const zIndex = useMemo(() => { + if (!hidden) { + if (isUndefined(dZIndex)) { + if (isFixed) { + return maxZIndex; + } else { + return toNumber(getComputedStyle(document.body).getPropertyValue(`--${dPrefix}absolute-z-index`)); + } + } else { + return dZIndex; + } + } + }, [dPrefix, dZIndex, hidden, isFixed, maxZIndex]); + // `onMouseEnter` and `onMouseLeave` trigger time is uncertain. // Very strange, sometimes popup element emit `onMouseEnter` before // trigger element emit `onMouseLeave`. @@ -358,24 +407,6 @@ const Popup: React.ForwardRefRenderFunction = (props, re ); //#region DidUpdate - useEffect(() => { - if (dVisible) { - if (isUndefined(dZIndex)) { - if (isFixed) { - const [key, maxZIndex] = MAX_INDEX_MANAGER.getMaxIndex(); - setZIndex(maxZIndex); - return () => { - MAX_INDEX_MANAGER.deleteRecord(key); - }; - } else { - setZIndex(toNumber(getComputedStyle(document.body).getPropertyValue(`--${dPrefix}absolute-z-index`))); - } - } else { - setZIndex(dZIndex); - } - } - }, [dPrefix, dVisible, dZIndex, isFixed, setZIndex]); - useEffect(() => { if (!isUndefined(dTriggerEl)) { const [asyncGroup, asyncId] = asyncCapture.createGroup(); @@ -474,7 +505,13 @@ const Popup: React.ForwardRefRenderFunction = (props, re useEffect(() => { const [asyncGroup, asyncId] = asyncCapture.createGroup(); - if (dVisible) { + if (dVisible && afterEnter) { + if (popupEl) { + asyncGroup.onResize(popupEl, updatePosition); + } + if (triggerRef.current) { + asyncGroup.onResize(triggerRef.current, updatePosition); + } if (!isFixed && rootContentRef.current) { asyncGroup.onResize(rootContentRef.current, updatePosition); } @@ -484,7 +521,7 @@ const Popup: React.ForwardRefRenderFunction = (props, re return () => { asyncCapture.deleteGroup(asyncId); }; - }, [asyncCapture, dVisible, updatePosition, rootContentRef, isFixed]); + }, [afterEnter, asyncCapture, dVisible, isFixed, popupEl, rootContentRef, triggerRef, updatePosition]); //#endregion useImperativeHandle( @@ -541,74 +578,38 @@ const Popup: React.ForwardRefRenderFunction = (props, re return ( <> {dTriggerRender?.(triggerRenderProps)} - { - dataRef.current.hasCancelLeave = false; - updatePosition(); - dataRef.current.inTransition = true; - - return dataRef.current.transitionState; - }, - afterEnter: (el) => { - dataRef.current.inTransition = false; - updatePosition(); - dataRef.current.clearResize[0] = asyncCapture.onResize(el, updatePosition); - if (triggerRef.current) { - dataRef.current.clearResize[1] = asyncCapture.onResize(triggerRef.current, updatePosition); - } - }, - beforeLeave: () => dataRef.current.transitionState, - afterLeave: () => { - dataRef.current.clearResize.forEach((cb) => cb?.()); - }, - }} - dRender={(hidden) => - !(dDestroy && hidden) && - dPopupContent && - containerRef.current && - ReactDOM.createPortal( - , - containerRef.current - ) - } - afterEnter={() => { - if (dataRef.current.hasCancelLeave && checkMouseLeave()) { - changeVisible(false); - } - afterVisibleChange?.(true); - }} - afterLeave={() => { - afterVisibleChange?.(false); - }} - /> + {!(dDestroy && hidden) && + dPopupContent && + containerRef.current && + ReactDOM.createPortal( + , + containerRef.current + )} ); }; diff --git a/packages/ui/src/components/_select-box/SelectBox.tsx b/packages/ui/src/components/_select-box/SelectBox.tsx index e02b76fa..2546505b 100644 --- a/packages/ui/src/components/_select-box/SelectBox.tsx +++ b/packages/ui/src/components/_select-box/SelectBox.tsx @@ -2,9 +2,8 @@ import { isUndefined } from 'lodash'; import React, { startTransition, useCallback, useState } from 'react'; import { useEffect } from 'react'; -import { useAsync, usePrefixConfig, useRefCallback, useTranslation } from '../../hooks'; +import { useAsync, usePrefixConfig, useRefCallback, useGeneralState, useTranslation } from '../../hooks'; import { getClassName } from '../../utils'; -import { useCompose } from '../compose'; import { DIcon } from '../icon'; export interface DSelectBoxProps extends React.HTMLAttributes { @@ -17,6 +16,7 @@ export interface DSelectBoxProps extends React.HTMLAttributes { dPlaceholder?: string; dDisabled?: boolean; dLoading?: boolean; + dAriaAttribute?: React.HTMLAttributes; onClear?: () => void; onSearch?: (value: string) => void; } @@ -32,6 +32,7 @@ const SelectBox: React.ForwardRefRenderFunction dPlaceholder, dDisabled = false, dLoading = false, + dAriaAttribute, onClear, onSearch, className, @@ -43,7 +44,7 @@ const SelectBox: React.ForwardRefRenderFunction //#region Context const dPrefix = usePrefixConfig(); - const { composeSize, composeDisabled } = useCompose(); + const { gSize, gDisabled } = useGeneralState(); //#endregion //#region Ref @@ -55,8 +56,8 @@ const SelectBox: React.ForwardRefRenderFunction const [searchValue, setSearchValue] = useState(''); - const size = composeSize ?? dSize; - const disabled = composeDisabled || dDisabled; + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled; const iconSize = size === 'smaller' ? 12 : size === 'larger' ? 16 : 14; @@ -130,6 +131,7 @@ const SelectBox: React.ForwardRefRenderFunction > {dSearchable && dExpanded ? ( onClickCapture={handleClearClick} > {isUndefined(dClearIcon) ? ( - + ) : ( diff --git a/packages/ui/src/components/affix/README.md b/packages/ui/src/components/affix/README.md index fa9aa846..b10906a6 100644 --- a/packages/ui/src/components/affix/README.md +++ b/packages/ui/src/components/affix/README.md @@ -28,7 +28,7 @@ Extend `React.HTMLAttributes`. ### DAffixRef ```tsx -export interface DAffixRef { +interface DAffixRef { el: HTMLDivElement | null; updatePosition: () => void; } diff --git a/packages/ui/src/components/affix/README.zh-Hant.md b/packages/ui/src/components/affix/README.zh-Hant.md index 806e7023..836e3260 100644 --- a/packages/ui/src/components/affix/README.zh-Hant.md +++ b/packages/ui/src/components/affix/README.zh-Hant.md @@ -27,7 +27,7 @@ title: 固钉 ### DAffixRef ```tsx -export interface DAffixRef { +interface DAffixRef { el: HTMLDivElement | null; updatePosition: () => void; } diff --git a/packages/ui/src/components/anchor/Anchor.tsx b/packages/ui/src/components/anchor/Anchor.tsx index 571219a1..391093dc 100644 --- a/packages/ui/src/components/anchor/Anchor.tsx +++ b/packages/ui/src/components/anchor/Anchor.tsx @@ -1,7 +1,8 @@ import type { DElementSelector } from '../../hooks/element-ref'; +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; import { isUndefined } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { usePrefixConfig, @@ -12,13 +13,13 @@ import { useRefCallback, useRootContentConfig, useValueChange, + DStateBackflowContext, } from '../../hooks'; import { getClassName, CustomScroll } from '../../utils'; export interface DAnchorContextData { anchorActiveHref: string | null; onLinkClick: (href: string) => void; - onLinkRendered: (href: string, el?: HTMLElement | null) => void; } export const DAnchorContext = React.createContext(null); @@ -51,13 +52,10 @@ export function DAnchor(props: DAnchorProps) { const [anchorEl, anchorRef] = useRefCallback(); //#endregion - const dataRef = useRef({ - links: new Map(), - }); - const asyncCapture = useAsync(); const [customScroll] = useState(() => new CustomScroll()); const [dotStyle, setDotStyle] = useImmer({}); + const [links, setLinks] = useImmer(new Map()); const [activeHref, setActiveHref] = useState(null); const pageRef = useRefSelector(dPage ?? null); @@ -75,7 +73,7 @@ export function DAnchor(props: DAnchorProps) { } let nearestEl: [string, number] | null = null; - for (const [href] of dataRef.current.links.entries()) { + for (const { href } of links.values()) { if (href) { const el = document.getElementById(href.slice(1)); if (el) { @@ -97,15 +95,20 @@ export function DAnchor(props: DAnchorProps) { setDotStyle((draft) => { draft.opacity = nearestEl ? 1 : 0; if (newHref) { - const rect = dataRef.current.links.get(newHref)?.getBoundingClientRect(); - if (rect && anchorEl) { - draft.top = rect.top + rect.height / 2 - anchorEl.getBoundingClientRect().top; + for (const { href, el } of links.values()) { + if (href === newHref) { + const rect = el?.getBoundingClientRect(); + if (rect && anchorEl) { + draft.top = rect.top + rect.height / 2 - anchorEl.getBoundingClientRect().top; + } + break; + } } } return draft; }); - }, [anchorEl, dDistance, dPage, pageRef, setActiveHref, setDotStyle]); + }, [anchorEl, dDistance, dPage, links, pageRef, setDotStyle]); const onLinkClick = useCallback( (href: string) => { @@ -158,31 +161,47 @@ export function DAnchor(props: DAnchorProps) { () => ({ anchorActiveHref: activeHref, onLinkClick, - onLinkRendered: (href, el) => { - if (isUndefined(el)) { - dataRef.current.links.delete(href); - } else { - dataRef.current.links.set(href, el); - } - }, }), [activeHref, onLinkClick] ); + const stateBackflowContextValue = useMemo( + () => ({ + addState: (identity, href, el) => { + setLinks((draft) => { + draft.set(identity, { href, el }); + }); + }, + updateState: (identity, href, el) => { + setLinks((draft) => { + draft.set(identity, { href, el }); + }); + }, + removeState: (identity) => { + setLinks((draft) => { + draft.delete(identity); + }); + }, + }), + [setLinks] + ); + return ( - -
    -
    - {dIndicator === 'dot' ? ( - - ) : dIndicator === 'line' ? ( - - ) : ( - dIndicator - )} -
    - {children} -
-
+ + +
    +
    + {dIndicator === 'dot' ? ( + + ) : dIndicator === 'line' ? ( + + ) : ( + dIndicator + )} +
    + {children} +
+
+
); } diff --git a/packages/ui/src/components/anchor/AnchorLink.tsx b/packages/ui/src/components/anchor/AnchorLink.tsx index 9e639419..aa79743d 100644 --- a/packages/ui/src/components/anchor/AnchorLink.tsx +++ b/packages/ui/src/components/anchor/AnchorLink.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useStateBackflow } from '../../hooks'; import { getClassName } from '../../utils'; import { DAnchorContext } from './Anchor'; @@ -14,13 +14,15 @@ export function DAnchorLink(props: DAnchorLinkProps) { //#region Context const dPrefix = usePrefixConfig(); - const [{ anchorActiveHref, onLinkRendered, onLinkClick }] = useCustomContext(DAnchorContext); + const [{ anchorActiveHref, onLinkClick }] = useCustomContext(DAnchorContext); //#endregion //#region Ref const [linkEl, linkRef] = useRefCallback(); //#endregion + useStateBackflow(href, linkEl); + const handleClick = useCallback( (e) => { onClick?.(e); @@ -33,17 +35,6 @@ export function DAnchorLink(props: DAnchorLinkProps) { [href, onClick, onLinkClick] ); - //#region DidUpdate - useEffect(() => { - if (linkEl && href) { - onLinkRendered?.(href, linkEl); - return () => { - onLinkRendered?.(href); - }; - } - }, [href, linkEl, onLinkRendered]); - //#endregion - return (
  • = (props, dIcon, dIconRight = false, className, + type = 'button', disabled, children, onClick, @@ -40,8 +47,8 @@ const Button: React.ForwardRefRenderFunction = (props, //#region Context const dPrefix = usePrefixConfig(); - const [{ buttonGroupType, buttonGroupTheme, buttonGroupSize, buttonGroupDisabled }] = useCustomContext(DButtonGroupContext); - const { composeSize, composeDisabled } = useCompose(); + const { gSize, gDisabled } = useGeneralState(); + const [{ buttonGroupType, buttonGroupTheme, buttonGroupDisabled }] = useCustomContext(DButtonGroupContext); //#endregion //#region Ref @@ -50,25 +57,20 @@ const Button: React.ForwardRefRenderFunction = (props, const wave = useWave(); - const type = isUndefined(props.dType) ? buttonGroupType ?? dType : dType; + const buttonType = isUndefined(props.dType) ? buttonGroupType ?? dType : dType; const theme = isUndefined(props.dTheme) ? buttonGroupTheme ?? dTheme : dTheme; - const size = isUndefined(composeSize) ? (isUndefined(props.dSize) ? buttonGroupSize ?? dSize : dSize) : composeSize; + const size = dSize ?? gSize; + const _disabled = disabled || buttonGroupDisabled || gDisabled; const handleClick = useCallback( (e) => { onClick?.(e); - if (!dLoading && (type === 'primary' || type === 'secondary' || type === 'outline' || type === 'dashed')) { + if (!dLoading && (buttonType === 'primary' || buttonType === 'secondary' || buttonType === 'outline' || buttonType === 'dashed')) { wave(e.currentTarget, `var(--${dPrefix}color-${theme})`); } }, - [theme, dLoading, dPrefix, onClick, type, wave] - ); - - const loadingIcon = ( - - - + [theme, dLoading, dPrefix, onClick, buttonType, wave] ); const buttonIcon = (loading: boolean, ref?: React.LegacyRef) => ( @@ -78,36 +80,42 @@ const Button: React.ForwardRefRenderFunction = (props, [`${dPrefix}button__icon--right`]: dIconRight, })} > - {loading ? loadingIcon : dIcon} + {loading ? ( + + + + ) : ( + dIcon + )} ); + const hidden = useDCollapseTransition({ + dEl: loadingEl, + dVisible: dLoading, + dDirection: 'horizontal', + }); + return ( - ( - - )} - /> + ); }; diff --git a/packages/ui/src/components/button/ButtonGroup.tsx b/packages/ui/src/components/button/ButtonGroup.tsx index 8bb83073..e999e5ff 100644 --- a/packages/ui/src/components/button/ButtonGroup.tsx +++ b/packages/ui/src/components/button/ButtonGroup.tsx @@ -1,14 +1,14 @@ +import type { DGeneralStateContextData } from '../../hooks/general-state'; import type { DButtonProps } from './Button'; import React, { useMemo } from 'react'; -import { usePrefixConfig, useComponentConfig } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useGeneralState, DGeneralStateContext } from '../../hooks'; import { getClassName } from '../../utils'; export interface DButtonGroupContextData { buttonGroupType: DButtonProps['dType']; buttonGroupTheme: DButtonProps['dTheme']; - buttonGroupSize: DButtonProps['dSize']; buttonGroupDisabled: boolean; } export const DButtonGroupContext = React.createContext(null); @@ -33,23 +33,36 @@ export function DButtonGroup(props: DButtonGroupProps) { //#region Context const dPrefix = usePrefixConfig(); + const { gSize, gDisabled } = useGeneralState(); //#endregion + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled; + const contextValue = useMemo( () => ({ buttonGroupType: dType, buttonGroupTheme: dTheme, - buttonGroupSize: dSize, buttonGroupDisabled: dDisabled, }), - [dType, dTheme, dSize, dDisabled] + [dType, dTheme, dDisabled] + ); + + const generalStateContextValue = useMemo( + () => ({ + gSize: size, + gDisabled: disabled, + }), + [disabled, size] ); return ( - -
    - {children} -
    -
    + + +
    + {children} +
    +
    +
    ); } diff --git a/packages/ui/src/components/button/README.md b/packages/ui/src/components/button/README.md index 7f0b0854..8ae9a07d 100644 --- a/packages/ui/src/components/button/README.md +++ b/packages/ui/src/components/button/README.md @@ -31,7 +31,7 @@ Extend `React.ButtonHTMLAttributes`. ### DButtonRef ```tsx -export type DButtonRef = HTMLButtonElement; +type DButtonRef = HTMLButtonElement; ``` ### DButtonGroupProps diff --git a/packages/ui/src/components/button/README.zh-Hant.md b/packages/ui/src/components/button/README.zh-Hant.md index 9bd77ce2..0bd7aa8c 100644 --- a/packages/ui/src/components/button/README.zh-Hant.md +++ b/packages/ui/src/components/button/README.zh-Hant.md @@ -30,7 +30,7 @@ title: 按钮 ### DButtonRef ```tsx -export type DButtonRef = HTMLButtonElement; +type DButtonRef = HTMLButtonElement; ``` ### DButtonGroupProps diff --git a/packages/ui/src/components/compose/Compose.tsx b/packages/ui/src/components/compose/Compose.tsx index fdca1930..fa2c5a0b 100644 --- a/packages/ui/src/components/compose/Compose.tsx +++ b/packages/ui/src/components/compose/Compose.tsx @@ -1,14 +1,10 @@ +import type { DGeneralStateContextData } from '../../hooks/general-state'; + import React, { useMemo } from 'react'; -import { usePrefixConfig, useComponentConfig } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useGeneralState, DGeneralStateContext } from '../../hooks'; import { getClassName } from '../../utils'; -export interface DComposeContextData { - composeSize: DComposeProps['dSize']; - composeDisabled: boolean; -} -export const DComposeContext = React.createContext(null); - export interface DComposeProps extends React.HTMLAttributes { dSize?: 'smaller' | 'larger'; dDisabled?: boolean; @@ -19,21 +15,25 @@ export function DCompose(props: DComposeProps) { //#region Context const dPrefix = usePrefixConfig(); + const { gSize, gDisabled } = useGeneralState(); //#endregion - const contextValue = useMemo( + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled; + + const generalStateContextValue = useMemo( () => ({ - composeSize: dSize, - composeDisabled: dDisabled, + gSize: size, + gDisabled: disabled, }), - [dSize, dDisabled] + [disabled, size] ); return ( - +
    {children}
    -
    + ); } diff --git a/packages/ui/src/components/compose/ComposeItem.tsx b/packages/ui/src/components/compose/ComposeItem.tsx index f3bfbeb7..0f493367 100644 --- a/packages/ui/src/components/compose/ComposeItem.tsx +++ b/packages/ui/src/components/compose/ComposeItem.tsx @@ -1,6 +1,5 @@ -import { usePrefixConfig, useComponentConfig } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useGeneralState } from '../../hooks'; import { getClassName } from '../../utils'; -import { useCompose } from './utils'; export interface DComposeItemProps extends React.HTMLAttributes { dGray?: boolean; @@ -11,16 +10,16 @@ export function DComposeItem(props: DComposeItemProps) { //#region Context const dPrefix = usePrefixConfig(); - const { composeSize, composeDisabled } = useCompose(); + const { gSize, gDisabled } = useGeneralState(); //#endregion return (
    {children} diff --git a/packages/ui/src/components/compose/index.ts b/packages/ui/src/components/compose/index.ts index f582edff..3f98508a 100644 --- a/packages/ui/src/components/compose/index.ts +++ b/packages/ui/src/components/compose/index.ts @@ -1,3 +1,2 @@ export * from './Compose'; export * from './ComposeItem'; -export { useCompose } from './utils'; diff --git a/packages/ui/src/components/compose/utils.ts b/packages/ui/src/components/compose/utils.ts deleted file mode 100644 index 3970d730..00000000 --- a/packages/ui/src/components/compose/utils.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useCustomContext } from '../../hooks'; -import { DComposeContext } from './Compose'; - -export function useCompose() { - const [{ composeSize, composeDisabled = false }] = useCustomContext(DComposeContext); - - return { - composeSize, - composeDisabled, - }; -} diff --git a/packages/ui/src/components/drag-drop/Drag.tsx b/packages/ui/src/components/drag-drop/Drag.tsx index ea3c76ea..80abf12e 100644 --- a/packages/ui/src/components/drag-drop/Drag.tsx +++ b/packages/ui/src/components/drag-drop/Drag.tsx @@ -3,7 +3,16 @@ import React, { useId, useEffect, useMemo, useState, useRef } from 'react'; import ReactDOM from 'react-dom'; import { merge } from 'rxjs'; -import { useComponentConfig, useRefSelector, useThrottle, useAsync, usePrefixConfig, useCustomContext, useImmer } from '../../hooks'; +import { + useComponentConfig, + useRefSelector, + useThrottle, + useAsync, + usePrefixConfig, + useCustomContext, + useImmer, + useStateBackflow, +} from '../../hooks'; import { DDropContext } from './Drop'; export interface DDragProps { @@ -20,11 +29,13 @@ export function DDrag(props: DDragProps) { //#region Context const dPrefix = usePrefixConfig(); - const [{ dropOuter, dropCurrentData, dropPlaceholder, onDragStart: _onDragStart, onDrag: _onDrag, onDragEnd: _onDragEnd }, dropContext] = + const [{ dropOuter, dropPlaceholder, onDragStart: _onDragStart, onDrag: _onDrag, onDragEnd: _onDragEnd }, dropContext] = useCustomContext(DDropContext); //#endregion - const dataRef = useRef<{ dragEl: HTMLElement | null }>({ dragEl: null }); + const dataRef = useRef<{ dragEl: HTMLElement | null }>({ + dragEl: null, + }); const asyncCapture = useAsync(); const { throttleByAnimationFrame } = useThrottle(); @@ -49,6 +60,8 @@ export function DDrag(props: DDragProps) { return el; }); + useStateBackflow(dId, `[data-${dPrefix}drag="${uniqueId}"]`, `[data-${dPrefix}drag-placeholder="${uniqueId}"]`); + //#region DidUpdate useEffect(() => { if (isDragging && isNumber(fixedStyle.top) && isNumber(fixedStyle.left)) { @@ -199,17 +212,6 @@ export function DDrag(props: DDragProps) { onDragEnd, dId, ]); - - useEffect(() => { - if (dId) { - dropCurrentData?.drags.set(dId, `[data-${dPrefix}drag="${uniqueId}"]`); - dropCurrentData?.placeholders.set(dId, `[data-${dPrefix}drag-placeholder="${uniqueId}"]`); - return () => { - dropCurrentData?.drags.delete(dId); - dropCurrentData?.placeholders.delete(dId); - }; - } - }, [dId, dPrefix, dropCurrentData, uniqueId]); //#endregion const child = useMemo(() => { diff --git a/packages/ui/src/components/drag-drop/Drop.tsx b/packages/ui/src/components/drag-drop/Drop.tsx index 20685466..ba33d60d 100644 --- a/packages/ui/src/components/drag-drop/Drop.tsx +++ b/packages/ui/src/components/drag-drop/Drop.tsx @@ -1,19 +1,16 @@ import type { DElementSelector } from '../../hooks/element-ref'; +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; import type { DDragProps } from './Drag'; import { cloneDeep, isEqual, isUndefined } from 'lodash'; -import React, { useImperativeHandle, useRef, useState } from 'react'; +import React, { useImperativeHandle, useState } from 'react'; import { useEffect, useMemo } from 'react'; -import { useComponentConfig, useRefSelector, useImmer } from '../../hooks'; +import { useComponentConfig, useRefSelector, useImmer, DStateBackflowContext } from '../../hooks'; export interface DDropContextData { dropDirection: 'horizontal' | 'vertical'; dropOuter: boolean; - dropCurrentData: { - drags: Map; - placeholders: Map; - }; dropPlaceholder: React.ReactNode; onDragStart: (id: string) => void; onDrag: (id: string, rect: { width: number; height: number; top: number; left: number }) => void; @@ -44,17 +41,15 @@ const Drop: React.ForwardRefRenderFunction = (props, ref) onDragEnd, } = useComponentConfig(DDrop.name, props); - const dataRef = useRef({ - drags: new Map(), - placeholders: new Map(), - }); - const [isOuter, setIsOuter] = useState(false); const [dragEnd, setDragEnd] = useState(false); const [orderIds, setOrderIds] = useState([]); const [orderChildren, setOrderChildren] = useImmer([]); + const [drags, setDrags] = useImmer(new Map()); + const [placeholders, setPlaceholders] = useImmer(new Map()); + const containerRef = useRefSelector(dContainer); //#region DidUpdate @@ -93,7 +88,6 @@ const Drop: React.ForwardRefRenderFunction = (props, ref) () => ({ dropDirection: dDirection, dropOuter: isOuter, - dropCurrentData: dataRef.current, dropPlaceholder: dPlaceholder, onDragStart: (id) => { setDragEnd(false); @@ -151,11 +145,19 @@ const Drop: React.ForwardRefRenderFunction = (props, ref) newOrderIds.forEach((id, index) => { let el: HTMLElement | null = null; if (id === dragId) { - const selector = dataRef.current.placeholders.get(id); - el = selector ? document.querySelector(selector) : null; + for (const { id: _id, selector } of placeholders.values()) { + if (_id === id) { + el = document.querySelector(selector); + break; + } + } } else { - const selector = dataRef.current.drags.get(id); - el = selector ? document.querySelector(selector) : null; + for (const { id: _id, selector } of drags.values()) { + if (_id === id) { + el = document.querySelector(selector); + break; + } + } } if (el) { const elRect = el.getBoundingClientRect(); @@ -200,12 +202,46 @@ const Drop: React.ForwardRefRenderFunction = (props, ref) onDragEnd?.(id); }, }), - [containerRef, dDirection, dPlaceholder, isOuter, onDragEnd, onDragStart, orderIds, setDragEnd, setIsOuter] + [containerRef, dDirection, dPlaceholder, drags, isOuter, onDragEnd, onDragStart, orderIds, placeholders] ); useImperativeHandle(ref, () => orderIds, [orderIds]); - return {orderChildren}; + const stateBackflowContextValue = useMemo( + () => ({ + addState: (identity, id, drag, placeholder) => { + setDrags((draft) => { + draft.set(identity, { id, selector: drag }); + }); + setPlaceholders((draft) => { + draft.set(identity, { id, selector: placeholder }); + }); + }, + updateState: (identity, id, drag, placeholder) => { + setDrags((draft) => { + draft.set(identity, { id, selector: drag }); + }); + setPlaceholders((draft) => { + draft.set(identity, { id, selector: placeholder }); + }); + }, + removeState: (identity) => { + setDrags((draft) => { + draft.delete(identity); + }); + setPlaceholders((draft) => { + draft.delete(identity); + }); + }, + }), + [setDrags, setPlaceholders] + ); + + return ( + + {orderChildren} + + ); }; export const DDrop = React.forwardRef(Drop); diff --git a/packages/ui/src/components/drag-drop/README.md b/packages/ui/src/components/drag-drop/README.md index 85439c3d..d74de4ba 100644 --- a/packages/ui/src/components/drag-drop/README.md +++ b/packages/ui/src/components/drag-drop/README.md @@ -41,7 +41,7 @@ Need to dynamically adjust the component position. ### DDropRef ```tsx -export type DDropRef = string[]; +type DDropRef = string[]; ``` ### DDragPlaceholderProps diff --git a/packages/ui/src/components/drag-drop/README.zh-Hant.md b/packages/ui/src/components/drag-drop/README.zh-Hant.md index fe83659b..1de6a9d9 100644 --- a/packages/ui/src/components/drag-drop/README.zh-Hant.md +++ b/packages/ui/src/components/drag-drop/README.zh-Hant.md @@ -40,7 +40,7 @@ title: 拖放 ### DDropRef ```tsx -export type DDropRef = string[]; +type DDropRef = string[]; ``` ### DDragPlaceholderProps diff --git a/packages/ui/src/components/drawer/Drawer.tsx b/packages/ui/src/components/drawer/Drawer.tsx index ae93cbeb..b621cb9a 100644 --- a/packages/ui/src/components/drawer/Drawer.tsx +++ b/packages/ui/src/components/drawer/Drawer.tsx @@ -1,13 +1,21 @@ import type { DElementSelector } from '../../hooks/element-ref'; import type { Updater } from '../../hooks/two-way-binding'; -import type { DDialogRef } from '../_dialog'; import { isUndefined, toNumber } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useId, useMemo, useRef } from 'react'; import ReactDOM from 'react-dom'; -import { usePrefixConfig, useComponentConfig, useRefSelector, useImmer, useRefCallback, useLockScroll } from '../../hooks'; -import { getClassName, MAX_INDEX_MANAGER, mergeStyle } from '../../utils'; +import { + usePrefixConfig, + useComponentConfig, + useRefSelector, + useImmer, + useRefCallback, + useLockScroll, + useDTransition, + useMaxIndex, +} from '../../hooks'; +import { getClassName, mergeStyle } from '../../utils'; import { DDialog } from '../_dialog'; export interface DDrawerContextData { @@ -64,14 +72,87 @@ export function DDrawer(props: DDrawerProps) { //#endregion //#region Ref - const [dialogRefContent, dialogRef] = useRefCallback(); + const [dialogEl, dialogRef] = useRefCallback(); + const [dialogContentEl, dialogContentRef] = useRefCallback(); //#endregion - const [zIndex, setZIndex] = useState(1000); + const dataRef = useRef<{ preActiveEl: HTMLElement | null }>({ + preActiveEl: null, + }); + + const uniqueId = useId(); const [visible, setVisible] = dVisible; + const transitionState = (() => { + const transform = + dPlacement === 'top' + ? 'translate(0, -100%)' + : dPlacement === 'right' + ? 'translate(100%, 0)' + : dPlacement === 'bottom' + ? 'translate(0, 100%)' + : 'translate(-100%, 0)'; + return { + 'enter-from': { transform }, + 'enter-to': { transition: 'transform 0.2s ease-out' }, + 'leave-to': { transform, transition: 'transform 0.2s ease-in' }, + }; + })(); + const hidden = useDTransition({ + dEl: dialogContentEl, + dVisible: visible, + dCallbackList: { + beforeEnter: (el) => { + const rect = el.getBoundingClientRect(); + __onVisibleChange?.({ + visible: true, + top: distance.top + (dPlacement === 'top' ? rect.height : 0), + right: distance.right + (dPlacement === 'right' ? rect.width : 0), + bottom: distance.bottom + (dPlacement === 'bottom' ? rect.height : 0), + left: distance.left + (dPlacement === 'left' ? rect.width : 0), + }); + + return transitionState; + }, + afterEnter: (el) => { + dataRef.current.preActiveEl = document.activeElement as HTMLElement | null; + el.focus({ preventScroll: true }); + }, + beforeLeave: (el) => { + dataRef.current.preActiveEl?.focus({ preventScroll: true }); + __onVisibleChange?.({ + ...distance, + visible: false, + }); + return transitionState; + }, + }, + afterEnter: () => { + afterVisibleChange?.(true); + }, + afterLeave: () => { + afterVisibleChange?.(false); + }, + }); + const isFixed = isUndefined(dContainer); + + const maxZIndex = useMaxIndex(!hidden); + const zIndex = useMemo(() => { + if (!hidden) { + if (isUndefined(__zIndex)) { + if (isFixed) { + return maxZIndex; + } else { + return toNumber(getComputedStyle(document.body).getPropertyValue(`--${dPrefix}absolute-z-index`)); + } + } else { + return __zIndex; + } + } + }, [__zIndex, dPrefix, hidden, isFixed, maxZIndex]); + const handleContainer = useCallback(() => { if (isFixed) { let el = document.getElementById(`${dPrefix}drawer-root`); @@ -82,10 +163,10 @@ export function DDrawer(props: DDrawerProps) { } return el; } else if (dContainer === false) { - return dialogRefContent?.el?.parentElement ?? null; + return dialogEl?.parentElement ?? null; } return null; - }, [dContainer, dPrefix, dialogRefContent?.el?.parentElement, isFixed]); + }, [dContainer, dPrefix, dialogEl, isFixed]); const containerRef = useRefSelector(dContainer, handleContainer); const [distance, setDistance] = useImmer<{ visible: boolean; top: number; right: number; bottom: number; left: number }>({ @@ -101,31 +182,7 @@ export function DDrawer(props: DDrawerProps) { onClose?.(); }, [onClose, setVisible]); - useLockScroll(isFixed && visible); - - //#region DidUpdate - useEffect(() => { - if (visible) { - if (isUndefined(dZIndex)) { - if (isUndefined(__zIndex)) { - if (isFixed) { - const [key, maxZIndex] = MAX_INDEX_MANAGER.getMaxIndex(); - setZIndex(maxZIndex); - return () => { - MAX_INDEX_MANAGER.deleteRecord(key); - }; - } else { - setZIndex(toNumber(getComputedStyle(document.body).getPropertyValue(`--${dPrefix}absolute-z-index`))); - } - } else { - setZIndex(__zIndex); - } - } else { - setZIndex(dZIndex); - } - } - }, [__zIndex, dPrefix, dZIndex, isFixed, setZIndex, visible]); - //#endregion + useLockScroll(isFixed && !hidden); const childDrawer = useMemo(() => { if (dChildDrawer) { @@ -135,7 +192,7 @@ export function DDrawer(props: DDrawerProps) { __onVisibleChange: (distance) => { setDistance(distance); }, - __zIndex: zIndex + 1, + __zIndex: isUndefined(zIndex) ? zIndex : zIndex + 1, }); } return null; @@ -143,10 +200,10 @@ export function DDrawer(props: DDrawerProps) { const contextValue = useMemo( () => ({ - drawerId: dialogRefContent?.uniqueId, + drawerId: uniqueId, closeDrawer, }), - [closeDrawer, dialogRefContent?.uniqueId] + [closeDrawer, uniqueId] ); const contentProps = useMemo>( @@ -160,27 +217,10 @@ export function DDrawer(props: DDrawerProps) { [dHeight, dPlacement, dPrefix, dWidth] ); - const transitionState = (() => { - const transform = - dPlacement === 'top' - ? 'translate(0, -100%)' - : dPlacement === 'right' - ? 'translate(100%, 0)' - : dPlacement === 'bottom' - ? 'translate(0, 100%)' - : 'translate(-100%, 0)'; - return { - 'enter-from': { transform }, - 'enter-to': { transition: 'transform 0.2s ease-out' }, - 'leave-to': { transform, transition: 'transform 0.2s ease-in' }, - }; - })(); - const drawerNode = ( <> { - const rect = el.getBoundingClientRect(); - __onVisibleChange?.({ - visible: true, - top: distance.top + (dPlacement === 'top' ? rect.height : 0), - right: distance.right + (dPlacement === 'right' ? rect.width : 0), - bottom: distance.bottom + (dPlacement === 'bottom' ? rect.height : 0), - left: distance.left + (dPlacement === 'left' ? rect.width : 0), - }); - - return transitionState; - }, - beforeLeave: () => { - __onVisibleChange?.({ - ...distance, - visible: false, - }); - - return transitionState; - }, - }} + dHidden={hidden} dContentProps={contentProps} dMask={dMask} dMaskClosable={dMaskClosable} dDestroy={dDestroy} + dDialogRef={dialogRef} + dDialogContentRef={dialogContentRef} onClose={closeDrawer} - afterVisibleChange={afterVisibleChange} > {dHeader} diff --git a/packages/ui/src/components/dropdown/Dropdown.tsx b/packages/ui/src/components/dropdown/Dropdown.tsx index 77a2ce20..2ebd661d 100644 --- a/packages/ui/src/components/dropdown/Dropdown.tsx +++ b/packages/ui/src/components/dropdown/Dropdown.tsx @@ -1,7 +1,7 @@ import type { Updater } from '../../hooks/two-way-binding'; import type { DDropdownItemProps } from './DropdownItem'; -import React, { useId, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useId, useCallback, useEffect, useMemo, useState } from 'react'; import { usePrefixConfig, useComponentConfig, useImmer, useRefCallback, useTwoWayBinding, useAsync, useTranslation } from '../../hooks'; import { getClassName, getVerticalSideStyle } from '../../utils'; @@ -58,11 +58,6 @@ export function DDropdown(props: DDropdownProps) { const [navEl, navRef] = useRefCallback(); //#endregion - const dataRef = useRef<{ navIds: Set; ids: Map> }>({ - navIds: new Set(), - ids: new Map(), - }); - const [t] = useTranslation('Common'); const asyncCapture = useAsync(); @@ -135,28 +130,7 @@ export function DDropdown(props: DDropdownProps) { ); const childs = useMemo(() => { - dataRef.current.navIds.clear(); - dataRef.current.ids.clear(); - - const getAllIds = (child: React.ReactElement) => { - if (child.props?.dId) { - const nodes = (React.Children.toArray(child.props?.children) as React.ReactElement[]).filter((node) => node.props?.dId); - const ids = nodes.map((node) => node.props?.dId); - dataRef.current.ids.set(child.props?.dId, new Set(ids)); - - nodes.forEach((node) => { - getAllIds(node); - }); - } - }; - - React.Children.toArray(children).forEach((node) => { - getAllIds(node as React.ReactElement); - }); - return React.Children.map(children as Array>, (child, index) => { - child.props.dId && dataRef.current.navIds.add(child.props.dId); - let tabIndex = child.props.tabIndex; if (index === 0) { tabIndex = 0; diff --git a/packages/ui/src/components/dropdown/DropdownCroup.tsx b/packages/ui/src/components/dropdown/DropdownGroup.tsx similarity index 100% rename from packages/ui/src/components/dropdown/DropdownCroup.tsx rename to packages/ui/src/components/dropdown/DropdownGroup.tsx diff --git a/packages/ui/src/components/dropdown/DropdownSub.tsx b/packages/ui/src/components/dropdown/DropdownSub.tsx index a830075d..a5dfe96d 100644 --- a/packages/ui/src/components/dropdown/DropdownSub.tsx +++ b/packages/ui/src/components/dropdown/DropdownSub.tsx @@ -1,17 +1,23 @@ +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; + import { isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useTranslation } from '../../hooks'; +import { + usePrefixConfig, + useComponentConfig, + useCustomContext, + useRefCallback, + useTranslation, + DStateBackflowContext, + useStateBackflow, + useImmer, +} from '../../hooks'; import { getClassName, getHorizontalSideStyle, mergeStyle, toId } from '../../utils'; import { DPopup } from '../_popup'; import { DIcon } from '../icon'; import { DDropdownContext } from './Dropdown'; -export interface DDropdownSubContextData { - onPopupTrigger: (visible: boolean) => void; -} -export const DDropdownSubContext = React.createContext(null); - export interface DDropdownSubProps extends React.LiHTMLAttributes { dId: string; dIcon?: React.ReactNode; @@ -43,7 +49,6 @@ export function DDropdownSub(props: DDropdownSubProps) { const dPrefix = usePrefixConfig(); const [{ dropdownVisible, dropdownFocusId, dropdownPopupTrigger, onFocus: _onFocus, onBlur: _onBlur }] = useCustomContext(DDropdownContext); - const [{ onPopupTrigger }] = useCustomContext(DDropdownSubContext); //#endregion //#region Ref @@ -56,8 +61,19 @@ export function DDropdownSub(props: DDropdownSubProps) { const [activedescendant, setActiveDescendant] = useState(undefined); const [currentPopupVisible, setCurrentPopupVisible] = useState(false); - const [childrenPopupVisiable, setChildrenPopupVisiable] = useState(false); - const popupVisible = currentPopupVisible || childrenPopupVisiable; + const [childrenPopupVisiable, setChildrenPopupVisiable] = useImmer(new Map()); + const popupVisible = useMemo(() => { + let visible = currentPopupVisible; + for (const childrenVisiable of childrenPopupVisiable.values()) { + if (childrenVisiable) { + visible = childrenVisiable; + break; + } + } + return visible; + }, [childrenPopupVisiable, currentPopupVisible]); + + useStateBackflow(popupVisible); const _id = id ?? `${dPrefix}dropdown-sub-${toId(dId)}`; @@ -117,23 +133,31 @@ export function DDropdownSub(props: DDropdownSubProps) { setCurrentPopupVisible(false); } }, [dropdownVisible, setCurrentPopupVisible]); - - useEffect(() => { - onPopupTrigger?.(popupVisible); - }, [onPopupTrigger, popupVisible]); //#endregion - const contextValue = useMemo( + const stateBackflowContextValue = useMemo( () => ({ - onPopupTrigger: (visible) => { - setChildrenPopupVisiable(visible); + addState: (identity, visible) => { + setChildrenPopupVisiable((draft) => { + draft.set(identity, visible); + }); + }, + updateState: (identity, visible) => { + setChildrenPopupVisiable((draft) => { + draft.set(identity, visible); + }); + }, + removeState: (identity) => { + setChildrenPopupVisiable((draft) => { + draft.delete(identity); + }); }, }), [setChildrenPopupVisiable] ); return ( - +
  • )} - + ); } diff --git a/packages/ui/src/components/dropdown/index.ts b/packages/ui/src/components/dropdown/index.ts index c7847b88..68267760 100644 --- a/packages/ui/src/components/dropdown/index.ts +++ b/packages/ui/src/components/dropdown/index.ts @@ -1,4 +1,4 @@ export * from './Dropdown'; export * from './DropdownItem'; export * from './DropdownSub'; -export * from './DropdownCroup'; +export * from './DropdownGroup'; diff --git a/packages/ui/src/components/form/Error.tsx b/packages/ui/src/components/form/Error.tsx new file mode 100644 index 00000000..cbce5082 --- /dev/null +++ b/packages/ui/src/components/form/Error.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { usePrefixConfig, useDCollapseTransition, useRefCallback } from '../../hooks'; +import { getClassName } from '../../utils'; + +export interface DErrorProps extends React.HTMLAttributes { + dVisible: boolean; + dMessage: string; + dStatus?: 'error' | 'warning'; + onHidden?: () => void; +} + +export function DError(props: DErrorProps) { + const { dVisible, dMessage, dStatus = 'error', onHidden, className, children, ...restProps } = props; + + //#region Context + const dPrefix = usePrefixConfig(); + //#endregion + + //#region Ref + const [el, ref] = useRefCallback(); + //#endregion + + const hidden = useDCollapseTransition({ + dEl: el, + dVisible, + dSkipFirst: false, + dDuring: 133, + afterLeave: () => { + onHidden?.(); + }, + }); + + return hidden ? null : ( +
    + {dMessage} +
    + ); +} diff --git a/packages/ui/src/components/form/Form.tsx b/packages/ui/src/components/form/Form.tsx new file mode 100644 index 00000000..aa88af2c --- /dev/null +++ b/packages/ui/src/components/form/Form.tsx @@ -0,0 +1,137 @@ +import type { DGeneralStateContextData } from '../../hooks/general-state'; +import type { DBreakpoints } from '../grid'; +import type { DFormInstance } from './hooks'; + +import { isUndefined } from 'lodash'; +import React, { useCallback, useMemo } from 'react'; + +import { usePrefixConfig, useComponentConfig, DGeneralStateContext } from '../../hooks'; +import { getClassName } from '../../utils'; +import { MEDIA_QUERY_LIST } from '../grid'; +import { DRow } from '../grid'; + +export interface DFormContextData { + formInstance: DFormInstance; + formBreakpointMatchs: DBreakpoints[]; + formLabelWidth: string | number; + formLabelColon: boolean; + formCustomLabel: NonNullable; + formLayout: NonNullable; + formInlineSpan: number | true; + formFeedbackIcon: NonNullable; +} +export const DFormContext = React.createContext(null); + +export interface DFormProps extends React.FormHTMLAttributes { + dForm: DFormInstance; + dLabelWidth?: string | number; + dLabelColon?: boolean; + dCustomLabel?: 'required' | 'optional' | 'hidden'; + dLayout?: 'horizontal' | 'vertical' | 'inline'; + dInlineSpan?: number | true; + dFeedbackIcon?: + | boolean + | { + success?: React.ReactNode; + warning?: React.ReactNode; + error?: React.ReactNode; + pending?: React.ReactNode; + }; + dSize?: 'smaller' | 'larger'; + dResponsiveProps?: Record>; +} + +export function DForm(props: DFormProps) { + const { + dForm, + dLabelWidth, + dLabelColon, + dCustomLabel = 'required', + dLayout = 'horizontal', + dInlineSpan = 6, + dFeedbackIcon = false, + dSize, + dResponsiveProps, + className, + autoComplete = 'off', + children, + onSubmit, + ...restProps + } = useComponentConfig(DForm.name, props); + + //#region Context + const dPrefix = usePrefixConfig(); + //#endregion + + const handleSubmit = useCallback>( + (e) => { + e.preventDefault(); + e.stopPropagation(); + onSubmit?.(e); + }, + [onSubmit] + ); + + const generalStateContextValue = useMemo( + () => ({ + gSize: dSize, + gDisabled: false, + }), + [dSize] + ); + + return ( + + { + const contextValue = { + formInstance: dForm, + formBreakpointMatchs: matchs, + formLabelWidth: dLabelWidth ?? 150, + formLabelColon: dLabelColon ?? true, + formCustomLabel: dCustomLabel, + formLayout: dLayout, + formInlineSpan: dInlineSpan, + formFeedbackIcon: dFeedbackIcon, + }; + if (dResponsiveProps) { + const keys = Object.keys(dResponsiveProps); + const mergeProps = (point: string, targetKey: string, sourceKey: string) => { + const value = dResponsiveProps[point][sourceKey]; + if (!isUndefined(value)) { + contextValue[targetKey] = value; + } + }; + for (const point of [...MEDIA_QUERY_LIST].reverse()) { + if (keys.includes(point) && matchs.includes(point)) { + mergeProps(point, 'formLabelWidth', 'dLabelWidth'); + mergeProps(point, 'formCustomLabel', 'dCustomLabel'); + mergeProps(point, 'formLayout', 'dLayout'); + mergeProps(point, 'formInlineSpan', 'dInlineSpan'); + break; + } + } + } + contextValue.formLabelWidth = dLabelWidth ?? (contextValue.formLayout === 'vertical' ? '100%' : 150); + contextValue.formLabelColon = dLabelColon ?? (contextValue.formLayout === 'vertical' ? false : true); + + return ( + +
    + {children} +
    +
    + ); + }} + >
    +
    + ); +} diff --git a/packages/ui/src/components/form/FormGroup.tsx b/packages/ui/src/components/form/FormGroup.tsx new file mode 100644 index 00000000..4539747c --- /dev/null +++ b/packages/ui/src/components/form/FormGroup.tsx @@ -0,0 +1,39 @@ +import React, { useMemo } from 'react'; + +import { usePrefixConfig, useComponentConfig, useCustomContext } from '../../hooks'; +import { getClassName } from '../../utils'; + +export interface DFormGroupContextData { + formGroupPath: string[]; +} +export const DFormGroupContext = React.createContext(null); + +export interface DFormGroupProps extends React.HTMLAttributes { + dFormGroupName: string; + dTitle?: React.ReactNode; +} + +export function DFormGroup(props: DFormGroupProps) { + const { dFormGroupName, dTitle, className, children, ...restProps } = useComponentConfig(DFormGroup.name, props); + + //#region Context + const dPrefix = usePrefixConfig(); + const [{ formGroupPath }] = useCustomContext(DFormGroupContext); + //#endregion + + const contextValue = useMemo( + () => ({ formGroupPath: (formGroupPath ?? []).concat([dFormGroupName]) }), + [dFormGroupName, formGroupPath] + ); + + return ( + + {dTitle && ( +
    + {dTitle} +
    + )} + {children} +
    + ); +} diff --git a/packages/ui/src/components/form/FormItem.tsx b/packages/ui/src/components/form/FormItem.tsx index 9c348997..6b3cd5b6 100644 --- a/packages/ui/src/components/form/FormItem.tsx +++ b/packages/ui/src/components/form/FormItem.tsx @@ -1,7 +1,385 @@ -import React from 'react'; +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; +import type { DBreakpoints } from '../grid'; +import type { DFormContextData } from './Form'; +import type { AbstractControl, FormControlStatus } from './form'; -export interface DFormItemContextData { - dModel: unknown; - onModelChange: (value: unknown, name: string) => void; +import { isArray, isBoolean, isNull, isNumber, isString, isUndefined } from 'lodash'; +import React, { useCallback, useContext, useMemo, useRef } from 'react'; + +import { usePrefixConfig, useComponentConfig, useCustomContext, useImmer, DStateBackflowContext, useTranslation } from '../../hooks'; +import { getClassName } from '../../utils'; +import { MEDIA_QUERY_LIST } from '../grid'; +import { DIcon } from '../icon'; +import { DTooltip } from '../tooltip'; +import { DError } from './Error'; +import { DFormContext } from './Form'; +import { DFormGroupContext } from './FormGroup'; +import { Validators } from './form'; + +type DErrors = Array<{ identity: string; key: string; message: string; status: 'warning' | 'error'; hidden?: true }>; + +export type DValidateStatus = 'success' | 'warning' | 'error' | 'pending'; + +export type DErrorInfo = + | string + | { message: string; status: 'warning' | 'error' } + | { [index: string]: string | { message: string; status: 'warning' | 'error' } }; + +export interface DFormItemProps extends React.HTMLAttributes { + dLabel?: React.ReactNode; + dLabelWidth?: number | string; + dLabelExtra?: Array<{ title: string; icon?: React.ReactNode } | string>; + dShowRequired?: boolean; + dErrors?: DErrorInfo | Array<[string, DErrorInfo]>; + dSpan?: number | string | true; + dResponsiveProps?: Record>; +} + +export function DFormItem(props: DFormItemProps) { + const { dLabel, dLabelWidth, dLabelExtra, dShowRequired, dErrors, dSpan, dResponsiveProps, className, children, ...restProps } = + useComponentConfig(DFormItem.name, props); + + //#region Context + const dPrefix = usePrefixConfig(); + const { + formBreakpointMatchs, + formLabelWidth, + formLabelColon, + formCustomLabel, + formInstance, + formLayout, + formInlineSpan, + formFeedbackIcon, + } = useContext(DFormContext) as DFormContextData; + const [{ formGroupPath }] = useCustomContext(DFormGroupContext); + //#endregion + + const [t] = useTranslation('DForm'); + + const dataRef = useRef<{ preErrors: DErrors }>({ + preErrors: [], + }); + + const { span, labelWidth } = (() => { + const _props = { + span: dSpan ?? (formLayout === 'inline' ? formInlineSpan : 12), + labelWidth: dLabelWidth ?? formLabelWidth, + }; + + if (dResponsiveProps) { + const keys = Object.keys(dResponsiveProps); + const mergeProps = (point: string, targetKey: string, sourceKey: string) => { + const value = dResponsiveProps[point][sourceKey]; + if (!isUndefined(value)) { + _props[targetKey] = value; + } + }; + for (const point of [...MEDIA_QUERY_LIST].reverse()) { + if (keys.includes(point) && formBreakpointMatchs.includes(point)) { + mergeProps(point, 'span', 'dSpan'); + mergeProps(point, 'labelWidth', 'dLabelWidth'); + } + } + } + return _props; + })(); + + const [formItems, setFormItems] = useImmer(new Map()); + + const getControl = useCallback( + (formControlName: string) => { + const control = formInstance.form.get((formGroupPath ?? []).concat([formControlName])); + if (isNull(control)) { + throw new Error(`Cant find '${formControlName}', please check if name exists!`); + } + return control; + }, + [formGroupPath, formInstance] + ); + + const [errors, hasError, errorStyle, status] = useMemo< + [Array<{ identity: string; errors: DErrors }>, boolean, 'error' | 'warning', DValidateStatus | undefined] + >(() => { + const errors: DErrors = []; + let hasError = false; + let status: DValidateStatus | undefined = undefined; + const setStatus = (formControlStatus: FormControlStatus) => { + if (formControlStatus === 'PENDING') { + status = 'pending'; + } + if (formControlStatus === 'INVALID' && status !== 'pending') { + status = 'error'; + } + if (formControlStatus === 'VALID' && status === undefined) { + status = 'success'; + } + }; + + if (dErrors) { + const getErrors = (identity: string, formControl: AbstractControl, errorInfo: DErrorInfo) => { + if (isString(errorInfo)) { + errors.push({ identity, key: identity, message: errorInfo, status: 'error' }); + } else if (Object.keys(errorInfo).length === 2 && 'message' in errorInfo && 'status' in errorInfo) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errors.push({ identity, key: identity, ...errorInfo } as any); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + for (const key of Object.keys(formControl.errors!)) { + if (key in errorInfo) { + if (isString(errorInfo[key])) { + errors.push({ identity, key: `${identity}-${key}`, message: errorInfo[key], status: 'error' }); + } else { + errors.push({ identity, key: `${identity}-${key}`, ...errorInfo[key] }); + } + } + } + } + }; + + for (const [identity, { formControlName }] of formItems.entries()) { + const formControl = getControl(formControlName); + if (formControl.dirty) { + setStatus(formControl.status); + + if (formControl.invalid && formControl.errors) { + hasError = true; + if (isArray(dErrors)) { + const errorInfo = dErrors.find((item) => item[0] === formControlName); + if (errorInfo) { + getErrors(identity, formControl, errorInfo[1]); + } + } else { + getErrors(identity, formControl, dErrors); + } + } + } + } + } + + const errorStyle = errors.findIndex((item) => item.status === 'error') !== -1 ? 'error' : 'warning'; + if (errorStyle === 'warning' && status === 'error') { + status = 'warning'; + } + + const preErrors = dataRef.current.preErrors; + dataRef.current.preErrors = errors; + preErrors.forEach((error, inedx) => { + if (errors.findIndex((item) => item.key === error.key) === -1) { + errors.splice(inedx, 0, { ...error, hidden: true }); + } + }); + + const identitys = new Set(errors.map((item) => item.identity)); + const _errors: Array<{ identity: string; errors: DErrors }> = []; + identitys.forEach((identity) => { + _errors.push({ identity, errors: errors.filter((item) => item.identity === identity) }); + }); + + return [_errors, hasError, errorStyle, status]; + }, [dErrors, formItems, getControl]); + + const feedbackIcon = useMemo(() => { + if (isUndefined(status)) { + return null; + } else { + const statusIcons = { + pending: ( + + + + ), + error: ( + + + + ), + warning: ( + + + + ), + success: ( + + + + ), + }; + if (isBoolean(formFeedbackIcon)) { + return statusIcons[status]; + } else { + return formFeedbackIcon[status] ?? statusIcons[status]; + } + } + }, [formFeedbackIcon, status]); + + const required = useMemo(() => { + if (isBoolean(dShowRequired)) { + return dShowRequired; + } + for (const { formControlName } of formItems.values()) { + if (getControl(formControlName).hasValidator(Validators.required)) { + return true; + } + } + return false; + }, [dShowRequired, formItems, getControl]); + + const id = useMemo(() => { + if (formItems.size === 1) { + for (const { id } of formItems.values()) { + return id; + } + } + }, [formItems]); + + const extraNode = useMemo(() => { + if (dLabelExtra) { + return dLabelExtra.map((extra, index) => { + if (isString(extra)) { + return {extra}; + } else { + return ( + + {extra.icon ?? ( + + + + + )} + + ); + } + }); + } + }, [dLabelExtra]); + + const stateBackflowContextValue = useMemo( + () => ({ + addState: (identity, formControlName, id) => { + if (isString(formControlName)) { + setFormItems((draft) => { + draft.set(identity, { formControlName, id }); + }); + } + }, + updateState: (identity, formControlName, id) => { + setFormItems((draft) => { + if (isString(formControlName)) { + draft.set(identity, { formControlName, id }); + } else { + draft.delete(identity); + } + }); + }, + removeState: (identity) => { + setFormItems((draft) => { + draft.delete(identity); + }); + }, + }), + [setFormItems] + ); + return ( + +
    +
    + {labelWidth !== 0 && + (dLabel ? ( +
    + +
    + ) : ( +
    + ))} +
    + {status === 'pending' && ( + <> + + + + + + )} + {children} +
    + {errors.map((errors) => ( +
    + {errors.errors.map((error) => ( + { + dataRef.current.preErrors = dataRef.current.preErrors.filter((item) => item.key !== error.key); + }} + > + ))} +
    + ))} +
    +
    + {formFeedbackIcon && ( +
    + {feedbackIcon} +
    + )} +
    +
    + {errors.map((errors) => ( +
    + {errors.errors.map((error) => ( + { + dataRef.current.preErrors = dataRef.current.preErrors.filter((item) => item.key !== error.key); + }} + > + ))} +
    + ))} +
    +
    +
    + ); } -export const DFormItemContext = React.createContext(null); diff --git a/packages/ui/src/components/form/README.md b/packages/ui/src/components/form/README.md new file mode 100644 index 00000000..f9b78f30 --- /dev/null +++ b/packages/ui/src/components/form/README.md @@ -0,0 +1,278 @@ +--- +group: Data Entry +title: Form +--- + +Data entry and verification. + +## When To Use + +Need to verify the data. + +## API + +### DFormProps + +Extend `React.FormHTMLAttributes`. + + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| dForm | Bind the instance returned by [useForm](#useForm) | DFormInstance | - | +| dLabelWidth | Label length | string \| number | - | +| dLabelColon | Whether the label shows a colon | boolean | - | +| dCustomLabel | Custom label | 'required' \| 'optional' \| 'hidden' | 'required' | +| dLayout | Form layout | 'horizontal' \| 'vertical' \| 'inline' | 'horizontal' | +| dInlineSpan | Set the number of grids occupied by each form item in the row layout, a total of 12 grids | number \| true | 6 | +| dFeedbackIcon | Set the verification result feedback icon | boolean \| `{ success?: React.ReactNode; warning?: React.ReactNode; error?: React.ReactNode; pending?: React.ReactNode; }` | false | +| dSize | Set form size | 'smaller' \| 'larger' | - | +| dResponsiveProps | Responsive layout | `Record>` | - | + + +### DFormGroupProps + +Extend `React.HTMLAttributes`. + + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| dFormGroupName | Check field name | string | - | +| dTitle | title | React.ReactNode | - | + + +### DFormItem + +Extend `React.HTMLAttributes`. + + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| dLabel | Label | React.ReactNode | - | +| dLabelWidth | Label length, 0 will not render the label | number \| string | - | +| dLabelExtra | The additional information of the label, the object is rendered as a tooltip | `Array<{ title: string; icon?: React.ReactNode } \| string>` | - | +| dShowRequired | Is it displayed as required | boolean | - | +| dErrors | For information about verification failure, please refer to [Validity result](#FormValidityResultDemo) for specific usage | `DErrorInfo \| Array<[string, DErrorInfo]>` | - | +| dSpan | Number of grids occupied by form items | number \| string \| true | - | +| dResponsiveProps | Responsive layout | `Record>` | - | + + +### DErrorInfo + +```tsx +type DErrorInfo = + | string + | { message: string; status: 'warning' | 'error' } + | { [index: string]: string | { message: string; status: 'warning' | 'error' } }; +``` + +### useForm + +```tsx +function useForm(initData: () => FormGroup): DFormInstance; + +interface DFormInstance { + form: FormGroup; + resetForm: () => void; + updateForm: () => void; +} +``` + +### AbstractControl + +```tsx +/** + * A form can have several different statuses. Each + * possible status is returned as a string literal. + * + * * **VALID**: Reports that a FormControl is valid, meaning that no errors exist in the input + * value. + * * **INVALID**: Reports that a FormControl is invalid, meaning that an error exists in the input + * value. + * * **PENDING**: Reports that a FormControl is pending, meaning that that async validation is + * occurring and errors are not yet available for the input value. + * * **DISABLED**: Reports that a FormControl is + * disabled, meaning that the control is exempt from ancestor calculations of validity or value. + * + */ +type FormControlStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'; + +// Defines the map of errors returned from failed validation checks. +interface ValidationErrors { + [key: string]: any; +} + +// A function that receives a control and synchronously returns a map of validation errors if present, otherwise null. +type ValidatorFn = (control: AbstractControl) => ValidationErrors | null; + +// A function that receives a control and returns a Promise or observable that emits validation errors if present, otherwise null. +type AsyncValidatorFn = (control: AbstractControl) => Promise; + +abstract class AbstractControl { + constructor( + // The function or array of functions that is used to determine the validity of this control synchronously. + validators: ValidatorFn | ValidatorFn[] | null, + // The function or array of functions that is used to determine validity of this control asynchronously. + asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + // Returns the function that is used to determine the validity of this control synchronously. + validator: ValidatorFn | null; + + // Returns the function that is used to determine the validity of this control asynchronously. + asyncValidator: AsyncValidatorFn | null; + + // The parent control. + readonly parent: AbstractControl; + + // Retrieves the top-level ancestor of this control. + readonly root: AbstractControl; + + // The current value of the control. + readonly value: any; + + // The validation status of the control. + readonly status: FormControlStatus; + + // An object containing any errors generated by failing validation, or null if there are no errors. + readonly errors: ValidationErrors | null; + + // A control is valid when its status is VALID. + readonly valid: boolean; + + // A control is invalid when its status is INVALID. + readonly invalid: boolean; + + // A control is pending when its status is PENDING. + readonly pending: boolean; + + // A control is disabled when its status is DISABLED. + readonly disabled: boolean; + + // A control is enabled as long as its status is not DISABLED. + readonly enabled: boolean; + + // A control is pristine if onChange has not yet called. + readonly pristine: boolean; + + // A control is dirty if onChange has called. + readonly dirty: boolean; + + // Sets the synchronous validators that are active on this control. Calling this overwrites any existing synchronous validators. + setValidators(validators: ValidatorFn | ValidatorFn[] | null): void; + + // Sets the asynchronous validators that are active on this control. Calling this overwrites any existing asynchronous validators. + setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[] | null): void; + + // Add a synchronous validator or validators to this control, without affecting other validators. + addValidators(validators: ValidatorFn | ValidatorFn[]): void; + + // Add an asynchronous validator or validators to this control, without affecting other validators. + addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + + // Remove a synchronous validator from this control, without affecting other validators. + removeValidators(validators: ValidatorFn | ValidatorFn[]): void; + + // Remove an asynchronous validator from this control, without affecting other validators. + removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + + // Check whether a synchronous validator function is present on this control. + hasValidator(validator: ValidatorFn): boolean; + + // Check whether an asynchronous validator function is present on this control. + hasAsyncValidator(validator: AsyncValidatorFn): boolean; + + // Empties out the synchronous validator list. + clearValidators(): void; + + // Empties out the async validator list. + clearAsyncValidators(): void; + + // Marks the control as dirty. + markAsDirty(onlySelf = false): void; + + // Marks the control as pristine. + markAsPristine(onlySelf = false): void; + + // Marks the control as pending. + markAsPending(onlySelf = false): void; + + // Disables the control. This means the control is exempt from validation checks and excluded from the aggregate value of any parent. + disable(onlySelf = false): void; + + // Enables the control. This means the control is included in validation checks and the aggregate value of its parent. + enable(onlySelf = false): void; + + // Sets the parent of the control. + setParent(parent: FormGroup): void; + + // Sets errors on a form control when running validations manually, rather than automatically. + setErrors(errors: ValidationErrors | null): void; + + // Recalculates the value and validation status of the control. + updateValueAndValidity(onlySelf = false): void; + + // Retrieves a child control given the control's name or path. + get(path: string[] | string): AbstractControl | null; + + // Reports error data for the control with the given path. + getError(errorCode: string, path?: string[] | string): any; + + // Reports whether the control with the given path has the error specified. + hasError(errorCode: string, path?: string[] | string): boolean; + + // Sets the value of the control. + setValue(value: any, onlySelf?: boolean): void; + + // Patches the value of the control. + patchValue(value: any, onlySelf?: boolean): void; + + // Resets the control. + reset(formState: any = null, onlySelf?: boolean): void; +} +``` + +### FormControl + +```tsx +class FormControl extends AbstractControl { + constructor( + // Initializes the control with an initial value, or an object that defines the initial value and disabled state. + formState: any = null, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + override setValue(value: any, onlySelf?: boolean): void; + override patchValue(value: any, onlySelf?: boolean): void; + override reset(formState: any = null, onlySelf?: boolean): void; +} +``` + +### FormControl + +```tsx +class FormGroup extends AbstractControl { + constructor( + // A collection of child controls. The key for each child is the name under which it is registered. + public controls: { [key: string]: AbstractControl }, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + // Add a control to this group. + addControl(name: string, control: AbstractControl): void; + + // Remove a control from this group. + removeControl(name: string): void; + + // Replace an existing control. + setControl(name: string, control: AbstractControl): void; + + // Check whether there is an enabled control with the given name in the group. + contains(controlName: string): boolean; + + override setValue(value: { [key: string]: any }, onlySelf?: boolean): void; + override patchValue(value: { [key: string]: any }, onlySelf?: boolean): void; + override reset(value: any = {}, onlySelf?: boolean): void; +} +``` diff --git a/packages/ui/src/components/form/README.zh-Hant.md b/packages/ui/src/components/form/README.zh-Hant.md new file mode 100644 index 00000000..395af1f1 --- /dev/null +++ b/packages/ui/src/components/form/README.zh-Hant.md @@ -0,0 +1,272 @@ +--- +title: 表单 +--- + +数据录入、校验。 + +## 何时使用 + +需要校验数据。 + +## API + +### DFormProps + +继承 `React.FormHTMLAttributes`。 + + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| dForm | 绑定 [useForm](#useForm) 返回的实例 | DFormInstance | - | +| dLabelWidth | 标签长度 | string \| number | - | +| dLabelColon | 标签是否显示冒号 | boolean | - | +| dCustomLabel | 自定义标签 | 'required' \| 'optional' \| 'hidden' | 'required' | +| dLayout | 表单布局 | 'horizontal' \| 'vertical' \| 'inline' | 'horizontal' | +| dInlineSpan | 设置行内布局每个表单项占据的格数,共有 12 格 | number \| true | 6 | +| dFeedbackIcon | 设置校验结果反馈图标 | boolean \| `{ success?: React.ReactNode; warning?: React.ReactNode; error?: React.ReactNode; pending?: React.ReactNode; }` | false | +| dSize | 设置表单尺寸 | 'smaller' \| 'larger' | - | +| dResponsiveProps | 响应式布局 | `Record>` | - | + + +### DFormGroupProps + +继承 `React.HTMLAttributes`。 + + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| dFormGroupName | 校验字段名 | string | - | +| dTitle | 标题 | React.ReactNode | - | + + +### DFormItem + +继承 `React.HTMLAttributes`。 + + +| 参数 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| dLabel | 标签 | React.ReactNode | - | +| dLabelWidth | 标签长度,为 0 不渲染标签 | number \| string | - | +| dLabelExtra | 标签的额外信息,对象为表示渲染为 tooltip | `Array<{ title: string; icon?: React.ReactNode } \| string>` | - | +| dShowRequired | 是否显示为必填项 | boolean | - | +| dErrors | 校验失败的信息,具体用法参考 [校验结果](#FormValidityResultDemo) | `DErrorInfo \| Array<[string, DErrorInfo]>` | - | +| dSpan | 表单项占据的格数 | number \| string \| true | - | +| dResponsiveProps | 响应式布局 | `Record>` | - | + + +### DErrorInfo + +```tsx +type DErrorInfo = + | string + | { message: string; status: 'warning' | 'error' } + | { [index: string]: string | { message: string; status: 'warning' | 'error' } }; +``` + +### useForm + +```tsx +function useForm(initData: () => FormGroup): DFormInstance; + +interface DFormInstance { + form: FormGroup; + resetForm: () => void; + updateForm: () => void; +} +``` + +### AbstractControl + +```tsx +/** + * A form can have several different statuses. Each possible status is returned as a string literal. + * + * * **VALID**: Reports that a FormControl is valid, meaning that no errors exist in the input value. + * * **INVALID**: Reports that a FormControl is invalid, meaning that an error exists in the input value. + * * **PENDING**: Reports that a FormControl is pending, meaning that that async validation is occurring and errors are not yet available for the input value. + * * **DISABLED**: Reports that a FormControl is disabled, meaning that the control is exempt from ancestor calculations of validity or value. + * + */ +type FormControlStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'; + +// Defines the map of errors returned from failed validation checks. +interface ValidationErrors { + [key: string]: any; +} + +// A function that receives a control and synchronously returns a map of validation errors if present, otherwise null. +type ValidatorFn = (control: AbstractControl) => ValidationErrors | null; + +// A function that receives a control and returns a Promise or observable that emits validation errors if present, otherwise null. +type AsyncValidatorFn = (control: AbstractControl) => Promise; + +abstract class AbstractControl { + constructor( + // The function or array of functions that is used to determine the validity of this control synchronously. + validators: ValidatorFn | ValidatorFn[] | null, + // The function or array of functions that is used to determine validity of this control asynchronously. + asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + // Returns the function that is used to determine the validity of this control synchronously. + validator: ValidatorFn | null; + + // Returns the function that is used to determine the validity of this control asynchronously. + asyncValidator: AsyncValidatorFn | null; + + // The parent control. + readonly parent: AbstractControl; + + // Retrieves the top-level ancestor of this control. + readonly root: AbstractControl; + + // The current value of the control. + readonly value: any; + + // The validation status of the control. + readonly status: FormControlStatus; + + // An object containing any errors generated by failing validation, or null if there are no errors. + readonly errors: ValidationErrors | null; + + // A control is valid when its status is VALID. + readonly valid: boolean; + + // A control is invalid when its status is INVALID. + readonly invalid: boolean; + + // A control is pending when its status is PENDING. + readonly pending: boolean; + + // A control is disabled when its status is DISABLED. + readonly disabled: boolean; + + // A control is enabled as long as its status is not DISABLED. + readonly enabled: boolean; + + // A control is pristine if onChange has not yet called. + readonly pristine: boolean; + + // A control is dirty if onChange has called. + readonly dirty: boolean; + + // Sets the synchronous validators that are active on this control. Calling this overwrites any existing synchronous validators. + setValidators(validators: ValidatorFn | ValidatorFn[] | null): void; + + // Sets the asynchronous validators that are active on this control. Calling this overwrites any existing asynchronous validators. + setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[] | null): void; + + // Add a synchronous validator or validators to this control, without affecting other validators. + addValidators(validators: ValidatorFn | ValidatorFn[]): void; + + // Add an asynchronous validator or validators to this control, without affecting other validators. + addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + + // Remove a synchronous validator from this control, without affecting other validators. + removeValidators(validators: ValidatorFn | ValidatorFn[]): void; + + // Remove an asynchronous validator from this control, without affecting other validators. + removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + + // Check whether a synchronous validator function is present on this control. + hasValidator(validator: ValidatorFn): boolean; + + // Check whether an asynchronous validator function is present on this control. + hasAsyncValidator(validator: AsyncValidatorFn): boolean; + + // Empties out the synchronous validator list. + clearValidators(): void; + + // Empties out the async validator list. + clearAsyncValidators(): void; + + // Marks the control as dirty. + markAsDirty(onlySelf = false): void; + + // Marks the control as pristine. + markAsPristine(onlySelf = false): void; + + // Marks the control as pending. + markAsPending(onlySelf = false): void; + + // Disables the control. This means the control is exempt from validation checks and excluded from the aggregate value of any parent. + disable(onlySelf = false): void; + + // Enables the control. This means the control is included in validation checks and the aggregate value of its parent. + enable(onlySelf = false): void; + + // Sets the parent of the control. + setParent(parent: FormGroup): void; + + // Sets errors on a form control when running validations manually, rather than automatically. + setErrors(errors: ValidationErrors | null): void; + + // Recalculates the value and validation status of the control. + updateValueAndValidity(onlySelf = false): void; + + // Retrieves a child control given the control's name or path. + get(path: string[] | string): AbstractControl | null; + + // Reports error data for the control with the given path. + getError(errorCode: string, path?: string[] | string): any; + + // Reports whether the control with the given path has the error specified. + hasError(errorCode: string, path?: string[] | string): boolean; + + // Sets the value of the control. + setValue(value: any, onlySelf?: boolean): void; + + // Patches the value of the control. + patchValue(value: any, onlySelf?: boolean): void; + + // Resets the control. + reset(formState: any = null, onlySelf?: boolean): void; +} +``` + +### FormControl + +```tsx +class FormControl extends AbstractControl { + constructor( + // Initializes the control with an initial value, or an object that defines the initial value and disabled state. + formState: any = null, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + override setValue(value: any, onlySelf?: boolean): void; + override patchValue(value: any, onlySelf?: boolean): void; + override reset(formState: any = null, onlySelf?: boolean): void; +} +``` + +### FormControl + +```tsx +class FormGroup extends AbstractControl { + constructor( + // A collection of child controls. The key for each child is the name under which it is registered. + public controls: { [key: string]: AbstractControl }, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ); + + // Add a control to this group. + addControl(name: string, control: AbstractControl): void; + + // Remove a control from this group. + removeControl(name: string): void; + + // Replace an existing control. + setControl(name: string, control: AbstractControl): void; + + // Check whether there is an enabled control with the given name in the group. + contains(controlName: string): boolean; + + override setValue(value: { [key: string]: any }, onlySelf?: boolean): void; + override patchValue(value: { [key: string]: any }, onlySelf?: boolean): void; + override reset(value: any = {}, onlySelf?: boolean): void; +} +``` diff --git a/packages/ui/src/components/form/demos/1.Basic.md b/packages/ui/src/components/form/demos/1.Basic.md new file mode 100644 index 00000000..df3c5dc4 --- /dev/null +++ b/packages/ui/src/components/form/demos/1.Basic.md @@ -0,0 +1,45 @@ +--- +title: + en-US: Basic + zh-Hant: 基本 +--- + +# en-US + +The simplest usage. + +# zh-Hant + +最简单的用法。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DInputAffix, DButton } from '@react-devui/ui'; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + + return ( + + + + + + + + + + + + Submit + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/10.FormGroup.md b/packages/ui/src/components/form/demos/10.FormGroup.md new file mode 100644 index 00000000..db3cc519 --- /dev/null +++ b/packages/ui/src/components/form/demos/10.FormGroup.md @@ -0,0 +1,54 @@ +--- +title: + en-US: Form group + zh-Hant: 表单分组 +--- + +# en-US + +Use `DFormGroup` to group forms. When nesting `FormGroup`, `DFormGroup` needs to correspond to the corresponding structure. + +# zh-Hant + +通过 `DFormGroup` 对表单进行分组,嵌套 `FormGroup` 时,需要 `DFormGroup` 对应相应结构。 + +```tsx +import { DForm, DFormItem, DFormGroup, FormControl, FormGroup, Validators, useForm, DInput } from '@react-devui/ui'; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + group1: new FormGroup({ + username: new FormControl('', Validators.required), + }), + group2: new FormGroup({ + username: new FormControl('', Validators.required), + group3: new FormGroup({ + username: new FormControl('', Validators.required), + }), + }), + }) + ); + + return ( + + + + + + + + + + + + + + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/11.Size.md b/packages/ui/src/components/form/demos/11.Size.md new file mode 100644 index 00000000..81eef903 --- /dev/null +++ b/packages/ui/src/components/form/demos/11.Size.md @@ -0,0 +1,80 @@ +--- +title: + en-US: Size + zh-Hant: 尺寸 +--- + +# en-US + +Adjust components size by setting `dSize`. + +# zh-Hant + +通过设置 `dSize` 调整组件尺寸。 + +```tsx +import { useState } from 'react'; + +import { + DForm, + DFormItem, + FormControl, + FormGroup, + Validators, + useForm, + DInput, + DInputAffix, + DButton, + DRadioGroup, + DRadio, +} from '@react-devui/ui'; + +export default function Demo() { + const [layout, setLayout] = useState('horizontal'); + const [size, setSize] = useState(undefined); + + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + + return ( + <> + + {['horizontal', 'vertical', 'inline'].map((layout) => ( + + {layout} + + ))} + +
    + + {['smaller', 'default', 'larger'].map((size) => ( + + {size} + + ))} + +
    + + + + + + + + + + + + Submit + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/12.DynamicallySetting.md b/packages/ui/src/components/form/demos/12.DynamicallySetting.md new file mode 100644 index 00000000..bd444d67 --- /dev/null +++ b/packages/ui/src/components/form/demos/12.DynamicallySetting.md @@ -0,0 +1,131 @@ +--- +title: + en-US: Dynamic setting + zh-Hant: 动态设置 +--- + +# en-US + +The API design comes from [Angular form](https://angular.io/guide/forms-overview), which can handle various complex usage scenarios easily. + +Make sure to execute `formInstance.updateForm()` once after the modification is completed to update the form. + +# zh-Hant + +API 设计来源于 [Angular form](https://angular.io/guide/forms-overview),可以轻松应对各种复杂的使用场景。 + +确保修改完成后执行一次 `formInstance.updateForm()` 以更新表单。 + +```tsx +import { useCallback } from 'react'; + +import { + DForm, + DFormItem, + DFormGroup, + FormControl, + FormGroup, + Validators, + useForm, + DInput, + DInputAffix, + DButton, + DIcon, +} from '@react-devui/ui'; +import { useImmer } from '@react-devui/ui/hooks'; + +let n = 1; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + group1: new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }), + }) + ); + const [formItems, setFormItems] = useImmer([1]); + + const handleAdd = useCallback(() => { + n += 1; + formInstance.form.addControl( + `group${n}`, + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + formInstance.updateForm(); + setFormItems((draft) => { + draft.push(n); + }); + }, [formInstance, setFormItems]); + + const handleInit = useCallback(() => { + formInstance.resetForm(); + formInstance.updateForm(); + setFormItems([1]); + }, [formInstance, setFormItems]); + + const handleReset = useCallback(() => { + formInstance.form.reset(); + formInstance.updateForm(); + }, [formInstance]); + + const handleFill = useCallback(() => { + formItems.forEach((n) => { + formInstance.form.get(`group${n}`).setValue({ + username: 'username' + n, + password: 'password' + n, + }); + }); + formInstance.updateForm(); + }, [formItems, formInstance]); + + const handleRemove = useCallback( + (n) => { + formInstance.form.removeControl(`group${n}`); + formInstance.updateForm(); + setFormItems((draft) => { + const index = draft.findIndex((_n) => _n === n); + draft.splice(index, 1); + }); + }, + [formInstance, setFormItems] + ); + + return ( + <> + + {formItems.map((n) => ( + + + + + + + + + +
    + } onClick={() => handleRemove(n)} /> +
    +
    + ))} +
    +
    + Add + Init + Reset + Fill + + Submit + +
    +
    {JSON.stringify(formInstance.form.value)}
    + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/13.SupportComponents.md b/packages/ui/src/components/form/demos/13.SupportComponents.md new file mode 100644 index 00000000..868b2c08 --- /dev/null +++ b/packages/ui/src/components/form/demos/13.SupportComponents.md @@ -0,0 +1,99 @@ +--- +title: + en-US: Supported components + zh-Hant: 支持的组件 +--- + +# en-US + +All supported components are shown here. + +# zh-Hant + +这里展示了所有受支持的组件。 + +```tsx +import { useCallback, useState } from 'react'; + +import { + DForm, + DFormItem, + FormControl, + FormGroup, + useForm, + DInput, + DInputAffix, + DButton, + DRadio, + DRadioGroup, + DSelect, + DTextarea, +} from '@react-devui/ui'; + +export default function Demo() { + const [options] = useState(() => + Array(100) + .fill(0) + .map((item, index) => ({ dLabel: `Option ${index + 1}`, dValue: index + 1, dDisabled: index === 3 })) + ); + const formInstance = useForm( + () => + new FormGroup({ + Input: new FormControl('', () => ({ error: true })), + Number: new FormControl('', () => ({ error: true })), + Radio: new FormControl(true, () => ({ error: true })), + RadioGroup: new FormControl(1, () => ({ error: true })), + Select: new FormControl(50, () => ({ error: true })), + MultipleSelect: new FormControl([30, 50, 70], () => ({ error: true })), + Textarea: new FormControl('', () => ({ error: true })), + }) + ); + + const handleVerify = useCallback( + (n) => { + Object.values(formInstance.form.controls).forEach((control) => { + control.markAsDirty(); + }); + formInstance.updateForm(); + }, + [formInstance] + ); + + return ( + + + + + + + + + + + Radio + + + + {[1, 2, 3].map((n) => ( + + Radio {n} + + ))} + + + + + + + + + + + + + Verify + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/2.Layout.md b/packages/ui/src/components/form/demos/2.Layout.md new file mode 100644 index 00000000..2b0723d6 --- /dev/null +++ b/packages/ui/src/components/form/demos/2.Layout.md @@ -0,0 +1,71 @@ +--- +title: + en-US: Layout + zh-Hant: 表单布局 +--- + +# en-US + +The form supports three layouts. + +# zh-Hant + +表单支持三种布局。 + +```tsx +import { useState } from 'react'; + +import { + DForm, + DFormItem, + FormControl, + FormGroup, + Validators, + useForm, + DInput, + DInputAffix, + DButton, + DRadioGroup, + DRadio, +} from '@react-devui/ui'; + +export default function Demo() { + const [layout, setLayout] = useState('horizontal'); + + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + + return ( + <> + + {['horizontal', 'vertical', 'inline'].map((layout) => ( + + {layout} + + ))} + +
    + + + + + + + + + + + + Submit + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/3.IrregularLayout.md b/packages/ui/src/components/form/demos/3.IrregularLayout.md new file mode 100644 index 00000000..546a81a2 --- /dev/null +++ b/packages/ui/src/components/form/demos/3.IrregularLayout.md @@ -0,0 +1,67 @@ +--- +title: + en-US: Irregular layout + zh-Hant: 不规则布局 +--- + +# en-US + +You can directly set the `dSpan` value for `DFormItem` to implement custom layout. + +# zh-Hant + +可直接为 `DFormItem` 设置 `dSpan` 值来实现自定义布局。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DCompose, DInputAffix } from '@react-devui/ui'; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + username1: new FormControl('', Validators.required), + username2: new FormControl('', Validators.required), + username3: new FormControl('', Validators.required), + username4: new FormControl('', Validators.required), + username5: new FormControl('', Validators.required), + username6: new FormControl('', Validators.required), + number: new FormControl('', Validators.required), + }) + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/4.ResponsiveLayout.md b/packages/ui/src/components/form/demos/4.ResponsiveLayout.md new file mode 100644 index 00000000..bae4f935 --- /dev/null +++ b/packages/ui/src/components/form/demos/4.ResponsiveLayout.md @@ -0,0 +1,67 @@ +--- +title: + en-US: Responsive layout + zh-Hant: 响应式布局 +--- + +# en-US + +Both `DForm` and `DFormItem` support responsive layout, please refer to [API](#API) for details. + +# zh-Hant + +`DForm` 和 `DFormItem` 均支持响应式布局,具体请参考 [API](#API)。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DInputAffix, DButton } from '@react-devui/ui'; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + + return ( + + + + + + + + + + + + Submit + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/5.CustomLabel.md b/packages/ui/src/components/form/demos/5.CustomLabel.md new file mode 100644 index 00000000..59fa36fb --- /dev/null +++ b/packages/ui/src/components/form/demos/5.CustomLabel.md @@ -0,0 +1,60 @@ +--- +title: + en-US: Custom label + zh-Hant: 自定义标签 +--- + +# en-US + +`DFormItem` supports `dLabelExtra` to configure additional information, `DForm` supports `dCustomLabel` global custom label. + +# zh-Hant + +`DFormItem` 支持 `dLabelExtra` 配置额外信息,`DForm` 支持 `dCustomLabel` 全局自定义标签。 + +```tsx +import { useState } from 'react'; + +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DRadioGroup, DRadio, DIcon } from '@react-devui/ui'; + +export default function Demo() { + const [label, setLabel] = useState('required'); + + const formInstance = useForm( + () => + new FormGroup({ + username1: new FormControl('', Validators.required), + username2: new FormControl('', Validators.required), + username3: new FormControl('', Validators.required), + }) + ); + + return ( + <> + + {['required', 'optional', 'hidden'].map((label) => ( + + {label} + + ))} + +
    + + + + + + + + }]} + > + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/6.ValidityResult.md b/packages/ui/src/components/form/demos/6.ValidityResult.md new file mode 100644 index 00000000..cb29e68a --- /dev/null +++ b/packages/ui/src/components/form/demos/6.ValidityResult.md @@ -0,0 +1,75 @@ +--- +title: + en-US: Validity result + zh-Hant: 校验结果 +--- + +# en-US + +Use `dErrors` to adapt to more complex scenarios. + +# zh-Hant + +通过 `dErrors` 来适应更复杂的场景。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DCompose } from '@react-devui/ui'; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + username1: new FormControl('', Validators.required), + username2: new FormControl('', [Validators.required, Validators.minLength(5), Validators.maxLength(12)]), + username3: new FormControl('', Validators.required), + username4: new FormControl('', [Validators.required, Validators.minLength(5)]), + }) + ); + + return ( + + + + + + + + + + + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/7.FeedbackIcon.md b/packages/ui/src/components/form/demos/7.FeedbackIcon.md new file mode 100644 index 00000000..000f657e --- /dev/null +++ b/packages/ui/src/components/form/demos/7.FeedbackIcon.md @@ -0,0 +1,77 @@ +--- +title: + en-US: Feedback icon + zh-Hant: 反馈图标 +--- + +# en-US + +通过 `dFeedbackIcon` 来配置反馈图标。 + +# zh-Hant + +通过 `dFeedbackIcon` 来配置反馈图标。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, Validators, useForm, DInput, DIcon } from '@react-devui/ui'; + +export default function Demo() { + const searchIcon = ; + + const formInstance1 = useForm( + () => + new FormGroup({ + username: new FormControl('', [Validators.required, Validators.minLength(5), Validators.maxLength(12)]), + }) + ); + const formInstance2 = useForm( + () => + new FormGroup({ + username: new FormControl('', [Validators.required, Validators.minLength(5), Validators.maxLength(12)]), + }) + ); + + return ( + <> + + + + + +
    + + + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/8.AsynchronousVerification.md b/packages/ui/src/components/form/demos/8.AsynchronousVerification.md new file mode 100644 index 00000000..0fe17b9d --- /dev/null +++ b/packages/ui/src/components/form/demos/8.AsynchronousVerification.md @@ -0,0 +1,58 @@ +--- +title: + en-US: Asynchronous verification + zh-Hant: 异步校验 +--- + +# en-US + +The form supports asynchronous validation. + +# zh-Hant + +表单支持异步校验。 + +```tsx +import { DForm, DFormItem, FormControl, FormGroup, useForm, DInput } from '@react-devui/ui'; + +const asyncValidatorFn = (control) => { + return new Promise((r) => { + setTimeout(() => { + if (control.value.length > 5) { + r({ maxLength: true }); + } else if (control.value.length > 0) { + r(null); + } else { + r({ required: true }); + } + }, 1000); + }); +}; + +export default function Demo() { + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', [], asyncValidatorFn), + }) + ); + + return ( + + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/demos/9.Disabled.md b/packages/ui/src/components/form/demos/9.Disabled.md new file mode 100644 index 00000000..3c6dc8df --- /dev/null +++ b/packages/ui/src/components/form/demos/9.Disabled.md @@ -0,0 +1,84 @@ +--- +title: + en-US: Disable + zh-Hant: 禁用 +--- + +# en-US + +Disabled form items will not participate in verification. + +# zh-Hant + +禁用的表单项不会参与校验。 + +```tsx +import { useState, useCallback } from 'react'; + +import { + DForm, + DFormItem, + FormControl, + FormGroup, + Validators, + useForm, + DInput, + DInputAffix, + DButton, + DRadioGroup, + DRadio, +} from '@react-devui/ui'; + +export default function Demo() { + const [disabled, setDisabled] = useState(false); + + const formInstance = useForm( + () => + new FormGroup({ + username: new FormControl('', Validators.required), + password: new FormControl('', Validators.required), + }) + ); + + const changeDisabled = useCallback( + (disabled) => { + if (disabled) { + formInstance.form.get('username').disable(); + formInstance.updateForm(); + } else { + formInstance.form.get('username').enable(); + formInstance.updateForm(); + } + }, + [formInstance] + ); + + return ( + <> + + {[true, false].map((disabled) => ( + + {disabled ? 'Disabled' : 'No disabled'} + + ))} + +
    + + + + + + + + + + + + Submit + + + + + ); +} +``` diff --git a/packages/ui/src/components/form/form.ts b/packages/ui/src/components/form/form.ts new file mode 100644 index 00000000..1e797121 --- /dev/null +++ b/packages/ui/src/components/form/form.ts @@ -0,0 +1,709 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-empty-function */ + +// Refer to Angular [forms](https://github.com/angular/angular/blob/13.1.1/packages/forms) + +import type { Subscription } from 'rxjs'; + +import { isArray, isNull, isNumber, isString } from 'lodash'; +import { from, Subject } from 'rxjs'; +import { forkJoin } from 'rxjs'; + +export interface ValidationErrors { + [key: string]: any; +} + +export type ValidatorFn = (control: AbstractControl) => ValidationErrors | null; + +export type AsyncValidatorFn = (control: AbstractControl) => Promise; + +export type FormControlStatus = 'VALID' | 'INVALID' | 'PENDING' | 'DISABLED'; + +const [VALID, INVALID, PENDING, DISABLED] = ['VALID', 'INVALID', 'PENDING', 'DISABLED'] as FormControlStatus[]; + +function find(control: AbstractControl, path: string[] | string, delimiter: string) { + if (path == null) return null; + + if (!Array.isArray(path)) { + path = path.split(delimiter); + } + if (Array.isArray(path) && path.length === 0) return null; + + // Not using Array.reduce here due to a Chrome 80 bug + // https://bugs.chromium.org/p/chromium/issues/detail?id=1049982 + let controlToFind: AbstractControl | null = control; + path.forEach((name) => { + if (controlToFind instanceof FormGroup) { + controlToFind = name in controlToFind.controls ? controlToFind.controls[name] : null; + } else { + controlToFind = null; + } + }); + return controlToFind; +} + +function mergeErrors(arrayOfErrors: Array): ValidationErrors | null { + const res: { [key: string]: any } = {}; + + arrayOfErrors.forEach((errors: ValidationErrors | null) => { + if (errors != null) { + Object.assign(res, errors); + } + }); + + return Object.keys(res).length === 0 ? null : res; +} + +function composeValidators(validators: Array): ValidatorFn | null { + const presentValidators = validators.filter((validator) => validator !== null) as ValidatorFn[]; + if (presentValidators.length === 0) { + return null; + } + + return function (control: AbstractControl) { + return mergeErrors(presentValidators.map((fn) => fn(control))); + }; +} +function composeAsyncValidators(validators: Array): AsyncValidatorFn | null { + const presentValidators = validators.filter((validator) => validator !== null) as AsyncValidatorFn[]; + if (presentValidators.length === 0) { + return null; + } + + return function (control: AbstractControl) { + return new Promise((resolve) => { + const observables = forkJoin(presentValidators.map((fn) => fn(control))); + observables.subscribe({ + next: (errors) => { + resolve(mergeErrors(errors)); + }, + }); + }); + }; +} + +function coerceToValidator(validator: ValidatorFn | ValidatorFn[] | null): ValidatorFn | null { + return isArray(validator) ? composeValidators(validator) : validator; +} +function coerceToAsyncValidator(asyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null): AsyncValidatorFn | null { + return isArray(asyncValidator) ? composeAsyncValidators(asyncValidator) : asyncValidator; +} + +function makeValidatorsArray(validators: T | T[] | null): T[] { + if (!validators) { + return []; + } + return isArray(validators) ? validators : [validators]; +} +function hasValidator(validators: T | T[] | null, validator: T): boolean { + return Array.isArray(validators) ? validators.includes(validator) : validators === validator; +} +function addValidators(validators: T | T[], currentValidators: T | T[] | null) { + const current = makeValidatorsArray(currentValidators); + const validatorsToAdd = makeValidatorsArray(validators); + return current.concat(validatorsToAdd.filter((validator) => !hasValidator(current, validator))); +} +function removeValidators(validators: T | T[], currentValidators: T | T[] | null): T[] { + return makeValidatorsArray(currentValidators).filter((validator) => !hasValidator(validators, validator)); +} + +export abstract class AbstractControl { + private _parent: FormGroup | null = null; + + private _pristine: boolean = true; + + private _errors: ValidationErrors | null = null; + + private _hasOwnPendingAsyncValidator = false; + private _asyncValidationSubscription?: Subscription; + + private _composedValidatorFn: ValidatorFn | null; + private _composedAsyncValidatorFn: AsyncValidatorFn | null; + + private _rawValidators: ValidatorFn | ValidatorFn[] | null; + private _rawAsyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null; + + constructor(validators: ValidatorFn | ValidatorFn[] | null, asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null) { + this._rawValidators = validators; + this._rawAsyncValidators = asyncValidators; + this._composedValidatorFn = coerceToValidator(this._rawValidators); + this._composedAsyncValidatorFn = coerceToAsyncValidator(this._rawAsyncValidators); + } + + get validator(): ValidatorFn | null { + return this._composedValidatorFn; + } + set validator(validatorFn: ValidatorFn | null) { + this._rawValidators = this._composedValidatorFn = validatorFn; + } + + get asyncValidator(): AsyncValidatorFn | null { + return this._composedAsyncValidatorFn; + } + set asyncValidator(asyncValidatorFn: AsyncValidatorFn | null) { + this._rawAsyncValidators = this._composedAsyncValidatorFn = asyncValidatorFn; + } + + get parent(): FormGroup | null { + return this._parent; + } + get root(): AbstractControl { + // eslint-disable-next-line @typescript-eslint/no-this-alias + let control: AbstractControl = this; + + while (control.parent) { + control = control.parent; + } + + return control; + } + + get value(): any { + return this._value; + } + + get status(): FormControlStatus { + return this._status; + } + + get errors(): ValidationErrors | null { + return this._errors; + } + + get valid(): boolean { + return this._status === VALID; + } + get invalid(): boolean { + return this._status === INVALID; + } + get pending(): boolean { + return this._status === PENDING; + } + get disabled(): boolean { + return this._status === DISABLED; + } + get enabled(): boolean { + return this._status !== DISABLED; + } + + get pristine(): boolean { + return this._pristine; + } + get dirty(): boolean { + return !this._pristine; + } + + public readonly asyncVerifyComplete = new Subject(); + + setValidators(validators: ValidatorFn | ValidatorFn[] | null): void { + this._rawValidators = validators; + this._composedValidatorFn = coerceToValidator(validators); + } + setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[] | null): void { + this._rawAsyncValidators = validators; + this._composedAsyncValidatorFn = coerceToAsyncValidator(validators); + } + + addValidators(validators: ValidatorFn | ValidatorFn[]): void { + this.setValidators(addValidators(validators, this._rawValidators)); + } + addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void { + this.setAsyncValidators(addValidators(validators, this._rawAsyncValidators)); + } + + removeValidators(validators: ValidatorFn | ValidatorFn[]): void { + this.setValidators(removeValidators(validators, this._rawValidators)); + } + removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void { + this.setAsyncValidators(removeValidators(validators, this._rawAsyncValidators)); + } + + hasValidator(validator: ValidatorFn): boolean { + return hasValidator(this._rawValidators, validator); + } + hasAsyncValidator(validator: AsyncValidatorFn): boolean { + return hasValidator(this._rawAsyncValidators, validator); + } + + clearValidators(): void { + this.validator = null; + } + clearAsyncValidators(): void { + this.asyncValidator = null; + } + + markAsDirty(onlySelf = false): void { + this._pristine = false; + + if (this._parent && !onlySelf) { + this._parent.markAsDirty(onlySelf); + } + } + markAsPristine(onlySelf = false): void { + this._pristine = true; + + this._forEachChild((control) => { + control.markAsPristine(true); + }); + + if (this._parent && !onlySelf) { + this._parent._updatePristine(onlySelf); + } + } + markAsPending(onlySelf = false): void { + this._status = PENDING; + + if (this._parent && !onlySelf) { + this._parent.markAsPending(onlySelf); + } + } + + disable(onlySelf = false): void { + // If parent has been marked artificially dirty we don't want to re-calculate the + // parent's dirtiness based on the children. + const skipPristineCheck = this._parentMarkedDirty(onlySelf); + + this._status = DISABLED; + this._forEachChild((control) => { + control.disable(true); + }); + + this._updateAncestors(onlySelf, skipPristineCheck); + } + enable(onlySelf = false): void { + // If parent has been marked artificially dirty we don't want to re-calculate the + // parent's dirtiness based on the children. + const skipPristineCheck = this._parentMarkedDirty(onlySelf); + + this._status = VALID; + this._forEachChild((control) => { + control.enable(true); + }); + this.updateValueAndValidity(true); + + this._updateAncestors(onlySelf, skipPristineCheck); + } + + setParent(parent: FormGroup): void { + this._parent = parent; + } + + setErrors(errors: ValidationErrors | null): void { + this._errors = errors; + this._updateControlsErrors(); + } + + updateValueAndValidity(onlySelf = false): void { + this._updateValue(); + + if (this.enabled) { + this._cancelExistingSubscription(); + this._errors = this._runValidator(); + this._status = this._calculateStatus(); + + if (this.status === VALID || this.status === PENDING) { + this._runAsyncValidator(); + } + } + + if (this._parent && !onlySelf) { + this._parent.updateValueAndValidity(onlySelf); + } + } + + get(path: string[] | string): AbstractControl | null { + return find(this, path, '.'); + } + + getError(errorCode: string, path?: string[] | string): any { + const control = path ? this.get(path) : this; + return control && control.errors ? control.errors[errorCode] : null; + } + + hasError(errorCode: string, path?: string[] | string): boolean { + return !!this.getError(errorCode, path); + } + + protected abstract _value: any; + + protected abstract _status: FormControlStatus; + + abstract setValue(value: any, onlySelf?: boolean): void; + abstract patchValue(value: any, onlySelf?: boolean): void; + abstract reset(value?: any, onlySelf?: boolean): void; + + protected abstract _updateValue(): void; + + protected abstract _forEachChild(cb: (c: AbstractControl) => void): void; + + protected abstract _anyControls(condition: (c: AbstractControl) => boolean): boolean; + + protected abstract _allControlsDisabled(): boolean; + + protected _isBoxedValue(formState: any): boolean { + return ( + typeof formState === 'object' && + formState !== null && + Object.keys(formState).length === 2 && + 'value' in formState && + 'disabled' in formState + ); + } + + protected _anyControlsHaveStatus(status: FormControlStatus): boolean { + return this._anyControls((control) => control.status === status); + } + + protected _anyControlsDirty(): boolean { + return this._anyControls((control) => control.dirty); + } + + protected _parentMarkedDirty(onlySelf: boolean): boolean { + return !!(!onlySelf && this._parent && this._parent.dirty && !this._parent._anyControlsDirty()); + } + + protected _updatePristine(onlySelf = false): void { + this._pristine = !this._anyControlsDirty(); + + if (this._parent && !onlySelf) { + this._parent._updatePristine(onlySelf); + } + } + + protected _updateControlsErrors(): void { + this._status = this._calculateStatus(); + + if (this._parent) { + this._parent._updateControlsErrors(); + } + } + + protected _calculateStatus(): FormControlStatus { + if (this._allControlsDisabled()) return DISABLED; + if (this.errors) return INVALID; + if (this._hasOwnPendingAsyncValidator || this._anyControlsHaveStatus(PENDING)) return PENDING; + if (this._anyControlsHaveStatus(INVALID)) return INVALID; + return VALID; + } + + protected _updateAncestors(onlySelf: boolean, skipPristineCheck: boolean) { + if (this._parent && !onlySelf) { + this._parent.updateValueAndValidity(onlySelf); + if (!skipPristineCheck) { + this._parent._updatePristine(); + } + } + } + + protected _runValidator(): ValidationErrors | null { + return this.validator ? this.validator(this) : null; + } + + protected _cancelExistingSubscription(): void { + if (this._asyncValidationSubscription) { + this._asyncValidationSubscription.unsubscribe(); + this._hasOwnPendingAsyncValidator = false; + } + } + + protected _runAsyncValidator(): void { + if (this.asyncValidator) { + this._status = PENDING; + this._hasOwnPendingAsyncValidator = true; + + this._asyncValidationSubscription = from(this.asyncValidator(this)).subscribe((errors) => { + this._hasOwnPendingAsyncValidator = false; + // This will trigger the recalculation of the validation status, which depends on + // the state of the asynchronous validation (whether it is in progress or not). So, it is + // necessary that we have updated the `_hasOwnPendingAsyncValidator` boolean flag first. + this.setErrors(errors); + this.asyncVerifyComplete.next(this); + }); + } + } +} + +export class FormControl extends AbstractControl { + protected _value: any; + protected _status: FormControlStatus = 'VALID'; + + constructor( + formState: any = null, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ) { + super(validators ?? null, asyncValidator ?? null); + this._applyFormState(formState); + this.updateValueAndValidity(true); + } + + override setValue(value: any, onlySelf?: boolean): void { + this._value = value; + this.updateValueAndValidity(onlySelf); + } + override patchValue(value: any, onlySelf?: boolean): void { + this.setValue(value, onlySelf); + } + override reset(formState: any = null, onlySelf?: boolean): void { + if (isString(this.value)) { + formState = ''; + } else if (isArray(this.value)) { + formState = []; + } + this._applyFormState(formState); + this.markAsPristine(onlySelf); + this.setValue(this.value, onlySelf); + } + + protected override _updateValue() {} + + protected override _forEachChild(cb: (c: AbstractControl) => void): void {} + + protected override _anyControls(condition: (c: AbstractControl) => boolean): boolean { + return false; + } + + protected override _allControlsDisabled(): boolean { + return this.disabled; + } + + private _applyFormState(formState: any) { + if (this._isBoxedValue(formState)) { + this._value = formState.value; + formState.disabled ? this.disable(true) : this.enable(true); + } else { + this._value = formState; + } + } +} + +export class FormGroup extends AbstractControl { + protected _value: any; + protected _status: FormControlStatus = 'VALID'; + + constructor( + public controls: { [key: string]: AbstractControl }, + validators?: ValidatorFn | ValidatorFn[] | null, + asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null + ) { + super(validators ?? null, asyncValidator ?? null); + this._setUpControls(); + this.updateValueAndValidity(true); + } + + addControl(name: string, control: AbstractControl): void { + if (!(name in this.controls)) { + this.controls[name] = control; + control.setParent(this); + } + this.updateValueAndValidity(); + } + removeControl(name: string): void { + delete this.controls[name]; + this.updateValueAndValidity(); + } + setControl(name: string, control: AbstractControl): void { + delete this.controls[name]; + this.addControl(name, control); + this.updateValueAndValidity(); + } + contains(controlName: string): boolean { + return controlName in this.controls; + } + + override setValue(value: { [key: string]: any }, onlySelf?: boolean): void { + this._checkAllValuesPresent(value); + Object.keys(value).forEach((name) => { + this._throwIfControlMissing(name); + this.controls[name].setValue(value[name], true); + }); + this.updateValueAndValidity(onlySelf); + } + override patchValue(value: { [key: string]: any }, onlySelf?: boolean): void { + Object.keys(value).forEach((name) => { + if (this.controls[name]) { + this.controls[name].patchValue(value[name], true); + } + }); + this.updateValueAndValidity(onlySelf); + } + override reset(value: any = {}, onlySelf?: boolean): void { + this._forEachChild((control, name) => { + control.reset(value[name], true); + }); + this._updatePristine(onlySelf); + this.updateValueAndValidity(onlySelf); + } + + protected override _updateValue(): void { + this._value = this._reduceValue(); + } + + protected override _forEachChild(cb: (v: any, k: string) => void): void { + Object.keys(this.controls).forEach((key) => { + // The list of controls can change (for ex. controls might be removed) while the loop + // is running (as a result of invoking Forms API in `valueChanges` subscription), so we + // have to null check before invoking the callback. + const control = this.controls[key]; + control && cb(control, key); + }); + } + + protected override _anyControls(condition: (c: AbstractControl) => boolean): boolean { + for (const controlName of Object.keys(this.controls)) { + const control = this.controls[controlName]; + if (this.contains(controlName) && condition(control)) { + return true; + } + } + return false; + } + + protected override _allControlsDisabled(): boolean { + for (const controlName of Object.keys(this.controls)) { + if (this.controls[controlName].enabled) { + return false; + } + } + return Object.keys(this.controls).length > 0 || this.disabled; + } + + private _setUpControls(): void { + this._forEachChild((control) => { + control.setParent(this); + }); + } + + private _reduceValue() { + return this._reduceChildren({}, (acc: { [k: string]: any }, control, name): any => { + acc[name] = control.value; + return acc; + }); + } + + private _reduceChildren(initValue: T, fn: (acc: T, control: AbstractControl, name: string) => T): T { + let res = initValue; + this._forEachChild((control, name) => { + res = fn(res, control, name); + }); + return res; + } + + private _checkAllValuesPresent(value: any): void { + this._forEachChild((control, name) => { + if (value[name] === undefined) { + throw new Error(`Must supply a value for form control with name: '${name}'.`); + } + }); + } + + private _throwIfControlMissing(name: string): void { + if (!Object.keys(this.controls).length) { + throw new Error(` + There are no form controls registered with this group yet. If you're using ngModel, + you may want to check next tick (e.g. use setTimeout). + `); + } + if (!this.controls[name]) { + throw new Error(`Cannot find form control with name: ${name}.`); + } + } +} + +export class Validators { + static min(min: number): ValidatorFn { + return (control) => { + if (isString(control.value) || isNumber(control.value)) { + const value = Number(control.value); + if (Number.isNaN(value)) { + return null; + } else { + return value < min ? { min: { min, actual: control.value } } : null; + } + } + + return null; + }; + } + + static max(max: number): ValidatorFn { + return (control) => { + if (isString(control.value) || isNumber(control.value)) { + const value = Number(control.value); + if (Number.isNaN(value)) { + return null; + } else { + return value > max ? { max: { max, actual: control.value } } : null; + } + } + + return null; + }; + } + + static required(control: AbstractControl): ValidationErrors | null { + const isEmpty = isArray(control.value) || isString(control.value) ? control.value.length === 0 : isNull(control.value) ? true : false; + return isEmpty ? { required: true } : null; + } + + static email(control: AbstractControl): ValidationErrors | null { + if ( + isString(control.value) && + !/^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test( + control.value + ) + ) { + return { email: true }; + } + + return null; + } + + static minLength(minLength: number): ValidatorFn { + return (control) => { + if ((isArray(control.value) || isString(control.value)) && control.value.length < minLength) { + return { minLength: { minLength, actual: control.value.length } }; + } + + return null; + }; + } + + static maxLength(maxLength: number): ValidatorFn { + return (control) => { + if ((isArray(control.value) || isString(control.value)) && control.value.length > maxLength) { + return { maxLength: { maxLength, actual: control.value.length } }; + } + + return null; + }; + } + + static pattern(pattern: string | RegExp): ValidatorFn { + if (!pattern) return () => null; + let regex: RegExp; + let regexStr: string; + if (isString(pattern)) { + regexStr = ''; + + if (pattern.charAt(0) !== '^') regexStr += '^'; + + regexStr += pattern; + + if (pattern.charAt(pattern.length - 1) !== '$') regexStr += '$'; + + regex = new RegExp(regexStr); + } else { + regexStr = pattern.toString(); + regex = pattern; + } + + return (control) => { + if ((isString(control.value) || isNumber(control.value)) && !regex.test(control.value.toString())) { + return { pattern: { pattern: regexStr, actual: control.value } }; + } + + return null; + }; + } +} diff --git a/packages/ui/src/components/form/hooks.ts b/packages/ui/src/components/form/hooks.ts new file mode 100644 index 00000000..aa874111 --- /dev/null +++ b/packages/ui/src/components/form/hooks.ts @@ -0,0 +1,35 @@ +import type { FormGroup } from './form'; + +import { useCallback, useMemo, useState } from 'react'; + +export interface DFormInstance { + form: FormGroup; + resetForm: () => void; + updateForm: () => void; +} + +export function useForm(initData: () => FormGroup): DFormInstance { + const [form, setForm] = useState(initData); + const [formChange, setFormChange] = useState(0); + + const updateForm = useCallback(() => { + setFormChange((n) => n + 1); + }, []); + + const resetForm = useCallback(() => { + const data = initData(); + setForm(data); + }, [initData]); + + const formInstance = useMemo( + () => ({ + form, + resetForm, + updateForm, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [formChange] + ); + + return formInstance; +} diff --git a/packages/ui/src/components/form/index.ts b/packages/ui/src/components/form/index.ts index e2112e90..b683a01e 100644 --- a/packages/ui/src/components/form/index.ts +++ b/packages/ui/src/components/form/index.ts @@ -1,2 +1,5 @@ +export * from './form'; +export * from './Form'; export * from './FormItem'; -export * from './interface'; +export * from './FormGroup'; +export * from './hooks'; diff --git a/packages/ui/src/components/form/interface.ts b/packages/ui/src/components/form/interface.ts deleted file mode 100644 index 2852d065..00000000 --- a/packages/ui/src/components/form/interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DFormControl { - dFormControlName?: string; -} diff --git a/packages/ui/src/components/grid/Col.tsx b/packages/ui/src/components/grid/Col.tsx index cebd0a46..eb85b09b 100644 --- a/packages/ui/src/components/grid/Col.tsx +++ b/packages/ui/src/components/grid/Col.tsx @@ -1,3 +1,5 @@ +import type { DBreakpoints } from './Row'; + import { usePrefixConfig, useComponentConfig } from '../../hooks'; import { getClassName } from '../../utils'; @@ -7,16 +9,11 @@ export interface DColBaseProps extends React.HTMLAttributes { dSpan?: DSpanValue; } export interface DColProps extends DColBaseProps { - xs?: DSpanValue | DColBaseProps; - sm?: DSpanValue | DColBaseProps; - md?: DSpanValue | DColBaseProps; - lg?: DSpanValue | DColBaseProps; - xl?: DSpanValue | DColBaseProps; - xxl?: DSpanValue | DColBaseProps; + dResponsiveProps?: Record; } export function DCol(props: DColProps) { - const { dSpan, xs, sm, md, lg, xl, xxl, className, children, ...restProps } = useComponentConfig(DCol.name, props); + const { dSpan, dResponsiveProps, className, children, ...restProps } = useComponentConfig(DCol.name, props); //#region Context const dPrefix = usePrefixConfig(); diff --git a/packages/ui/src/components/grid/README.md b/packages/ui/src/components/grid/README.md index 14e96be0..35c40256 100644 --- a/packages/ui/src/components/grid/README.md +++ b/packages/ui/src/components/grid/README.md @@ -55,17 +55,17 @@ Extend `React.HTMLAttributes`。 ### DBreakpoints ```tsx -export type DBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; +type DBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; ``` ### DGutterValue ```tsx -export type DGutterValue = number | string | [number | string, number | string]; +type DGutterValue = number | string | [number | string, number | string]; ``` ### DSpanValue ```tsx -export type DSpanValue = number | true; +type DSpanValue = number | true; ``` diff --git a/packages/ui/src/components/grid/README.zh-Hant.md b/packages/ui/src/components/grid/README.zh-Hant.md index 4f44f322..5af8687e 100644 --- a/packages/ui/src/components/grid/README.zh-Hant.md +++ b/packages/ui/src/components/grid/README.zh-Hant.md @@ -54,17 +54,17 @@ title: 栅格 ### DBreakpoints ```tsx -export type DBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; +type DBreakpoints = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'; ``` ### DGutterValue ```tsx -export type DGutterValue = number | string | [number | string, number | string]; +type DGutterValue = number | string | [number | string, number | string]; ``` ### DSpanValue ```tsx -export type DSpanValue = number | true; +type DSpanValue = number | true; ``` diff --git a/packages/ui/src/components/grid/Row.tsx b/packages/ui/src/components/grid/Row.tsx index 9a169926..d7585b42 100644 --- a/packages/ui/src/components/grid/Row.tsx +++ b/packages/ui/src/components/grid/Row.tsx @@ -1,5 +1,6 @@ -import type { DColBaseProps, DColProps, DSpanValue } from './Col'; +import type { DColProps } from './Col'; +import { freeze } from 'immer'; import { isArray, isEqual, isNumber, isObject, isString, isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo } from 'react'; @@ -29,7 +30,7 @@ const DEFAULT_PROPS = { ['xxl', 1400], ]), }; -const MEDIA_QUERY_LIST = Array.from(DEFAULT_PROPS.dBreakpoints.keys()); +export const MEDIA_QUERY_LIST = freeze(Array.from(DEFAULT_PROPS.dBreakpoints.keys())); export function DRow(props: DRowProps) { const { dColNum = 12, @@ -175,9 +176,10 @@ export function DRow(props: DRowProps) { React.Children.forEach(_children, (col) => { let colSpan = col.props.dSpan; let colProps = col.props; - const breakpoint = getMaxBreakpoint(Object.keys(col.props)); + const breakpoint = col.props.dResponsiveProps ? getMaxBreakpoint(Object.keys(col.props.dResponsiveProps)) : null; if (breakpoint) { - const breakpointProps = col.props[breakpoint] as DSpanValue | DColBaseProps; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const breakpointProps = col.props.dResponsiveProps![breakpoint]; if (isObject(breakpointProps)) { if (!isUndefined(breakpointProps.dSpan)) { diff --git a/packages/ui/src/components/grid/demos/4.ResponsiveLayout.md b/packages/ui/src/components/grid/demos/4.ResponsiveLayout.md index 94029c71..03395f6c 100644 --- a/packages/ui/src/components/grid/demos/4.ResponsiveLayout.md +++ b/packages/ui/src/components/grid/demos/4.ResponsiveLayout.md @@ -26,10 +26,7 @@ export default function Demo() { key={index} className={['app-demo-col', index % 2 ? 'app-demo-col--lighter' : ''].join(' ')} dSpan={12} - md={6} - lg={4} - xl={3} - xxl={2} + dResponsiveProps={{ md: 6, lg: 4, xl: 3, xxl: 2 }} > col-{match} diff --git a/packages/ui/src/components/grid/demos/5.FreePlay.md b/packages/ui/src/components/grid/demos/5.FreePlay.md index 244f1987..5c64a7ce 100644 --- a/packages/ui/src/components/grid/demos/5.FreePlay.md +++ b/packages/ui/src/components/grid/demos/5.FreePlay.md @@ -30,21 +30,23 @@ export default function Demo() { key={index} className={['app-demo-col', index % 2 ? 'app-demo-col--lighter' : ''].join(' ')} dSpan={12} - md={{ - dSpan: 6, - style: { order: index === 3 ? 1 : undefined, transform: 'rotate(0.5turn)' }, - }} - lg={{ - dSpan: 4, - style: { order: index === 2 ? 1 : undefined, transform: 'scale(0.8)' }, - }} - xl={{ - dSpan: 3, - style: { order: index === 1 ? 1 : undefined, opacity: 0.5 }, - }} - xxl={{ - dSpan: 2, - style: { order: index === 0 ? 1 : undefined, color: '#87f4ff' }, + dResponsiveProps={{ + md: { + dSpan: 6, + style: { order: index === 3 ? 1 : undefined, transform: 'rotate(0.5turn)' }, + }, + lg: { + dSpan: 4, + style: { order: index === 2 ? 1 : undefined, transform: 'scale(0.8)' }, + }, + xl: { + dSpan: 3, + style: { order: index === 1 ? 1 : undefined, opacity: 0.5 }, + }, + xxl: { + dSpan: 2, + style: { order: index === 0 ? 1 : undefined, color: '#87f4ff' }, + }, }} > col-{match}-{index} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 4a4e2abb..ed544452 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -19,6 +19,9 @@ export { DDrawer, DDrawerHeader, DDrawerFooter } from './drawer'; export type { DDropdownProps, DDropdownItemProps, DDropdownSubProps, DDropdownGroupProps } from './dropdown'; export { DDropdown, DDropdownItem, DDropdownSub, DDropdownGroup } from './dropdown'; +export type { DFormProps, DFormItemProps } from './form'; +export { DForm, DFormItem, DFormGroup, useForm, FormControl, FormGroup, Validators } from './form'; + export type { DRowProps, DColProps } from './grid'; export { DRow, DCol } from './grid'; diff --git a/packages/ui/src/components/input/Input.tsx b/packages/ui/src/components/input/Input.tsx index 05317bee..4d648715 100644 --- a/packages/ui/src/components/input/Input.tsx +++ b/packages/ui/src/components/input/Input.tsx @@ -1,29 +1,28 @@ import type { Updater } from '../../hooks/two-way-binding'; -import type { DFormControl } from '../form'; -import { isUndefined } from 'lodash'; -import React, { useEffect, useImperativeHandle } from 'react'; +import React, { useEffect, useId, useImperativeHandle } from 'react'; import { useCallback } from 'react'; -import { usePrefixConfig, useComponentConfig, useTwoWayBinding, useCustomContext, useRefCallback } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useTwoWayBinding, useCustomContext, useRefCallback, useGeneralState } from '../../hooks'; import { getClassName } from '../../utils'; -import { useCompose } from '../compose'; import { DInputAffixContext } from './InputAffix'; export type DInputRef = HTMLInputElement; -export interface DInputProps extends React.InputHTMLAttributes, DFormControl { +export interface DInputProps extends React.InputHTMLAttributes { dModel?: [string, Updater?]; + dFormControlName?: string; dSize?: 'smaller' | 'larger'; onModelChange?: (value: string) => void; } const Input: React.ForwardRefRenderFunction = (props, ref) => { const { - dFormControlName, dModel, + dFormControlName, dSize, onModelChange, + id, className, type = 'text', disabled, @@ -36,32 +35,39 @@ const Input: React.ForwardRefRenderFunction = (props, re //#region Context const dPrefix = usePrefixConfig(); + const { gSize, gDisabled } = useGeneralState(); const [ { inputAffixPassword, inputAffixNumber, inputAffixDisabled, - inputAffixSize, + inputAffixSetInputEl, + inputAffixSetClearable, + inputAffixSetValidateClassName, + inputAffixNotificationCallback, onFocus: _onFocus, onBlur: _onBlur, - onClearableChange, - onInputRendered, }, ] = useCustomContext(DInputAffixContext); - const { composeSize, composeDisabled } = useCompose(); //#endregion //#region Ref const [inputEl, inputRef] = useRefCallback(); //#endregion - const size = isUndefined(composeSize) ? inputAffixSize ?? dSize : composeSize; + const uniqueId = useId(); + const _id = id ?? `${dPrefix}input-${uniqueId}`; + + const size = dSize ?? gSize; - const [value, changeValue] = useTwoWayBinding('', dModel, onModelChange, { - name: dFormControlName, - }); + const [value, changeValue, { validateClassName, ariaAttribute, controlDisabled }] = useTwoWayBinding( + '', + dModel, + onModelChange, + dFormControlName ? { formControlName: dFormControlName, id: _id } : undefined + ); - const _disabled = composeDisabled || inputAffixDisabled || disabled; + const _disabled = disabled || inputAffixDisabled || gDisabled || controlDisabled; const handleChange = useCallback>( (e) => { @@ -87,25 +93,34 @@ const Input: React.ForwardRefRenderFunction = (props, re [_onBlur, onBlur] ); - //#region DidUpdate useEffect(() => { - if (inputEl) { - onInputRendered?.(changeValue, inputEl); - } - }, [changeValue, inputEl, onInputRendered]); + inputAffixNotificationCallback?.bind(changeValue); + return () => { + inputAffixNotificationCallback?.removeBind(changeValue); + }; + }, [changeValue, inputAffixNotificationCallback]); useEffect(() => { - onClearableChange?.(value.length > 0); - }, [value, onClearableChange]); - //#endregion + inputAffixSetInputEl?.(inputEl); + }, [inputAffixSetInputEl, inputEl]); + + useEffect(() => { + inputAffixSetClearable?.(value.length > 0); + }, [inputAffixSetClearable, value.length]); + + useEffect(() => { + inputAffixSetValidateClassName?.(validateClassName); + }, [inputAffixSetValidateClassName, validateClassName]); useImperativeHandle(ref, () => inputEl, [inputEl]); return ( >; + inputAffixSetInputEl: React.Dispatch>; + inputAffixSetValidateClassName: React.Dispatch>; + inputAffixNotificationCallback: NotificationCallback; onFocus: () => void; onBlur: () => void; - onClearableChange: (clearable: boolean) => void; - onInputRendered: (changeValue: (value: string) => void, inputEl: HTMLInputElement) => void; } export const DInputAffixContext = React.createContext(null); @@ -50,12 +61,10 @@ export function DInputAffix(props: DInputAffixProps) { //#region Context const dPrefix = usePrefixConfig(); - const { composeSize, composeDisabled } = useCompose(); + const { gSize, gDisabled } = useGeneralState(); //#endregion const dataRef = useRef<{ - changeValue?: (value: string) => void; - inputEl?: HTMLInputElement; clearLoop?: () => void; clearTid?: () => void; }>({}); @@ -64,31 +73,38 @@ export function DInputAffix(props: DInputAffixProps) { const [t] = useTranslation(); const [isFocus, setIsFocus] = useState(false); - const [clearable, setClearable] = useState(false); const [password, setPassword] = useState(true); + const [clearable, setClearable] = useState(false); + const [inputEl, setInputEl] = useState(null); + const [validateClassName, setValidateClassName] = useState(); - const size = composeSize ?? dSize; - const disabled = composeDisabled || dDisabled; + const [notification, notificationCallback] = useNotification(); - const handleClearMouseDown = useCallback>((e) => { - if (e.button === 0) { - if (document.activeElement === dataRef.current.inputEl) { - e.preventDefault(); + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled; + + const handleClearMouseDown = useCallback>( + (e) => { + if (e.button === 0) { + if (document.activeElement === inputEl) { + e.preventDefault(); + } + notification.next(''); } - dataRef.current.changeValue?.(''); - } - }, []); + }, + [inputEl, notification] + ); const handleNumberChange = useCallback( (isIncrease = true) => { const handleFunc = () => { - if (dataRef.current.inputEl && dataRef.current.changeValue) { - const step = getNumberAttribute(dataRef.current.inputEl.step, 1); - const max = getNumberAttribute(dataRef.current.inputEl.max, Infinity); - const min = getNumberAttribute(dataRef.current.inputEl.min, -Infinity); - const value = getNumberAttribute(dataRef.current.inputEl.value, 0); + if (inputEl) { + const step = getNumberAttribute(inputEl.step, 1); + const max = getNumberAttribute(inputEl.max, Infinity); + const min = getNumberAttribute(inputEl.min, -Infinity); + const value = getNumberAttribute(inputEl.value, 0); const newValue = isIncrease ? value + step : value - step; - dataRef.current.changeValue(Math.max(min, Math.min(max, newValue)).toFixed(step.toString().split('.')[1]?.length ?? 0)); + notification.next(Math.max(min, Math.min(max, newValue)).toFixed(step.toString().split('.')[1]?.length ?? 0)); } }; @@ -99,13 +115,13 @@ export function DInputAffix(props: DInputAffixProps) { }; dataRef.current.clearTid = asyncCapture.setTimeout(() => loop(), 400); }, - [asyncCapture] + [asyncCapture, inputEl, notification] ); const handlePasswordMouseDown = useCallback>( (e) => { if (e.button === 0) { - if (document.activeElement === dataRef.current.inputEl) { + if (document.activeElement === inputEl) { e.preventDefault(); } if (dPasswordToggle) { @@ -113,157 +129,166 @@ export function DInputAffix(props: DInputAffixProps) { } } }, - [dPasswordToggle, password, setPassword] + [dPasswordToggle, inputEl, password] ); const handleNumberIncreaseMouseDown = useCallback>( (e) => { if (e.button === 0) { - if (document.activeElement === dataRef.current.inputEl) { + if (document.activeElement === inputEl) { e.preventDefault(); } handleNumberChange(); } }, - [handleNumberChange] + [handleNumberChange, inputEl] ); const handleNumberDecreaseMouseDown = useCallback>( (e) => { if (e.button === 0) { - if (document.activeElement === dataRef.current.inputEl) { + if (document.activeElement === inputEl) { e.preventDefault(); } handleNumberChange(false); } }, - [handleNumberChange] + [handleNumberChange, inputEl] ); - const handleMouseUp = useCallback((e) => { - if (document.activeElement === dataRef.current.inputEl) { - e.preventDefault(); - } + const handleMouseUp = useCallback( + (e) => { + if (document.activeElement === inputEl) { + e.preventDefault(); + } - dataRef.current.clearLoop?.(); - dataRef.current.clearTid?.(); - }, []); + dataRef.current.clearLoop?.(); + dataRef.current.clearTid?.(); + }, + [inputEl] + ); const contextValue = useMemo( () => ({ inputAffixDisabled: disabled, inputAffixPassword: dPassword ? password : false, inputAffixNumber: dNumber, - inputAffixSize: size, + inputAffixSetClearable: setClearable, + inputAffixSetInputEl: setInputEl, + inputAffixSetValidateClassName: setValidateClassName, + inputAffixNotificationCallback: notificationCallback, onFocus: () => { setIsFocus(true); }, onBlur: () => { setIsFocus(false); }, - onClearableChange: (clearable) => { - setClearable(clearable); - }, - onInputRendered: (changeValue, inputEl) => { - dataRef.current.changeValue = changeValue; - dataRef.current.inputEl = inputEl; - }, }), - [dNumber, dPassword, disabled, password, setClearable, setIsFocus, size] + [dNumber, dPassword, disabled, notificationCallback, password] + ); + + const generalStateContextValue = useMemo( + () => ({ + gSize: size, + gDisabled: disabled, + }), + [disabled, size] ); return ( - -
    - {_dPrefix &&
    {_dPrefix}
    } - {children} - {dClearable && !disabled && ( - - - - ) : ( - dClearIcon - ) - } - aria-label={t('Common', 'Clear')} - onMouseDown={handleClearMouseDown} - onMouseUp={handleMouseUp} - > - )} - {dPassword && !disabled && ( - - {password ? ( - <> - - - - ) : ( - - )} - - } - aria-label={t('DInputAffix', password ? 'Password is not visible' : 'Password is visible')} - onMouseDown={handlePasswordMouseDown} - onMouseUp={handleMouseUp} - > - )} - {dNumber && !disabled && ( -
    + + +
    + {_dPrefix &&
    {_dPrefix}
    } + {children} + {dClearable && !disabled && ( - - + isUndefined(dClearIcon) ? ( + + + + ) : ( + dClearIcon + ) } - aria-label={t('DInputAffix', 'Increase number')} - onMouseDown={handleNumberIncreaseMouseDown} + aria-label={t('Common', 'Clear')} + onMouseDown={handleClearMouseDown} onMouseUp={handleMouseUp} > + )} + {dPassword && !disabled && ( - + + {password ? ( + <> + + + + ) : ( + + )} } - aria-label={t('DInputAffix', 'Decrease number')} - onMouseDown={handleNumberDecreaseMouseDown} + aria-label={t('DInputAffix', password ? 'Password is not visible' : 'Password is visible')} + onMouseDown={handlePasswordMouseDown} onMouseUp={handleMouseUp} > -
    - )} - {dSuffix &&
    {dSuffix}
    } -
    - + )} + {dNumber && !disabled && ( +
    + + + + } + aria-label={t('DInputAffix', 'Increase number')} + onMouseDown={handleNumberIncreaseMouseDown} + onMouseUp={handleMouseUp} + > + + + + } + aria-label={t('DInputAffix', 'Decrease number')} + onMouseDown={handleNumberDecreaseMouseDown} + onMouseUp={handleMouseUp} + > +
    + )} + {dSuffix &&
    {dSuffix}
    } +
    +
    + ); } diff --git a/packages/ui/src/components/input/README.md b/packages/ui/src/components/input/README.md index 47bc8377..11a6db1f 100644 --- a/packages/ui/src/components/input/README.md +++ b/packages/ui/src/components/input/README.md @@ -13,7 +13,7 @@ When the user needs to enter content. ### DRadioProps -Extend `React.InputHTMLAttributes, DFormControl`, [DFormControl](/components/Form#DFormControl). +Extend `React.InputHTMLAttributes`. | Property | Description | Type | Default | @@ -26,7 +26,7 @@ Extend `React.InputHTMLAttributes, DFormControl`, [DFormContro ### DInputRef ```tsx -export type DInputRef = HTMLInputElement; +type DInputRef = HTMLInputElement; ``` ### DInputAffixProps diff --git a/packages/ui/src/components/input/README.zh-Hant.md b/packages/ui/src/components/input/README.zh-Hant.md index 3d61c9c4..533da152 100644 --- a/packages/ui/src/components/input/README.zh-Hant.md +++ b/packages/ui/src/components/input/README.zh-Hant.md @@ -12,7 +12,7 @@ title: 输入框 ### DInputProps -继承 `React.InputHTMLAttributes, DFormControl`,[DFormControl](/components/Form#DFormControl)。 +继承 `React.InputHTMLAttributes`。 | 参数 | 说明 | 类型 | 默认值 | @@ -25,7 +25,7 @@ title: 输入框 ### DInputRef ```tsx -export type DInputRef = HTMLInputElement; +type DInputRef = HTMLInputElement; ``` ### DInputAffixProps diff --git a/packages/ui/src/components/menu/Menu.tsx b/packages/ui/src/components/menu/Menu.tsx index 936a9592..20ad07e6 100644 --- a/packages/ui/src/components/menu/Menu.tsx +++ b/packages/ui/src/components/menu/Menu.tsx @@ -2,12 +2,13 @@ import type { Updater } from '../../hooks/two-way-binding'; import type { DMenuItemProps } from './MenuItem'; import { isUndefined } from 'lodash'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { usePrefixConfig, useComponentConfig, useImmer, useRefCallback, useTwoWayBinding } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useImmer, useRefCallback, useTwoWayBinding, useDCollapseTransition } from '../../hooks'; import { getClassName } from '../../utils'; -import { DCollapseTransition } from '../_transition'; import { DTrigger } from '../_trigger'; +import { DMenuItem } from './MenuItem'; +import { DMenuSub } from './MenuSub'; type DMenuMode = 'horizontal' | 'vertical' | 'popup' | 'icon'; @@ -17,10 +18,6 @@ export interface DMenuContextData { menuActiveId: string | null; menuExpandIds: Set; menuFocusId: [string, string] | null; - menuCurrentData: { - navIds: Set; - ids: Map>; - }; onActiveChange: (id: string) => void; onExpandChange: (id: string, expand: boolean) => void; onFocus: (dId: string, id: string) => void; @@ -65,11 +62,6 @@ export function DMenu(props: DMenuProps) { const [navEl, navRef] = useRefCallback(); //#endregion - const dataRef = useRef({ - navIds: new Set(), - ids: new Map(), - }); - const [focusId, setFocusId] = useImmer(null); const [activedescendant, setActiveDescendant] = useState(undefined); @@ -108,7 +100,6 @@ export function DMenu(props: DMenuProps) { menuActiveId: activeId, menuExpandIds: expandIds, menuFocusId: focusId, - menuCurrentData: dataRef.current, onActiveChange: (id) => { changeActiveId(id); }, @@ -116,8 +107,19 @@ export function DMenu(props: DMenuProps) { changeExpandIds((draft) => { if (expand) { if (dExpandOne) { - for (const ids of [...Array.from(dataRef.current.ids.values()), dataRef.current.navIds]) { - if (ids.has(id)) { + const idsArr: string[][] = []; + const getAllIds = (childs: React.ReactNode) => { + const nodes = React.Children.toArray(childs).filter((node) => node?.['props']?.dId); + idsArr.push(nodes.map((node) => node['props'].dId)); + nodes.forEach((node) => { + if (node?.['props']?.children) { + getAllIds(node['props'].children); + } + }); + }; + getAllIds(children); + for (const ids of idsArr) { + if (ids.includes(id)) { for (const sameLevelId of ids) { draft.delete(sameLevelId); } @@ -138,85 +140,65 @@ export function DMenu(props: DMenuProps) { setFocusId(null); }, }), - [activeId, changeActiveId, changeExpandIds, dExpandOne, dMode, expandIds, expandTrigger, focusId, setFocusId] + [activeId, changeActiveId, changeExpandIds, children, dExpandOne, dMode, expandIds, expandTrigger, focusId, setFocusId] ); const childs = useMemo(() => { - dataRef.current.navIds.clear(); - dataRef.current.ids.clear(); - - const getAllIds = (child: React.ReactElement) => { - if (child.props?.dId) { - const nodes = (React.Children.toArray(child.props?.children) as React.ReactElement[]).filter((node) => node.props?.dId); - const ids = nodes.map((node) => node.props?.dId); - dataRef.current.ids.set(child.props?.dId, new Set(ids)); + return React.Children.map(children as Array>, (child, index) => { + const props = Object.assign({}, child.props); - nodes.forEach((node) => { - getAllIds(node); - }); + if ('type' in child && (child.type === DMenuSub || child.type === DMenuItem)) { + props.__inNav = true; } - }; - React.Children.toArray(children).forEach((node) => { - getAllIds(node as React.ReactElement); - }); - - return React.Children.map(children as Array>, (child, index) => { - child.props.dId && dataRef.current.navIds.add(child.props.dId); - - let tabIndex = child.props.tabIndex; if (index === 0) { - tabIndex = 0; + props.tabIndex = 0; } - return React.cloneElement(child, { - ...child.props, - tabIndex, - }); + return React.cloneElement(child, props); }); }, [children]); + useDCollapseTransition({ + dEl: navEl, + dVisible: dMode !== 'icon', + dDirection: 'horizontal', + dDuring: 200, + dSpace: 80, + }); + return ( - ( - ( - - )} - onTrigger={handleTrigger} - /> + ( + )} + onTrigger={handleTrigger} /> ); diff --git a/packages/ui/src/components/menu/MenuItem.tsx b/packages/ui/src/components/menu/MenuItem.tsx index 39c6e971..dcd45564 100644 --- a/packages/ui/src/components/menu/MenuItem.tsx +++ b/packages/ui/src/components/menu/MenuItem.tsx @@ -1,7 +1,7 @@ import { isUndefined } from 'lodash'; import { useCallback } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useStateBackflow } from '../../hooks'; import { getClassName, toId, mergeStyle } from '../../utils'; import { DTooltip } from '../tooltip'; import { DMenuContext } from './Menu'; @@ -11,6 +11,7 @@ export interface DMenuItemProps extends React.LiHTMLAttributes { dIcon?: React.ReactNode; dDisabled?: boolean; __level?: number; + __inNav?: boolean; } export function DMenuItem(props: DMenuItemProps) { @@ -19,6 +20,7 @@ export function DMenuItem(props: DMenuItemProps) { dIcon, dDisabled = false, __level = 0, + __inNav = false, id, className, style, @@ -32,16 +34,17 @@ export function DMenuItem(props: DMenuItemProps) { //#region Context const dPrefix = usePrefixConfig(); - const [{ menuMode, menuActiveId, menuCurrentData, onActiveChange, onFocus: _onFocus, onBlur: _onBlur }] = useCustomContext(DMenuContext); + const [{ menuMode, menuActiveId, onActiveChange, onFocus: _onFocus, onBlur: _onBlur }] = useCustomContext(DMenuContext); //#endregion //#region Ref const [liEl, liRef] = useRefCallback(); //#endregion - const inNav = menuCurrentData?.navIds.has(dId) ?? false; const _id = id ?? `${dPrefix}menu-item-${toId(dId)}`; + useStateBackflow(false, dId); + const handleClick = useCallback( (e) => { onClick?.(e); @@ -75,8 +78,8 @@ export function DMenuItem(props: DMenuItemProps) { ref={liRef} id={_id} className={getClassName(className, `${dPrefix}menu-item`, { - [`${dPrefix}menu-item--horizontal`]: menuMode === 'horizontal' && inNav, - [`${dPrefix}menu-item--icon`]: menuMode === 'icon' && inNav, + [`${dPrefix}menu-item--horizontal`]: menuMode === 'horizontal' && __inNav, + [`${dPrefix}menu-item--icon`]: menuMode === 'icon' && __inNav, 'is-active': menuActiveId === dId, 'is-disabled': dDisabled, })} @@ -96,7 +99,7 @@ export function DMenuItem(props: DMenuItemProps) { {dIcon &&
    {dIcon}
    }
    {children}
  • - {inNav && menuMode === 'icon' && } + {__inNav && menuMode === 'icon' && } ); } diff --git a/packages/ui/src/components/menu/MenuSub.tsx b/packages/ui/src/components/menu/MenuSub.tsx index d00cdf10..db3fe173 100644 --- a/packages/ui/src/components/menu/MenuSub.tsx +++ b/packages/ui/src/components/menu/MenuSub.tsx @@ -1,20 +1,27 @@ +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; import type { DMenuItemProps } from './MenuItem'; -import { isUndefined } from 'lodash'; +import { isString, isUndefined } from 'lodash'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useTranslation } from '../../hooks'; +import { + usePrefixConfig, + useComponentConfig, + useCustomContext, + useRefCallback, + useTranslation, + useDCollapseTransition, + useImmer, + useStateBackflow, + DStateBackflowContext, +} from '../../hooks'; import { getClassName, getHorizontalSideStyle, getVerticalSideStyle, toId, mergeStyle } from '../../utils'; import { DPopup } from '../_popup'; -import { DCollapseTransition } from '../_transition'; import { DTrigger } from '../_trigger'; import { DIcon } from '../icon'; import { DMenuContext } from './Menu'; -export interface DMenuSubContextData { - onPopupTrigger: (visible: boolean) => void; -} -export const DMenuSubContext = React.createContext(null); +type DIds = Map; export interface DMenuSubProps extends React.LiHTMLAttributes { dId: string; @@ -23,6 +30,7 @@ export interface DMenuSubProps extends React.LiHTMLAttributes { dDisabled?: boolean; dPopupClassName?: string; __level?: number; + __inNav?: boolean; } export function DMenuSub(props: DMenuSubProps) { @@ -33,6 +41,7 @@ export function DMenuSub(props: DMenuSubProps) { dDisabled = false, dPopupClassName, __level = 0, + __inNav = false, id, className, style, @@ -45,21 +54,8 @@ export function DMenuSub(props: DMenuSubProps) { //#region Context const dPrefix = usePrefixConfig(); - const [ - { - menuMode, - menuExpandTrigger, - menuActiveId, - menuExpandIds, - menuFocusId, - menuCurrentData, - onExpandChange, - onFocus: _onFocus, - onBlur: _onBlur, - }, - ] = useCustomContext(DMenuContext); - const [{ onPopupTrigger }] = useCustomContext(DMenuSubContext); - + const [{ menuMode, menuExpandTrigger, menuActiveId, menuExpandIds, menuFocusId, onExpandChange, onFocus: _onFocus, onBlur: _onBlur }] = + useCustomContext(DMenuContext); //#endregion //#region Ref @@ -74,29 +70,50 @@ export function DMenuSub(props: DMenuSubProps) { const expand = menuExpandIds?.has(dId) ?? false; const popupMode = menuMode !== 'vertical'; + + const [childrenIds, setChildrenIds] = useImmer(new Map()); + const [currentPopupVisible, setCurrentPopupVisible] = useState(false); - const [childrenPopupVisiable, setChildrenPopupVisiable] = useState(false); - const popupVisible = currentPopupVisible || childrenPopupVisiable; - const inNav = menuCurrentData?.navIds.has(dId) ?? false; - const inHorizontalNav = menuMode === 'horizontal' && inNav; + const [childrenPopupVisiable, setChildrenPopupVisiable] = useImmer(new Map()); + const popupVisible = useMemo(() => { + let visible = currentPopupVisible; + for (const childrenVisiable of childrenPopupVisiable.values()) { + if (childrenVisiable) { + visible = childrenVisiable; + break; + } + } + return visible; + }, [childrenPopupVisiable, currentPopupVisible]); + + useStateBackflow(popupVisible, childrenIds); + + const inHorizontalNav = menuMode === 'horizontal' && __inNav; const _id = id ?? `${dPrefix}menu-sub-${toId(dId)}`; const isActive = useMemo(() => { - if (popupMode ? !popupVisible : !expand) { - const ids: string[] = []; - const getAllIds = (id: string) => { - ids.push(id); - const childIds = menuCurrentData?.ids.get(id); - if (childIds) { - for (const childId of childIds.values()) { - getAllIds(childId); + if (isString(menuActiveId)) { + if (popupMode ? !popupVisible : !expand) { + const checkActive = (idMap: DIds): boolean | undefined => { + for (const id of idMap.values()) { + if (isString(id)) { + if (id === menuActiveId) { + return true; + } + } else { + const res = checkActive(id); + if (res) { + return res; + } + } } - } - }; - getAllIds(dId); - return menuActiveId ? ids.includes(menuActiveId) : false; + }; + return checkActive(childrenIds) ?? false; + } } + return false; - }, [dId, expand, menuActiveId, menuCurrentData?.ids, popupMode, popupVisible]); + }, [childrenIds, expand, menuActiveId, popupMode, popupVisible]); + const iconRotate = useMemo(() => { if (inHorizontalNav && popupVisible) { return 180; @@ -114,7 +131,7 @@ export function DMenuSub(props: DMenuSubProps) { (popupEl: HTMLElement, targetEl: HTMLElement) => { const { top, left, transformOrigin } = inHorizontalNav ? getVerticalSideStyle(popupEl, targetEl, 'bottom-left', 12) - : getHorizontalSideStyle(popupEl, targetEl, 'right', inNav ? 10 : 14); + : getHorizontalSideStyle(popupEl, targetEl, 'right', __inNav ? 10 : 14); if (inHorizontalNav) { popupEl.style.width = targetEl.getBoundingClientRect().width - 32 + 'px'; } @@ -129,7 +146,7 @@ export function DMenuSub(props: DMenuSubProps) { }, }; }, - [inHorizontalNav, inNav] + [inHorizontalNav, __inNav] ); const handleExpandTrigger = useCallback( @@ -185,10 +202,6 @@ export function DMenuSub(props: DMenuSubProps) { setCurrentPopupVisible(false); } }, [popupMode, setCurrentPopupVisible]); - - useEffect(() => { - onPopupTrigger?.(popupVisible); - }, [onPopupTrigger, popupVisible]); //#endregion const childs = useMemo(() => { @@ -230,81 +243,99 @@ export function DMenuSub(props: DMenuSubProps) { ); - const contextValue = useMemo( + const stateBackflowContextValue = useMemo( () => ({ - onPopupTrigger: (visible) => { - setChildrenPopupVisiable(visible); + addState: (identity, visible, id) => { + setChildrenPopupVisiable((draft) => { + draft.set(identity, visible); + }); + setChildrenIds((draft) => { + draft.set(identity, id); + }); + }, + updateState: (identity, visible, id) => { + setChildrenPopupVisiable((draft) => { + draft.set(identity, visible); + }); + setChildrenIds((draft) => { + draft.set(identity, id); + }); + }, + removeState: (identity) => { + setChildrenPopupVisiable((draft) => { + draft.delete(identity); + }); + setChildrenIds((draft) => { + draft.delete(identity); + }); }, }), - [setChildrenPopupVisiable] + [setChildrenIds, setChildrenPopupVisiable] ); + const hidden = useDCollapseTransition({ + dEl: menuCollapseEl, + dVisible: popupMode ? false : expand, + dDuring: 200, + }); + return ( - - ( - <> -
  • +
  • + {!dDisabled && ( + <> + {popupMode ? ( + + ) : ( + + )} + {popupMode && hidden + ? null + : menuNode({ + ref: menuCollapseRef, + style: { display: hidden ? 'none' : undefined }, })} - style={mergeStyle(style, { - paddingLeft: 16 + __level * 20, - })} - role="menuitem" - tabIndex={isUndefined(tabIndex) ? -1 : tabIndex} - aria-haspopup={true} - aria-expanded={popupMode ? popupVisible : expand} - aria-disabled={dDisabled} - onFocus={handleFocus} - onBlur={handleBlur} - > -
    -
    -
    - {dIcon &&
    {dIcon}
    } -
    {dTitle}
    - - - - - {!dDisabled && ( - <> - {popupMode ? ( - - ) : ( - - )} - {popupMode && hidden - ? null - : menuNode({ - ref: menuCollapseRef, - style: { display: hidden ? 'none' : undefined }, - })} - - )} - - )} - /> -
    + + )} + ); } diff --git a/packages/ui/src/components/menu/demos/1.Basic.md b/packages/ui/src/components/menu/demos/1.Basic.md index 4b202373..f8a5a74d 100644 --- a/packages/ui/src/components/menu/demos/1.Basic.md +++ b/packages/ui/src/components/menu/demos/1.Basic.md @@ -18,7 +18,7 @@ import { useState } from 'react'; import { DMenu, DMenuGroup, DMenuItem, DMenuSub, DIcon } from '@react-devui/ui'; export default function Demo() { - const [activeId, setActiveId] = useState('Item121'); + const [activeId, setActiveId] = useState('Item211'); return ( diff --git a/packages/ui/src/components/menu/utils.ts b/packages/ui/src/components/menu/utils.ts index 29ad0026..07fb40d0 100644 --- a/packages/ui/src/components/menu/utils.ts +++ b/packages/ui/src/components/menu/utils.ts @@ -33,3 +33,9 @@ export function getAllIds(id: string, data?: Map): string[] { } return arr; } + +export interface DMenuCommonContextData { + expandIds: Set; + onExpandChange: (id: string, expand: boolean) => void; +} +export const DMenuCommonContext = React.createContext(null); diff --git a/packages/ui/src/components/pagination/Pagination.tsx b/packages/ui/src/components/pagination/Pagination.tsx index 7439084b..ae7dc1e4 100644 --- a/packages/ui/src/components/pagination/Pagination.tsx +++ b/packages/ui/src/components/pagination/Pagination.tsx @@ -60,7 +60,7 @@ export function DPagination(props: DPaginationProps) { const [jumpValue, setJumpValue] = useState(''); const [active, _changeActive] = useTwoWayBinding(1, dActive, onActiveChange); - const [pageSize, changePageSize] = useTwoWayBinding(10, dPageSize, onPageSizeChange); + const [pageSize, changePageSize] = useTwoWayBinding(dPageSizeOptions[0] ?? 10, dPageSize, onPageSizeChange); const changeActive = useCallback( (active: number) => { @@ -73,7 +73,7 @@ export function DPagination(props: DPaginationProps) { ); const lastPage = Math.max(Math.ceil(dTotal / pageSize), 1); - const iconSize = 14; + const iconSize = '0.9em'; if (lastPage < active) { _changeActive(lastPage); diff --git a/packages/ui/src/components/pagination/README.md b/packages/ui/src/components/pagination/README.md index 65651e3c..355fd280 100644 --- a/packages/ui/src/components/pagination/README.md +++ b/packages/ui/src/components/pagination/README.md @@ -20,7 +20,7 @@ Extend `React.HTMLAttributes`. | --- | --- | --- | --- | | dActive | Manually control the number of active pages | [number, Updater\?] | 1 | | dTotal | Total number of entries | number | - | -| dPageSize | Number of entries per page | [number, Updater\?] | 10 | +| dPageSize | Number of entries per page | [number, Updater\?] | - | | dPageSizeOptions | Number of items per page to choose from | number[] | [10, 20, 50, 100] | | dCompose | Free combination configuration | `Array<'total' \| 'pages' \| 'size' \| 'jump'>` | ['pages'] | | dCustomRender | Custom configuration | `{ total?: (range: [number, number]) => React.ReactNode; prev?: React.ReactNode; page?: (page: number) => React.ReactNode; next?: React.ReactNode; sizeOption?: (size: number) => React.ReactNode; jump?: (input: React.ReactNode) => React.ReactNode; }` | - | diff --git a/packages/ui/src/components/pagination/README.zh-Hant.md b/packages/ui/src/components/pagination/README.zh-Hant.md index 62f273ec..090768cb 100644 --- a/packages/ui/src/components/pagination/README.zh-Hant.md +++ b/packages/ui/src/components/pagination/README.zh-Hant.md @@ -19,7 +19,7 @@ title: 分页 | --- | --- | --- | --- | | dActive | 手动控制活动的页数 | [number, Updater\?] | 1 | | dTotal | 条目总数 | number | - | -| dPageSize | 每页包含条目数 | [number, Updater\?] | 10 | +| dPageSize | 每页包含条目数 | [number, Updater\?] | - | | dPageSizeOptions | 可供选择的每页条目数 | number[] | [10, 20, 50, 100] | | dCompose | 自由组合配置 | `Array<'total' \| 'pages' \| 'size' \| 'jump'>` | ['pages'] | | dCustomRender | 自定义配置 | `{ total?: (range: [number, number]) => React.ReactNode; prev?: React.ReactNode; page?: (page: number) => React.ReactNode; next?: React.ReactNode; sizeOption?: (size: number) => React.ReactNode; jump?: (input: React.ReactNode) => React.ReactNode; }` | - | diff --git a/packages/ui/src/components/pagination/demos/2.PageSizeOptions.md b/packages/ui/src/components/pagination/demos/2.PageSizeOptions.md index c947951b..a10c589a 100644 --- a/packages/ui/src/components/pagination/demos/2.PageSizeOptions.md +++ b/packages/ui/src/components/pagination/demos/2.PageSizeOptions.md @@ -16,6 +16,6 @@ To switch the size of each page, you can customize the options through `dPageSiz import { DPagination } from '@react-devui/ui'; export default function Demo() { - return ; + return ; } ``` diff --git a/packages/ui/src/components/radio/README.md b/packages/ui/src/components/radio/README.md index 65d92be0..27944817 100644 --- a/packages/ui/src/components/radio/README.md +++ b/packages/ui/src/components/radio/README.md @@ -13,7 +13,7 @@ The user needs to select a single option from a data set, and can view all the a ### DRadioProps -Extend `React.HTMLAttributes, DFormControl`, [DFormControl](/components/Form#DFormControl). +Extend `React.HTMLAttributes`. | Property | Description | Type | Default | @@ -27,12 +27,12 @@ Extend `React.HTMLAttributes, DFormControl`, [DFormControl](/compon ### DRadioRef ```tsx -export type DRadioRef = HTMLInputElement; +type DRadioRef = HTMLInputElement; ``` ### DRadioGroupProps -Extend `React.HTMLAttributes, DFormControl`, [DFormControl](/components/Form#DFormControl). +Extend `React.HTMLAttributes`. | Property | Description | Type | Default | @@ -49,5 +49,5 @@ Extend `React.HTMLAttributes, DFormControl`, [DFormControl](/com ### DValue ```tsx -export type DValue = React.InputHTMLAttributes['value']; +type DValue = React.InputHTMLAttributes['value']; ``` diff --git a/packages/ui/src/components/radio/README.zh-Hant.md b/packages/ui/src/components/radio/README.zh-Hant.md index 4815f6bb..ff5863c5 100644 --- a/packages/ui/src/components/radio/README.zh-Hant.md +++ b/packages/ui/src/components/radio/README.zh-Hant.md @@ -12,7 +12,7 @@ title: 单选组 ### DRadioProps -继承 `React.HTMLAttributes, DFormControl`,[DFormControl](/components/Form#DFormControl)。 +继承 `React.HTMLAttributes`。 | 参数 | 说明 | 类型 | 默认值 | @@ -26,12 +26,12 @@ title: 单选组 ### DRadioRef ```tsx -export type DRadioRef = HTMLInputElement; +type DRadioRef = HTMLInputElement; ``` ### DRadioGroupProps -继承 `React.HTMLAttributes, DFormControl`,[DFormControl](/components/Form#DFormControl)。 +继承 `React.HTMLAttributes`。 | 参数 | 说明 | 类型 | 默认值 | @@ -48,5 +48,5 @@ export type DRadioRef = HTMLInputElement; ### DValue ```tsx -export type DValue = React.InputHTMLAttributes['value']; +type DValue = React.InputHTMLAttributes['value']; ``` diff --git a/packages/ui/src/components/radio/Radio.tsx b/packages/ui/src/components/radio/Radio.tsx index 6c07cf2b..4a01176b 100644 --- a/packages/ui/src/components/radio/Radio.tsx +++ b/packages/ui/src/components/radio/Radio.tsx @@ -1,9 +1,16 @@ import type { Updater } from '../../hooks/two-way-binding'; -import type { DFormControl } from '../form'; import React, { useCallback, useId } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useTwoWayBinding, useWave, useRefCallback } from '../../hooks'; +import { + usePrefixConfig, + useComponentConfig, + useCustomContext, + useTwoWayBinding, + useWave, + useRefCallback, + useGeneralState, +} from '../../hooks'; import { getClassName } from '../../utils'; import { DRadioGroupContext } from './RadioGroup'; @@ -11,8 +18,9 @@ export type DRadioRef = HTMLInputElement; export type DValue = React.InputHTMLAttributes['value']; -export interface DRadioProps extends React.HTMLAttributes, DFormControl { +export interface DRadioProps extends React.HTMLAttributes { dModel?: [boolean, Updater?]; + dFormControlName?: string; dDisabled?: boolean; dValue?: DValue; onModelChange?: (checked: boolean) => void; @@ -20,8 +28,8 @@ export interface DRadioProps extends React.HTMLAttributes, DFormCon const Radio: React.ForwardRefRenderFunction = (props, ref) => { const { - dFormControlName, dModel, + dFormControlName, dDisabled = false, dValue, onModelChange, @@ -34,7 +42,8 @@ const Radio: React.ForwardRefRenderFunction = (props, re //#region Context const dPrefix = usePrefixConfig(); - const [{ radioGroupValue, radioGroupName, radioGroupType, radioGroupDisabled, onModelChange: _onModelChange }, radioGroupContext] = + const { gDisabled } = useGeneralState(); + const [{ radioGroupValue, radioGroupName, radioGroupType, radioGroupDisabled, onCheckedChange }, radioGroupContext] = useCustomContext(DRadioGroupContext); //#endregion @@ -49,27 +58,31 @@ const Radio: React.ForwardRefRenderFunction = (props, re const inGroup = radioGroupContext !== null; - const disabled = radioGroupDisabled || dDisabled; + const [checked, changeChecked, { validateClassName, ariaAttribute, controlDisabled }] = useTwoWayBinding( + false, + inGroup ? [radioGroupValue === dValue] : dModel, + onModelChange, + dFormControlName ? { formControlName: dFormControlName, id: _id } : undefined + ); - const [checked, changeChecked] = useTwoWayBinding(false, dModel, onModelChange, { - enable: !inGroup, - name: dFormControlName, - }); - const _checked = inGroup ? radioGroupValue === dValue : checked; + const disabled = dDisabled || radioGroupDisabled || gDisabled || controlDisabled; const handleChange = useCallback>( (e) => { onChange?.(e); if (!disabled) { - changeChecked(true); - _onModelChange?.(dValue); + if (inGroup) { + onCheckedChange?.(dValue); + } else { + changeChecked(true); + } if (radioEl && (radioGroupType === 'fill' || radioGroupType === 'outline')) { wave(radioEl, `var(--${dPrefix}color-primary)`); } } }, - [onChange, disabled, changeChecked, _onModelChange, dValue, radioEl, radioGroupType, wave, dPrefix] + [onChange, disabled, inGroup, radioEl, radioGroupType, onCheckedChange, dValue, changeChecked, wave, dPrefix] ); return ( @@ -77,22 +90,23 @@ const Radio: React.ForwardRefRenderFunction = (props, re {...restProps} ref={radioRef} className={getClassName(className, `${dPrefix}radio`, { - 'is-checked': _checked, + 'is-checked': checked, 'is-disabled': disabled, })} >
    diff --git a/packages/ui/src/components/radio/RadioGroup.tsx b/packages/ui/src/components/radio/RadioGroup.tsx index 224a7fa8..21d11e92 100644 --- a/packages/ui/src/components/radio/RadioGroup.tsx +++ b/packages/ui/src/components/radio/RadioGroup.tsx @@ -1,10 +1,9 @@ import type { Updater } from '../../hooks/two-way-binding'; -import type { DFormControl } from '../form'; import type { DValue } from './Radio'; -import React, { useMemo } from 'react'; +import React, { useId, useMemo } from 'react'; -import { usePrefixConfig, useComponentConfig, useTwoWayBinding } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useTwoWayBinding, useGeneralState } from '../../hooks'; import { getClassName } from '../../utils'; export interface DRadioGroupContextData { @@ -12,12 +11,13 @@ export interface DRadioGroupContextData { radioGroupValue: DValue; radioGroupDisabled: boolean; radioGroupType: DRadioGroupProps['dType']; - onModelChange: (checked: DValue) => void; + onCheckedChange: (checked: DValue) => void; } export const DRadioGroupContext = React.createContext(null); -export interface DRadioGroupProps extends React.HTMLAttributes, DFormControl { +export interface DRadioGroupProps extends React.HTMLAttributes { dModel?: [DValue, Updater?]; + dFormControlName?: string; dName?: string; dDisabled?: boolean; dType?: 'outline' | 'fill'; @@ -28,14 +28,15 @@ export interface DRadioGroupProps extends React.HTMLAttributes, export function DRadioGroup(props: DRadioGroupProps) { const { - dFormControlName, dModel, + dFormControlName, dName, dDisabled = false, dType, dSize, dVertical = false, onModelChange, + id, className, children, ...restProps @@ -43,30 +44,44 @@ export function DRadioGroup(props: DRadioGroupProps) { //#region Context const dPrefix = usePrefixConfig(); + const { gSize, gDisabled } = useGeneralState(); //#endregion - const [value, changeValue] = useTwoWayBinding(undefined, dModel, onModelChange, { name: dFormControlName }); + const uniqueId = useId(); + const _id = id ?? `${dPrefix}radio-group-${uniqueId}`; + + const [value, changeValue, { ariaAttribute, controlDisabled }] = useTwoWayBinding( + undefined, + dModel, + onModelChange, + dFormControlName ? { formControlName: dFormControlName, id: _id } : undefined + ); + + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled || controlDisabled; const contextValue = useMemo( () => ({ radioGroupName: dName, radioGroupValue: value, radioGroupType: dType, - radioGroupDisabled: dDisabled, - onModelChange: (value) => { + radioGroupDisabled: disabled, + onCheckedChange: (value) => { changeValue(value); }, }), - [changeValue, dDisabled, dName, dType, value] + [changeValue, dName, dType, disabled, value] ); return (
    `. ### DSelectBaseProps\ -Extend `Omit, DFormControl`, [DSelectBoxProps](/components/Interface#DSelectBoxProps), [DFormControl](/components/Form#DFormControl). +Extend `Omit`. | Property | Description | Type | Default | @@ -61,7 +61,7 @@ Extend `Omit, DFormControl`, [DSele ### DSelectBaseOption\ ```tsx -export interface DSelectBaseOption { +interface DSelectBaseOption { dLabel: string; dValue: T; dDisabled?: boolean; @@ -72,7 +72,7 @@ export interface DSelectBaseOption { ### DSelectOption\ ```tsx -export interface DSelectOption { +interface DSelectOption { dLabel: string; dValue?: T; dDisabled?: boolean; diff --git a/packages/ui/src/components/select/README.zh-Hant.md b/packages/ui/src/components/select/README.zh-Hant.md index 55a1a9ad..7c79476a 100644 --- a/packages/ui/src/components/select/README.zh-Hant.md +++ b/packages/ui/src/components/select/README.zh-Hant.md @@ -40,7 +40,7 @@ title: 选择框 ### DSelectBaseProps\ -继承 `Omit, DFormControl`,[DSelectBoxProps](/components/Interface#DSelectBoxProps),[DFormControl](/components/Form#DFormControl)。 +继承 `Omit`。 | 参数 | 说明 | 类型 | 默认值 | @@ -60,7 +60,7 @@ title: 选择框 ### DSelectBaseOption\ ```tsx -export interface DSelectBaseOption { +interface DSelectBaseOption { dLabel: string; dValue: T; dDisabled?: boolean; @@ -71,7 +71,7 @@ export interface DSelectBaseOption { ### DSelectOption\ ```tsx -export interface DSelectOption { +interface DSelectOption { dLabel: string; dValue?: T; dDisabled?: boolean; diff --git a/packages/ui/src/components/select/Select.tsx b/packages/ui/src/components/select/Select.tsx index 7855af02..66715b79 100644 --- a/packages/ui/src/components/select/Select.tsx +++ b/packages/ui/src/components/select/Select.tsx @@ -1,6 +1,5 @@ import type { Updater } from '../../hooks/two-way-binding'; import type { DSelectBoxProps } from '../_select-box'; -import type { DFormControl } from '../form'; import type { Draft } from 'immer'; import { isArray, isNull, isNumber, isString, isUndefined } from 'lodash'; @@ -8,11 +7,19 @@ import React, { useCallback, useRef, useEffect, useMemo, useState, useId } from import { flushSync } from 'react-dom'; import { filter } from 'rxjs'; -import { usePrefixConfig, useComponentConfig, useTwoWayBinding, useAsync, useImmer, useTranslation, useRefCallback } from '../../hooks'; +import { + usePrefixConfig, + useComponentConfig, + useTwoWayBinding, + useAsync, + useImmer, + useTranslation, + useRefCallback, + useGeneralState, +} from '../../hooks'; import { getClassName, getVerticalSideStyle } from '../../utils'; import { DPopup } from '../_popup'; import { DSelectBox } from '../_select-box'; -import { useCompose } from '../compose'; import { DDropdown, DDropdownItem } from '../dropdown'; import { DIcon } from '../icon'; import { DTag } from '../tag'; @@ -49,7 +56,8 @@ export interface DSelectOption { [index: string | symbol]: unknown; } -export interface DSelectBaseProps extends Omit, DFormControl { +export interface DSelectBaseProps extends Omit { + dFormControlName?: string; dVisible?: [boolean, Updater?]; dOptions: Array>; dOptionRender?: (option: DSelectBaseOption, index: number) => React.ReactNode; @@ -99,9 +107,9 @@ export function DSelect( } ) { const { + dModel, dFormControlName, dVisible, - dModel, dOptions, dOptionRender = DEFAULT_PROPS.dOptionRender, dCustomSelected, @@ -120,6 +128,7 @@ export function DSelect( onCreateOption, onSearch, onExceed, + id, className, children, onChange, @@ -130,7 +139,7 @@ export function DSelect( //#region Context const dPrefix = usePrefixConfig(); - const { composeSize, composeDisabled } = useCompose(); + const { gSize, gDisabled } = useGeneralState(); //#endregion //#region Ref @@ -152,18 +161,22 @@ export function DSelect( const asyncCapture = useAsync(); const uniqueId = useId(); + const _id = id ?? `${dPrefix}select-${uniqueId}`; const [focusId, setfocusId] = useState(null); const [searchValue, setSearchValue] = useState(''); const [createOptions, setCreateOptions] = useImmer>>([]); const [visible, _changeVisible] = useTwoWayBinding(false, dVisible, onVisibleChange); - const [select, changeSelect] = useTwoWayBinding(dMultiple ? [] : null, dModel, onModelChange, { - name: dFormControlName, - }); + const [select, changeSelect, { validateClassName, ariaAttribute, controlDisabled }] = useTwoWayBinding( + dMultiple ? [] : null, + dModel, + onModelChange, + dFormControlName ? { formControlName: dFormControlName, id: _id } : undefined + ); - const size = composeSize ?? dSize; - const disabled = composeDisabled || dDisabled; + const size = dSize ?? gSize; + const disabled = dDisabled || gDisabled || controlDisabled; const hasSearchChar = searchValue.length > 0; @@ -442,6 +455,7 @@ export function DSelect( }; }, []); + //#region DidUpdate useEffect(() => { if (hasSearchChar) { if (selectListEl) { @@ -721,6 +735,7 @@ export function DSelect( select, size, ]); + //#endregion const hasSelect = isArray(select) ? select.length > 0 : !isNull(select); @@ -844,7 +859,8 @@ export function DSelect( {...restProps} {...renderProps} ref={selectBoxRef} - className={getClassName(className, `${dPrefix}select`, { + id={_id} + className={getClassName(className, `${dPrefix}select`, validateClassName, { [`${dPrefix}select--multiple`]: dMultiple, })} dSuffix={suffixNode} @@ -853,6 +869,7 @@ export function DSelect( dLoading={dLoading} dDisabled={disabled} dSize={size} + dAriaAttribute={ariaAttribute} onClear={handleClear} onClick={handleClick} onKeyDown={handleKeyDown} diff --git a/packages/ui/src/components/tabs/Tab.tsx b/packages/ui/src/components/tabs/Tab.tsx index 51724b2c..689fa9ba 100644 --- a/packages/ui/src/components/tabs/Tab.tsx +++ b/packages/ui/src/components/tabs/Tab.tsx @@ -1,7 +1,7 @@ import { isUndefined } from 'lodash'; import React, { useCallback, useEffect } from 'react'; -import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useTranslation } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useCustomContext, useRefCallback, useTranslation, useStateBackflow } from '../../hooks'; import { getClassName, toId } from '../../utils'; import { DButton } from '../button'; import { DIcon } from '../icon'; @@ -32,7 +32,7 @@ export function DTab(props: DTabProps) { //#region Context const dPrefix = usePrefixConfig(); - const [{ tabsActiveId, getDotStyle, onActiveChange, onTabRendered, onClose }] = useCustomContext(DTabsContext); + const [{ tabsActiveId, getDotStyle, onActiveChange, onClose }] = useCustomContext(DTabsContext); //#endregion //#region Ref @@ -45,6 +45,8 @@ export function DTab(props: DTabProps) { const panelId = `${dPrefix}tabpanel-${toId(dId)}`; + useStateBackflow(dId, tabEl); + const handleClick = useCallback( (e) => { onClick?.(e); @@ -65,10 +67,6 @@ export function DTab(props: DTabProps) { ); //#region DidUpdate - useEffect(() => { - !__dropdown && onTabRendered?.(dId, tabEl); - }, [__dropdown, dId, onTabRendered, tabEl]); - useEffect(() => { if (!__dropdown && tabsActiveId === dId) { getDotStyle?.(); diff --git a/packages/ui/src/components/tabs/Tabs.tsx b/packages/ui/src/components/tabs/Tabs.tsx index cd82016f..c57b188f 100644 --- a/packages/ui/src/components/tabs/Tabs.tsx +++ b/packages/ui/src/components/tabs/Tabs.tsx @@ -1,11 +1,20 @@ +import type { DStateBackflowContextData } from '../../hooks/state-backflow'; import type { Updater } from '../../hooks/two-way-binding'; import type { DDropdownProps } from '../dropdown'; import type { DTabProps } from './Tab'; -import { isUndefined } from 'lodash'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { usePrefixConfig, useComponentConfig, useImmer, useTwoWayBinding, useRefCallback, useAsync, useTranslation } from '../../hooks'; +import { + usePrefixConfig, + useComponentConfig, + useImmer, + useTwoWayBinding, + useRefCallback, + useAsync, + useTranslation, + DStateBackflowContext, +} from '../../hooks'; import { getClassName, toId } from '../../utils'; import { DDrag, DDragPlaceholder, DDrop } from '../drag-drop'; import { DDropdown, DDropdownItem } from '../dropdown'; @@ -15,7 +24,6 @@ export interface DTabsContextData { tabsActiveId: string | null; getDotStyle: () => void; onActiveChange: (id: string) => void; - onTabRendered: (id: string, el?: HTMLElement | null) => void; onClose: (id: string) => void; } export const DTabsContext = React.createContext(null); @@ -63,9 +71,9 @@ export function DTabs(props: DTabsProps) { const [tablistWrapperEl, tablistWrapperRef] = useRefCallback(); //#endregion - const dataRef = useRef<{ clearTid: (() => void) | null; tabEls: Map }>({ + const dataRef = useRef<{ clearTid: (() => void) | null; scrollTid: (() => void) | null }>({ clearTid: null, - tabEls: new Map(), + scrollTid: null, }); const [t] = useTranslation('Common'); @@ -75,6 +83,7 @@ export function DTabs(props: DTabsProps) { const [listOverflow, setListOverflow] = useState(true); const [dropdownList, setDropdownList] = useImmer>>([]); const [scrollEnd, setScrollEnd] = useState(false); + const [tabEls, setTabEls] = useImmer(new Map()); const isHorizontal = dPlacement === 'top' || dPlacement === 'bottom'; const [activeId, changeActiveId] = useTwoWayBinding( @@ -101,24 +110,28 @@ export function DTabs(props: DTabsProps) { const tablistWrapperRect = tablistWrapperEl.getBoundingClientRect(); const dropdownList: Array> = []; React.Children.forEach(children as Array>, (child) => { - const el = dataRef.current.tabEls.get(child.props.dId); - if (el) { - const elRect = el.getBoundingClientRect(); - if (isHorizontal) { - if (elRect.right + 52 + (onAddClick ? 52 : 0) > tablistWrapperRect.right || elRect.left < tablistWrapperRect.left) { - dropdownList.push(child); - } - } else { - if (elRect.bottom + 36 + (onAddClick ? 36 : 0) > tablistWrapperRect.bottom || elRect.top < tablistWrapperRect.top) { - dropdownList.push(child); + for (const { id, el } of tabEls.values()) { + if (id === child.props.dId) { + if (el) { + const elRect = el.getBoundingClientRect(); + if (isHorizontal) { + if (elRect.right + 52 + (onAddClick ? 52 : 0) > tablistWrapperRect.right || elRect.left < tablistWrapperRect.left) { + dropdownList.push(child); + } + } else { + if (elRect.bottom + 36 + (onAddClick ? 36 : 0) > tablistWrapperRect.bottom || elRect.top < tablistWrapperRect.top) { + dropdownList.push(child); + } + } } + break; } } }); setDropdownList(dropdownList); } } - }, [children, isHorizontal, onAddClick, setDropdownList, setListOverflow, tablistWrapperEl]); + }, [children, isHorizontal, onAddClick, setDropdownList, tabEls, tablistWrapperEl]); const checkScrollEnd = useCallback(() => { if (tablistWrapperEl) { @@ -171,9 +184,12 @@ export function DTabs(props: DTabsProps) { }, [onAddClick]); const handleScroll = useCallback(() => { - updateDropdown(); checkScrollEnd(); - }, [checkScrollEnd, updateDropdown]); + dataRef.current.scrollTid && dataRef.current.scrollTid(); + dataRef.current.scrollTid = asyncCapture.setTimeout(() => { + updateDropdown(); + }, 300); + }, [asyncCapture, checkScrollEnd, updateDropdown]); //#region DidUpdate useLayoutEffect(() => { @@ -235,13 +251,6 @@ export function DTabs(props: DTabsProps) { onActiveChange: (id) => { changeActiveId(id); }, - onTabRendered: (id, el) => { - if (isUndefined(el)) { - dataRef.current.tabEls.delete(id); - } else { - dataRef.current.tabEls.set(id, el); - } - }, onClose: (id) => { if (activeId === id) { changeActiveId(null); @@ -252,111 +261,134 @@ export function DTabs(props: DTabsProps) { [activeId, changeActiveId, getDotStyle, onClose] ); + const stateBackflowContextValue = useMemo( + () => ({ + addState: (identity, dId, tabEl) => { + setTabEls((draft) => { + draft.set(identity, { id: dId, el: tabEl }); + }); + }, + updateState: (identity, dId, tabEl) => { + setTabEls((draft) => { + draft.set(identity, { id: dId, el: tabEl }); + }); + }, + removeState: (identity) => { + setTabEls((draft) => { + draft.delete(identity); + }); + }, + }), + [setTabEls] + ); + return ( - -
    -
    -
    - {dDraggable ? ( - } - onOrderChange={handleOrderChange} - onDragStart={handleDragStart} - > - {childs} - - ) : ( - childs - )} - {(listOverflow || onAddClick) && ( -
    - {listOverflow && ( - - - - -
    - } - dCloseOnItemClick={false} - dPlacement={dPlacement === 'left' ? 'bottom-left' : 'bottom-right'} - {...dDropdownProps} - > - {dropdownList.map((item) => ( - - {React.cloneElement(item, { - ...item.props, - __dropdown: true, - })} - - ))} - - )} - {onAddClick && ( -
    - - - - -
    - )} -
    - )} - {activeId === null ? null : dType === 'wrap' ? ( -
    - ) : dType === 'slider' ? ( -
    - ) : ( -
    - )} + + +
    +
    +
    + {dDraggable ? ( + } + onOrderChange={handleOrderChange} + onDragStart={handleDragStart} + > + {childs} + + ) : ( + childs + )} + {(listOverflow || onAddClick) && ( +
    + {listOverflow && ( + + + + +
    + } + dCloseOnItemClick={false} + dPlacement={dPlacement === 'left' ? 'bottom-left' : 'bottom-right'} + {...dDropdownProps} + > + {dropdownList.map((item) => ( + + {React.cloneElement(item, { + ...item.props, + __dropdown: true, + })} + + ))} + + )} + {onAddClick && ( +
    + + + + +
    + )} +
    + )} + {activeId === null ? null : dType === 'wrap' ? ( +
    + ) : dType === 'slider' ? ( +
    + ) : ( +
    + )} +
    + {tabpanels.map((tabpanel) => ( + + ))}
    - {tabpanels.map((tabpanel) => ( - - ))} -
    -
    + + ); } diff --git a/packages/ui/src/components/tag/Tag.tsx b/packages/ui/src/components/tag/Tag.tsx index 3c1db809..ee75699b 100644 --- a/packages/ui/src/components/tag/Tag.tsx +++ b/packages/ui/src/components/tag/Tag.tsx @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { usePrefixConfig, useComponentConfig, useTranslation } from '../../hooks'; +import { usePrefixConfig, useComponentConfig, useTranslation, useGeneralState } from '../../hooks'; import { getClassName, pSBC } from '../../utils'; import { DIcon } from '../icon'; @@ -29,8 +29,11 @@ export function DTag(props: DTagProps) { //#region Context const dPrefix = usePrefixConfig(); + const { gDisabled } = useGeneralState(); //#endregion + const size = dSize ?? gDisabled; + const [t] = useTranslation('Common'); const handleCloseClick = useCallback( @@ -44,7 +47,7 @@ export function DTag(props: DTagProps) {
    , DFormControl`, [DFormControl](/components/Form#DFormControl). +Extend `React.InputHTMLAttributes`. | Property | Description | Type | Default | @@ -28,5 +28,5 @@ Extend `React.InputHTMLAttributes, DFormControl`, [DFormCon ### DTextareaRef ```tsx -export type DTextareaRef = HTMLTextAreaElement; +type DTextareaRef = HTMLTextAreaElement; ``` diff --git a/packages/ui/src/components/textarea/README.zh-Hant.md b/packages/ui/src/components/textarea/README.zh-Hant.md index 75183bde..797007a8 100644 --- a/packages/ui/src/components/textarea/README.zh-Hant.md +++ b/packages/ui/src/components/textarea/README.zh-Hant.md @@ -12,7 +12,7 @@ title: 文本域 ### DTextareaProps -继承 `React.InputHTMLAttributes, DFormControl`,[DFormControl](/components/Form#DFormControl)。 +继承 `React.InputHTMLAttributes`。 | 参数 | 说明 | 类型 | 默认值 | @@ -27,5 +27,5 @@ title: 文本域 ### DTextareaRef ```tsx -export type DTextareaRef = HTMLTextAreaElement; +type DTextareaRef = HTMLTextAreaElement; ``` diff --git a/packages/ui/src/components/textarea/Textarea.tsx b/packages/ui/src/components/textarea/Textarea.tsx index f16eae17..ffeb6ff0 100644 --- a/packages/ui/src/components/textarea/Textarea.tsx +++ b/packages/ui/src/components/textarea/Textarea.tsx @@ -1,8 +1,7 @@ import type { Updater } from '../../hooks/two-way-binding'; -import type { DFormControl } from '../form'; import { isFunction, isNumber, isUndefined } from 'lodash'; -import React, { useEffect, useImperativeHandle, useMemo, useState } from 'react'; +import React, { useEffect, useId, useImperativeHandle, useMemo, useState } from 'react'; import { useCallback } from 'react'; import { usePrefixConfig, useComponentConfig, useTwoWayBinding, useRefCallback } from '../../hooks'; @@ -10,8 +9,9 @@ import { getClassName, mergeStyle } from '../../utils'; export type DTextareaRef = HTMLTextAreaElement; -export interface DTextareaProps extends React.InputHTMLAttributes, DFormControl { +export interface DTextareaProps extends React.InputHTMLAttributes { dModel?: [string, Updater?]; + dFormControlName?: string; dRows?: 'auto' | { minRows?: number; maxRows?: number }; dResizable?: boolean; dShowCount?: boolean | ((num: number) => React.ReactNode); @@ -20,12 +20,13 @@ export interface DTextareaProps extends React.InputHTMLAttributes = (props, ref) => { const { - dFormControlName, dModel, + dFormControlName, dRows, dResizable = true, dShowCount = false, onModelChange, + id, className, style, maxLength, @@ -45,9 +46,17 @@ const Textarea: React.ForwardRefRenderFunction = ( const [textareaEl, textareaRef] = useRefCallback(); //#endregion - const [value, changeValue] = useTwoWayBinding('', dModel, onModelChange, { - name: dFormControlName, - }); + const uniqueId = useId(); + const _id = id ?? `${dPrefix}input-${uniqueId}`; + + const [value, changeValue, { validateClassName, ariaAttribute, controlDisabled }] = useTwoWayBinding( + '', + dModel, + onModelChange, + dFormControlName ? { formControlName: dFormControlName, id: _id } : undefined + ); + + const _disabled = disabled || controlDisabled; const [rowNum, setRowNum] = useState(1); @@ -110,16 +119,18 @@ const Textarea: React.ForwardRefRenderFunction = ( <>