diff --git a/package.json b/package.json index 7d3e0a3..c2da94a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rc-component/steps", - "version": "1.1.0", + "version": "1.2.0-alpha.5", "description": "steps ui component for react", "keywords": [ "react", diff --git a/src/Context.ts b/src/Context.ts new file mode 100644 index 0000000..ed1f46e --- /dev/null +++ b/src/Context.ts @@ -0,0 +1,10 @@ +import * as React from 'react'; +import type { StepsProps } from './Steps'; + +export interface StepsContextProps { + prefixCls: string; + classNames: NonNullable; + styles: NonNullable; +} + +export const StepsContext = React.createContext(null!); diff --git a/src/Rail.tsx b/src/Rail.tsx index 85503c3..5850146 100644 --- a/src/Rail.tsx +++ b/src/Rail.tsx @@ -1,23 +1,18 @@ import * as React from 'react'; import cls from 'classnames'; -import type { Status, StepsProps } from './Steps'; +import type { Status } from './Steps'; export interface RailProps { prefixCls: string; - classNames: StepsProps['classNames']; - styles: StepsProps['styles']; + className: string; + style: React.CSSProperties; status: Status; } export default function Rail(props: RailProps) { - const { prefixCls, classNames, styles, status } = props; + const { prefixCls, className, style, status } = props; const railCls = `${prefixCls}-rail`; // ============================= render ============================= - return ( -
- ); + return
; } diff --git a/src/Step.tsx b/src/Step.tsx index 85606d5..a305f97 100644 --- a/src/Step.tsx +++ b/src/Step.tsx @@ -4,6 +4,12 @@ import cls from 'classnames'; import KeyCode from '@rc-component/util/lib/KeyCode'; import type { Status, StepItem, StepsProps } from './Steps'; import Rail from './Rail'; +import { UnstableContext } from './UnstableContext'; +import StepIcon, { StepIconSemanticContext } from './StepIcon'; + +function hasContent(value: T) { + return value !== undefined && value !== null; +} export interface StepProps { // style @@ -18,12 +24,6 @@ export interface StepProps { index: number; last: boolean; - // stepIndex?: number; - // stepNumber?: number; - // title?: React.ReactNode; - // subTitle?: React.ReactNode; - // description?: React.ReactNode; - // render iconRender?: StepsProps['iconRender']; icon?: React.ReactNode; @@ -59,6 +59,9 @@ export default function Step(props: StepProps) { const itemCls = `${prefixCls}-item`; + // ==================== Internal Context ==================== + const { railFollowPrevStatus } = React.useContext(UnstableContext); + // ========================== Data ========================== const { onClick: onItemClick, @@ -72,6 +75,9 @@ export default function Step(props: StepProps) { className, style, + classNames: itemClassNames = {}, + styles: itemStyles = {}, + ...restItemProps } = data; @@ -92,8 +98,8 @@ export default function Step(props: StepProps) { const accessibilityProps: { role?: string; tabIndex?: number; - onClick?: React.MouseEventHandler; - onKeyDown?: React.KeyboardEventHandler; + onClick?: React.MouseEventHandler; + onKeyDown?: React.KeyboardEventHandler; } = {}; if (clickable) { @@ -115,6 +121,9 @@ export default function Step(props: StepProps) { // ========================= Render ========================= const mergedStatus = status || 'wait'; + const hasTitle = hasContent(title); + const hasSubTitle = hasContent(subTitle); + const classString = cls( itemCls, `${itemCls}-${mergedStatus}`, @@ -122,39 +131,99 @@ export default function Step(props: StepProps) { [`${itemCls}-custom`]: icon, [`${itemCls}-active`]: active, [`${itemCls}-disabled`]: disabled === true, + [`${itemCls}-empty-header`]: !hasTitle && !hasSubTitle, }, className, classNames.item, + itemClassNames.root, ); + let iconNode = ; + if (iconRender) { + iconNode = iconRender(iconNode, { + ...renderInfo, + components: { + Icon: StepIcon, + }, + }) as React.ReactElement; + } + const wrapperNode = ( -
-
- {iconRender?.(renderInfo)} -
-
-
-
- {title} -
- {subTitle && ( +
+ {/* Icon */} + + {iconNode} + + +
+
+ {hasTitle && ( +
+ {title} +
+ )} + {hasSubTitle && (
{subTitle}
)} {!last && ( - + )}
- {mergedContent && ( + {hasContent(mergedContent) && (
{mergedContent}
@@ -164,17 +233,18 @@ export default function Step(props: StepProps) { ); let stepNode: React.ReactNode = ( -
{itemWrapperRender ? itemWrapperRender(wrapperNode) : wrapperNode} -
+ ); if (itemRender) { diff --git a/src/StepIcon.tsx b/src/StepIcon.tsx new file mode 100644 index 0000000..ea2bd07 --- /dev/null +++ b/src/StepIcon.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; +import cls from 'classnames'; +import { StepsContext } from './Context'; +import pickAttrs from '@rc-component/util/lib/pickAttrs'; + +export interface StepIconSemanticContextProps { + className?: string; + style?: React.CSSProperties; +} + +export const StepIconSemanticContext = React.createContext({}); + +export type StepIconProps = React.HTMLAttributes; + +const StepIcon = React.forwardRef((props, ref) => { + const { className, style, children, ...restProps } = props; + + const { prefixCls, classNames, styles } = React.useContext(StepsContext); + const { className: itemClassName, style: itemStyle } = React.useContext(StepIconSemanticContext); + + const itemCls = `${prefixCls}-item`; + + return ( +
+ {children} +
+ ); +}); + +export default StepIcon; diff --git a/src/Steps.tsx b/src/Steps.tsx index 6fa7528..f928208 100644 --- a/src/Steps.tsx +++ b/src/Steps.tsx @@ -2,9 +2,13 @@ import cls from 'classnames'; import React from 'react'; import Step from './Step'; +import { StepsContext, type StepsContextProps } from './Context'; +import type StepIcon from './StepIcon'; export type Status = 'error' | 'process' | 'finish' | 'wait'; +const EmptyObject = {}; + export type SemanticName = | 'root' | 'item' @@ -17,6 +21,17 @@ export type SemanticName = | 'itemIcon' | 'itemRail'; +export type ItemSemanticName = + | 'root' + | 'wrapper' + | 'header' + | 'title' + | 'subtitle' + | 'section' + | 'content' + | 'icon' + | 'rail'; + export type StepItem = { /** @deprecated Please use `content` instead. */ description?: React.ReactNode; @@ -26,7 +41,9 @@ export type StepItem = { status?: Status; subTitle?: React.ReactNode; title?: React.ReactNode; -} & Pick, 'onClick' | 'className' | 'style'>; + classNames?: Partial>; + styles?: Partial>; +} & Pick, 'onClick' | 'className' | 'style'>; export type StepIconRender = (info: { index: number; @@ -65,7 +82,14 @@ export interface StepsProps { onChange?: (current: number) => void; // render - iconRender?: (info: RenderInfo) => React.ReactNode; + iconRender?: ( + originNode: React.ReactElement, + info: RenderInfo & { + components: { + Icon: typeof StepIcon; + }; + }, + ) => React.ReactNode; itemRender?: (originNode: React.ReactElement, info: RenderInfo) => React.ReactNode; itemWrapperRender?: (originNode: React.ReactElement) => React.ReactNode; } @@ -76,13 +100,13 @@ export default function Steps(props: StepsProps) { prefixCls = 'rc-steps', style, className, - classNames = {}, - styles = {}, + classNames = EmptyObject as NonNullable, + styles = EmptyObject as NonNullable, rootClassName, // layout - orientation = 'horizontal', - titlePlacement = 'horizontal', + orientation, + titlePlacement, // data status = 'process', @@ -143,6 +167,16 @@ export default function Steps(props: StepsProps) { } }; + // ============================ contexts ============================ + const stepIconContext = React.useMemo( + () => ({ + prefixCls, + classNames, + styles, + }), + [prefixCls, classNames, styles], + ); + // ============================= render ============================= const renderStep = (item: StepItem, index: number) => { const stepIndex = initial + index; @@ -178,7 +212,7 @@ export default function Steps(props: StepsProps) { }; return ( -
- {mergedItems.map(renderStep)} -
+ + {mergedItems.map(renderStep)} + + ); } diff --git a/src/UnstableContext.ts b/src/UnstableContext.ts new file mode 100644 index 0000000..d755313 --- /dev/null +++ b/src/UnstableContext.ts @@ -0,0 +1,11 @@ +import * as React from 'react'; + +export interface UnstableContextProps { + /** + * Used for Timeline component `reverse` prop. + * Safe to remove if refactor. + */ + railFollowPrevStatus?: boolean; +} + +export const UnstableContext = React.createContext({}); diff --git a/src/index.ts b/src/index.ts index 8c5e175..22f5a76 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,4 +3,5 @@ import Step from './Step'; export { Step }; export type { StepsProps }; +export { UnstableContext } from './UnstableContext'; export default Steps; diff --git a/tests/__snapshots__/index.test.tsx.snap b/tests/__snapshots__/index.test.tsx.snap index 3bbe2ee..0141179 100644 --- a/tests/__snapshots__/index.test.tsx.snap +++ b/tests/__snapshots__/index.test.tsx.snap @@ -1,10 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Steps render renders correctly 1`] = ` -
-
-
-
+
  • -
    -
    +
  • -
    -
    +
  • -
    -
    + + `; exports[`Steps render renders current correctly 1`] = ` -
    -
    -
    -
    +
  • -
    -
    +
  • -
    -
    +
  • -
    - + + `; exports[`Steps render renders progressDot correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders progressDot function correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders status correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders step with description 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders step with description and status 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders stepIcon function correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders titlePlacement correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders vertical correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps render renders with falsy children 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - -
    +
  • - - + + `; exports[`Steps should render customIcon correctly 1`] = ` -
    -
    -
    -
    +
  • - -
    +
  • - - + + `; diff --git a/tests/index.test.tsx b/tests/index.test.tsx index 9647480..cc59327 100644 --- a/tests/index.test.tsx +++ b/tests/index.test.tsx @@ -344,4 +344,23 @@ describe('Steps', () => { expect(container.querySelector('.rc-steps-item')).toBeTruthy(); expect(container.querySelector('.rc-steps-item > .bamboo')).toBeTruthy(); }); + + it('iconRender', () => { + const { container } = render( + { + return little; + }} + />, + ); + + const iconEle = container.querySelector('.rc-steps-item-icon')!; + expect(iconEle).toHaveClass('bamboo'); + expect(iconEle.textContent).toBe('little'); + }); }); diff --git a/tests/semantic.test.tsx b/tests/semantic.test.tsx index d84b735..dd7888a 100644 --- a/tests/semantic.test.tsx +++ b/tests/semantic.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render } from '@testing-library/react'; import Steps, { type StepsProps } from '../src'; -import type { SemanticName } from '../src/Steps'; +import type { ItemSemanticName, SemanticName } from '../src/Steps'; describe('Steps.Semantic', () => { const renderSteps = (props: Partial) => ( @@ -9,7 +9,7 @@ describe('Steps.Semantic', () => { items={Array.from({ length: 3 }, (_, index) => ({ title: `Step ${index + 1}`, subTitle: `SubTitle ${index + 1}`, - description: `Description ${index + 1}`, + content: `Content ${index + 1}`, }))} {...props} /> @@ -73,4 +73,65 @@ describe('Steps.Semantic', () => { expect(element).toHaveStyle(style); }); }); + + it('item semantic structure', () => { + const classNames: Record = { + root: 'custom-root', + wrapper: 'custom-wrapper', + header: 'custom-header', + title: 'custom-title', + subtitle: 'custom-subtitle', + section: 'custom-section', + content: 'custom-content', + icon: 'custom-icon', + rail: 'custom-rail', + }; + + const classNamesTargets: Record = { + root: 'rc-steps-item', + wrapper: 'rc-steps-item-wrapper', + header: 'rc-steps-item-header', + title: 'rc-steps-item-title', + subtitle: 'rc-steps-item-subtitle', + section: 'rc-steps-item-section', + content: 'rc-steps-item-content', + icon: 'rc-steps-item-icon', + rail: 'rc-steps-item-rail', + }; + + const styles: Record> = { + root: { color: 'red' }, + wrapper: { color: 'green' }, + header: { color: 'orange' }, + title: { color: 'pink' }, + subtitle: { color: 'cyan' }, + section: { color: 'purple' }, + content: { color: 'magenta' }, + icon: { color: 'yellow' }, + rail: { color: 'lime' }, + }; + + const { container } = render( + renderSteps({ + items: Array.from({ length: 2 }, (_, index) => ({ + title: `Title ${index + 1}`, + subTitle: `SubTitle ${index + 1}`, + content: `Content ${index + 1}`, + classNames, + styles, + })), + }), + ); + + Object.keys(classNames).forEach((key) => { + const className = classNames[key as SemanticName]; + const oriClassName = classNamesTargets[key as SemanticName]; + const style = styles[key as SemanticName]; + + const element = container.querySelector(`.${className}`); + expect(element).toBeTruthy(); + expect(element).toHaveClass(oriClassName); + expect(element).toHaveStyle(style); + }); + }); });