diff --git a/migrate-from-v2.md b/migrate-from-v2.md index cb1ec0b79..1b596afe6 100644 --- a/migrate-from-v2.md +++ b/migrate-from-v2.md @@ -291,6 +291,16 @@ plugins: [ - `offset` 重命名为 `indent` +#### SideBar + +- 新增SideBar组件 +- 支持属性value,用于当前激活的`item`的key +- 支持属性defaultValue, 表示未设置value时,`item`的key的默认值 +- 支持属性contentDuration, 用于内容滚动动画时长 +- 支持属性sidebarDuration, 用于侧栏滚动动画时长 +- 支持属性onClick, 点击标签时触发 +- 支持属性onChange, 当前激活的标签改变时触发 + #### Tabbar - `unactiveColor` 重命名为 `inactiveColor` @@ -597,7 +607,7 @@ plugins: [ - 移除 `isAsync`,通过 `checked`实现 - 移除 `activeColor` ,通过css变量`--nutui-switch-open-background-color`实现 - 移除 `inactiveColor`,通过css变量`--nutui-switch-close-background-color`实现 -- `activeText 属性类型更改为 `ReactNode` +- `activeText` 属性类型更改为`ReactNode` - `inactiveText` 属性类型更改为 `ReactNode` #### Toast @@ -780,7 +790,7 @@ plugins: [ - 移除 `pageContent`,通过 indicator 实现 - `autoplay` 重命名为 `autoplay` - `initPage` 重命名为 `defaultValue` - - `paginationVisible` 重命名为 `indicator`,类型改为` ReactNode` + - `paginationVisible` 重命名为 `indicator`,类型改为`ReactNode` - `isPreventDefault` 重命名为 `preventDefault` - `isStopPropagation` 重命名为 `stopPropagation` - `isCenter` 重命名为 `center` diff --git a/src/config.json b/src/config.json index 30e85c3ff..618306656 100644 --- a/src/config.json +++ b/src/config.json @@ -345,6 +345,30 @@ "taro": true, "author": "hx" }, + { + "version": "3.0.0", + "name": "SideBar", + "type": "component", + "cName": "侧边栏导航", + "desc": "用于侧边内容选择和切换", + "sort": 10, + "show": true, + "taro": true, + "author": "Alex.hxy", + "v15": true + }, + { + "version": "3.0.0", + "name": "SideBarItem", + "type": "component", + "cName": "侧边栏导航子组件", + "desc": "用于侧边内容选择和切换", + "sort": 10, + "show": false, + "taro": true, + "author": "Alex.hxy", + "v15": true + }, { "version": "2.0.0", "name": "SideNavBarItem", diff --git a/src/packages/sidebar/_test_/sidebar.spec.tsx b/src/packages/sidebar/_test_/sidebar.spec.tsx new file mode 100644 index 000000000..25462fd1f --- /dev/null +++ b/src/packages/sidebar/_test_/sidebar.spec.tsx @@ -0,0 +1,70 @@ +import * as React from 'react' +import { fireEvent, render } from '@testing-library/react' +import '@testing-library/jest-dom' +import { SideBar } from '../sidebar' + +const list = Array.from(new Array(3).keys()) + +test('should render defaultValue correctly', async () => { + const { container } = render( + + {list.map((item) => ( + + Content {item + 1} + + ))} + + ) + const item = container.querySelectorAll('.nut-sidebar-titles-item')[0] + expect(item).toHaveClass('nut-sidebar-titles-item-active') +}) + +test('should choose and scroll to the right option', async () => { + const onChange = vi.fn() + const { container } = render( + + {list.map((item) => ( + + Content {item + 1} + + ))} + + ) + const items = container.querySelectorAll('.nut-sidebar-titles-item') + fireEvent.click(items[1]) + expect(onChange).toHaveBeenCalledWith(1) +}) +test('disabled option', async () => { + const onChange = vi.fn() + const { container } = render( + + {list.map((item) => ( + + Content {item + 1} + + ))} + + ) + const items = container.querySelectorAll('.nut-sidebar-titles-item') + fireEvent.click(items[1]) + expect(onChange).not.toHaveBeenCalled() +}) +test('matchByValue', async () => { + const list1 = [ + { value: 'a', title: 'Opt a Opt a Opt a Opt a' }, + { value: 'b', title: 'Opt b' }, + { value: 'c', title: 'Opt c' }, + ] + const onChange = vi.fn() + const { container } = render( + + {list1.map((item) => ( + + Content {item.value} + + ))} + + ) + const items = container.querySelectorAll('.nut-sidebar-titles-item') + expect(items[1]).toHaveClass('nut-sidebar-titles-item-active') +}) diff --git a/src/packages/sidebar/demo.taro.tsx b/src/packages/sidebar/demo.taro.tsx new file mode 100644 index 000000000..9b50b5b2e --- /dev/null +++ b/src/packages/sidebar/demo.taro.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import Taro from '@tarojs/taro' +import { ScrollView, View } from '@tarojs/components' +import { useTranslate } from '@/sites/assets/locale/taro' +import Header from '@/sites/components/header' +import Demo1 from './demos/taro/demo1' +import Demo2 from './demos/taro/demo2' +import Demo3 from './demos/taro/demo3' +import Demo4 from './demos/taro/demo4' +import Demo5 from './demos/taro/demo5' +import Demo6 from './demos/taro/demo6' + +const TabsDemo = () => { + const [translated] = useTranslate({ + 'zh-CN': { + basic: '基础用法', + disabled: '禁用选项', + matchByValue: '根据value匹配', + multiTitle: '多个标题', + setDuration: '设置滚动动画时长', + padding: '内容区域留白边距', + }, + 'en-US': { + basic: 'Basic Usage', + disabled: 'Disabled', + matchByValue: 'Match By Value', + multiTitle: 'Multiple Titles', + setDuration: 'Set Scroll Animation Duration', + padding: 'Set Content Padding', + }, + }) + + return ( + <> +
+ + {translated.basic} + + {translated.disabled} + + {translated.matchByValue} + + {translated.multiTitle} + + {translated.setDuration} + + {translated.padding} + + + + ) +} + +export default TabsDemo diff --git a/src/packages/sidebar/demo.tsx b/src/packages/sidebar/demo.tsx new file mode 100644 index 000000000..1c9d9d52a --- /dev/null +++ b/src/packages/sidebar/demo.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { useTranslate } from '@/sites/assets/locale' +import Demo1 from './demos/h5/demo1' +import Demo2 from './demos/h5/demo2' +import Demo3 from './demos/h5/demo3' +import Demo4 from './demos/h5/demo4' +import Demo5 from './demos/h5/demo5' +import Demo6 from './demos/h5/demo6' + +const SideNavBarDemo = () => { + const [translated] = useTranslate({ + 'zh-CN': { + basic: '基础用法', + disabled: '禁用选项', + matchByValue: '根据value匹配', + multiTitle: '多个标题', + setDuration: '设置滚动动画时长', + padding: '内容区域留白边距', + }, + 'en-US': { + basic: 'Basic Usage', + disabled: 'Disabled', + matchByValue: 'Match By Value', + multiTitle: 'Multiple Titles', + setDuration: 'Set Scroll Animation Duration', + padding: 'Set Content Padding', + }, + }) + + return ( + <> +
+

{translated.basic}

+ +

{translated.disabled}

+ +

{translated.matchByValue}

+ +

{translated.multiTitle}

+ +

{translated.setDuration}

+ +

{translated.padding}

+ +
+ + ) +} + +export default SideNavBarDemo diff --git a/src/packages/sidebar/demos/h5/demo1.tsx b/src/packages/sidebar/demos/h5/demo1.tsx new file mode 100644 index 000000000..8eede1cef --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo1.tsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react' + +const Demo1 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(3).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo1 diff --git a/src/packages/sidebar/demos/h5/demo2.tsx b/src/packages/sidebar/demos/h5/demo2.tsx new file mode 100644 index 000000000..faef94081 --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo2.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react' + +const Demo2 = () => { + const [value, setValue] = useState('0') + return ( + <> + { + setValue(value) + }} + > + Content 1 + Content 2 + + + + ) +} +export default Demo2 diff --git a/src/packages/sidebar/demos/h5/demo3.tsx b/src/packages/sidebar/demos/h5/demo3.tsx new file mode 100644 index 000000000..1fe093760 --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo3.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react' + +const Demo3 = () => { + const [value, setValue] = useState('b') + return ( + <> + { + setValue(value) + }} + > + + Content 1 + + + Content 2 + + + Content 3 + + + + ) +} +export default Demo3 diff --git a/src/packages/sidebar/demos/h5/demo4.tsx b/src/packages/sidebar/demos/h5/demo4.tsx new file mode 100644 index 000000000..1753d010d --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo4.tsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react' + +const Demo4 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(20).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo4 diff --git a/src/packages/sidebar/demos/h5/demo5.tsx b/src/packages/sidebar/demos/h5/demo5.tsx new file mode 100644 index 000000000..86fb9502b --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo5.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react' + +const Demo5 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(20).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo5 diff --git a/src/packages/sidebar/demos/h5/demo6.tsx b/src/packages/sidebar/demos/h5/demo6.tsx new file mode 100644 index 000000000..3ce3a8b27 --- /dev/null +++ b/src/packages/sidebar/demos/h5/demo6.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react' +import { ConfigProvider, SideBar } from '@nutui/nutui-react' + +const Demo6 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(3).keys()) + return ( + + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo6 diff --git a/src/packages/sidebar/demos/taro/demo1.tsx b/src/packages/sidebar/demos/taro/demo1.tsx new file mode 100644 index 000000000..50a63962c --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo1.tsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react-taro' + +const Demo1 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(3).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo1 diff --git a/src/packages/sidebar/demos/taro/demo2.tsx b/src/packages/sidebar/demos/taro/demo2.tsx new file mode 100644 index 000000000..a741d9eb1 --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo2.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react-taro' + +const Demo2 = () => { + const [value, setValue] = useState('0') + return ( + <> + { + setValue(value) + }} + > + Content 1 + Content 2 + + + + ) +} +export default Demo2 diff --git a/src/packages/sidebar/demos/taro/demo3.tsx b/src/packages/sidebar/demos/taro/demo3.tsx new file mode 100644 index 000000000..6dab28f3e --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo3.tsx @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react-taro' + +const Demo3 = () => { + const [value, setValue] = useState('b') + return ( + <> + { + setValue(value) + }} + > + + Content 1 + + + Content 2 + + + Content 3 + + + + ) +} +export default Demo3 diff --git a/src/packages/sidebar/demos/taro/demo4.tsx b/src/packages/sidebar/demos/taro/demo4.tsx new file mode 100644 index 000000000..f08ea9ef3 --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo4.tsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react-taro' + +const Demo4 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(20).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo4 diff --git a/src/packages/sidebar/demos/taro/demo5.tsx b/src/packages/sidebar/demos/taro/demo5.tsx new file mode 100644 index 000000000..515543da1 --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo5.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react' +import { SideBar } from '@nutui/nutui-react-taro' + +const Demo5 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(20).keys()) + return ( + <> + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo5 diff --git a/src/packages/sidebar/demos/taro/demo6.tsx b/src/packages/sidebar/demos/taro/demo6.tsx new file mode 100644 index 000000000..1062c6ca7 --- /dev/null +++ b/src/packages/sidebar/demos/taro/demo6.tsx @@ -0,0 +1,27 @@ +import React, { useState } from 'react' +import { ConfigProvider, SideBar } from '@nutui/nutui-react-taro' + +const Demo6 = () => { + const [value, setValue] = useState('0') + const list = Array.from(new Array(3).keys()) + return ( + + { + setValue(value) + }} + > + {list.map((item) => ( + + Content {item + 1} + + ))} + + + ) +} +export default Demo6 diff --git a/src/packages/sidebar/doc.en-US.md b/src/packages/sidebar/doc.en-US.md new file mode 100644 index 000000000..a3fc90f20 --- /dev/null +++ b/src/packages/sidebar/doc.en-US.md @@ -0,0 +1,94 @@ +# SideBar component + +Used for side content selection and switching + +## Introduction + +```tsx +import { SideBar } from '@nutui/nutui-react' +``` + +## Sample code + +### Basic usage + +:::demo + + + +::: + +### Disable option + +:::demo + + + +::: + +### Match based on value + +:::demo + + + +::: + +### Multiple titles + +:::demo + + + +::: + +### Set the scroll animation duration + +:::demo + + + +::: + +## SideBar + +### Props + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| value | The key of the currently activated `item` | `string \| number` | `-` | +| defaultValue | When value is not set, the default value of the key of `item` | `string \| number` | `-` | +| contentDuration | content scroll animation duration | `number` | `0` | +| sidebarDuration | Sidebar scroll animation duration | `number` | `0` | +| onClick | Triggered when the label is clicked | `(index: string \| number) => void` | `-` | +| onChange | Triggered when the currently active label changes | `(index: string \| number) => void` | `-` | + +## SideBar.Item + +### Props + +| Property | Description | Type | Default | +| --- | --- | --- | --- | +| title | title | `string` | `-` | +| value | tag Key, matched identifier, defaults to index value | `string` \| `number` | `-` | +| disabled | Whether to disable the label | `boolean` | `false` | + +## Theming + +### CSS Variables + +The component provides the following CSS variables, which can be used to customize styles. Please refer to [ConfigProvider component](#/en-US/component/configprovider). + +| Name | Description | Default | +| --- | --- | --- | +| \--nutui-sidebar-background-color | Sidebar navigation background color | `$color-background` | +| \--nutui-sidebar-border-radius | Rounded corners of the sidebar | `0` | +| \--nutui-sidebar-width | Sidebar width | `104px` | +| \--nutui-sidebar-max-width | Sidebar max width | `128px` | +| \--nutui-sidebar-title-height | Sidebar title height | `52px` | +| \--nutui-sidebar-inactive-font-size | Font size in normal state | `$font-size-base` | +| \--nutui-sidebar-active-font-size | Font size in active state | `$font-size-l` | +| \--nutui-sidebar-active-font-weight | Font weight in active state | `$font-weight-bold` | +| \--nutui-sidebar-active-color | Font color in active state | `$color-primary` | +| \--nutui-sidebar-item-background | The background color of the content area | `$white` | +| \--nutui-sidebar-item-padding | Padding of the content area | `24px 20px` | diff --git a/src/packages/sidebar/doc.md b/src/packages/sidebar/doc.md new file mode 100644 index 000000000..6e720a5e8 --- /dev/null +++ b/src/packages/sidebar/doc.md @@ -0,0 +1,94 @@ +# SideBar组件 + +用于侧边内容选择和切换 + +## 引入 + +```tsx +import { SideBar } from '@nutui/nutui-react' +``` + +## 示例代码 + +### 基础用法 + +:::demo + + + +::: + +### 禁用选项 + +:::demo + + + +::: + +### 根据value匹配 + +:::demo + + + +::: + +### 多个标题 + +:::demo + + + +::: + +### 设置滚动动画时长 + +:::demo + + + +::: + +## SideBar + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| value | 当前激活的`item`的key | `string \| number` | `-` | +| defaultValue | 未设置value时,`item`的key的默认值 | `string \| number` | `-` | +| contentDuration | 内容滚动动画时长 | `number` | `0` | +| sidebarDuration | 侧栏滚动动画时长 | `number` | `0` | +| onClick | 点击标签时触发 | `(index: string \| number) => void` | `-` | +| onChange | 当前激活的标签改变时触发 | `(index: string \| number) => void` | `-` | + +## SideBar.Item + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| title | 标题 | `string` | `-` | +| value | 标签 Key , 匹配的标识符, 默认为索引值 | `string` \| `number` | `-` | +| disabled | 是否禁用标签 | `boolean` | `false` | + +## 主题定制 + +### 样式变量 + +组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。 + +| 名称 | 说明 | 默认值 | +| --- | --- | --- | +| \--nutui-sidebar-background-color | 侧边栏导航背景色 | `$color-background` | +| \--nutui-sidebar-border-radius | 侧边栏的圆角 | `0` | +| \--nutui-sidebar-width | 侧边栏宽度 | `104px` | +| \--nutui-sidebar-max-width | 侧边栏最大宽度 | `128px` | +| \--nutui-sidebar-title-height | 侧边栏标题高度 | `52px` | +| \--nutui-sidebar-inactive-font-size | 普通状态下的字体大小 | `$font-size-base` | +| \--nutui-sidebar-active-font-size | 激活状态下的字体大小 | `$font-size-l` | +| \--nutui-sidebar-active-font-weight | 激活状态下的字重 | `$font-weight-bold` | +| \--nutui-sidebar-active-color | 激活状态下的字体颜色 | `$color-primary` | +| \--nutui-sidebar-item-background | 内容区域的背景色 | `$white` | +| \--nutui-sidebar-item-padding | 内容区域的内边距 | `24px 20px` | diff --git a/src/packages/sidebar/doc.taro.md b/src/packages/sidebar/doc.taro.md new file mode 100644 index 000000000..b51fbdf7b --- /dev/null +++ b/src/packages/sidebar/doc.taro.md @@ -0,0 +1,94 @@ +# SideBar组件 + +用于侧边内容选择和切换 + +## 引入 + +```tsx +import { SideBar } from '@nutui/nutui-react-taro' +``` + +## 示例代码 + +### 基础用法 + +:::demo + + + +::: + +### 禁用选项 + +:::demo + + + +::: + +### 根据value匹配 + +:::demo + + + +::: + +### 多个标题 + +:::demo + + + +::: + +### 设置滚动动画时长 + +:::demo + + + +::: + +## SideBar + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| value | 当前激活的`item`的key | `string \| number` | `-` | +| defaultValue | 未设置value时,`item`的key的默认值 | `string \| number` | `-` | +| contentDuration | 内容滚动动画时长 | `number` | `0` | +| sidebarDuration | 侧栏滚动动画时长 | `number` | `0` | +| onClick | 点击标签时触发 | `(index: string \| number) => void` | `-` | +| onChange | 当前激活的标签改变时触发 | `(index: string \| number) => void` | `-` | + +## SideBar.Item + +### Props + +| 属性 | 说明 | 类型 | 默认值 | +| --- | --- | --- | --- | +| title | 标题 | `string` | `-` | +| value | 标签 Key , 匹配的标识符, 默认为索引值 | `string` \| `number` | `-` | +| disabled | 是否禁用标签 | `boolean` | `false` | + +## 主题定制 + +### 样式变量 + +组件提供了下列 CSS 变量,可用于自定义样式,使用方法请参考 [ConfigProvider 组件](#/zh-CN/component/configprovider)。 + +| 名称 | 说明 | 默认值 | +| --- | --- | --- | +| \--nutui-sidebar-background-color | 侧边栏导航背景色 | `$color-background` | +| \--nutui-sidebar-border-radius | 侧边栏的圆角 | `0` | +| \--nutui-sidebar-width | 侧边栏宽度 | `104px` | +| \--nutui-sidebar-max-width | 侧边栏最大宽度 | `128` | +| \--nutui-sidebar-title-height | 侧边栏标题高度 | `52px` | +| \--nutui-sidebar-inactive-font-size | 普通状态下的字体大小 | `$font-size-base` | +| \--nutui-sidebar-active-font-size | 激活状态下的字体大小 | `$font-size-l` | +| \--nutui-sidebar-active-font-weight | 激活状态下的字重 | `$font-weight-bold` | +| \--nutui-sidebar-active-color | 激活状态下的字体颜色 | `$color-primary` | +| \--nutui-sidebar-item-background | 内容区域的背景色 | `$white` | +| \--nutui-sidebar-item-padding | 内容区域的内边距 | `24px 20px` | diff --git a/src/packages/sidebar/doc.zh-TW.md b/src/packages/sidebar/doc.zh-TW.md new file mode 100644 index 000000000..52cb9ed79 --- /dev/null +++ b/src/packages/sidebar/doc.zh-TW.md @@ -0,0 +1,94 @@ +# SideBar組件 + +用於側邊內容選擇和切換 + +## 引入 + +```tsx +import { SideBar } from '@nutui/nutui-react' +``` + +## 示例代碼 + +### 基礎用法 + +:::demo + + + +::: + +### 禁用選項 + +:::demo + + + +::: + +### 根據value匹配 + +:::demo + + + +::: + +### 多個標題 + +:::demo + + + +::: + +### 設置滾動動畫時長 + +:::demo + + + +::: + +## SideBar + +### Props + +| 屬性 | 說明 | 類型 | 默認值 | +| --- | --- | --- | --- | +| value | 當前激活的`item`的key | `string \| number` | `-` | +| defaultValue | 未設置value時,`item`的key的默認值 | `string \| number` | `-` | +| contentDuration | 內容滾動動畫時長 | `number` | `0` | +| sidebarDuration | 側欄滾動動畫時長 | `number` | `0` | +| onClick | 點擊標簽時觸發 | `(index: string \| number) => void` | `-` | +| onChange | 當前激活的標簽改變時觸發 | `(index: string \| number) => void` | `-` | + +## SideBar.Item + +### Props + +| 屬性 | 說明 | 類型 | 默認值 | +| --- | --- | --- | --- | +| title | 標題 | `string` | `-` | +| value | 標簽 Key , 匹配的標識符, 默認為索引值 | `string` \| `number` | `-` | +| disabled | 是否禁用標簽 | `boolean` | `false` | + +## 主題定製 + +### 樣式變量 + +組件提供了下列 CSS 變量,可用於自定義樣式,使用方法請參考 [ConfigProvider 組件](#/zh-CN/component/configprovider)。 + +| 名稱 | 說明 | 默認值 | +| --- | --- | --- | +| \--nutui-sidebar-background-color | 側邊欄導航背景色 | `$color-background` | +| \--nutui-sidebar-border-radius | 側邊欄的圓角 | `0` | +| \--nutui-sidebar-width | 側邊欄寬度 | `104px` | +| \--nutui-sidebar-max-width | 側邊欄最大寬度 | `128px` | +| \--nutui-sidebar-title-height | 側邊欄標題高度 | `52px` | +| \--nutui-sidebar-inactive-font-size | 普通狀態下的字體大小 | `$font-size-base` | +| \--nutui-sidebar-active-font-size | 激活狀態下的字體大小 | `$font-size-l` | +| \--nutui-sidebar-active-font-weight | 激活狀態下的字重 | `$font-weight-bold` | +| \--nutui-sidebar-active-color | 激活狀態下的字體顏色 | `$color-primary` | +| \--nutui-sidebar-item-background | 內容區域的背景色 | `$white` | +| \--nutui-sidebar-item-padding | 內容區域的內邊距 | `24px 20px` | diff --git a/src/packages/sidebar/index.taro.ts b/src/packages/sidebar/index.taro.ts new file mode 100644 index 000000000..e7d26db3b --- /dev/null +++ b/src/packages/sidebar/index.taro.ts @@ -0,0 +1,5 @@ +import { SideBar } from './sidebar.taro' + +export type { SideBarProps } from './types' + +export default SideBar diff --git a/src/packages/sidebar/index.ts b/src/packages/sidebar/index.ts new file mode 100644 index 000000000..105927bf4 --- /dev/null +++ b/src/packages/sidebar/index.ts @@ -0,0 +1,4 @@ +import { SideBar } from './sidebar' + +export type { SideBarProps } from './types' +export default SideBar diff --git a/src/packages/sidebar/sidebar.scss b/src/packages/sidebar/sidebar.scss new file mode 100644 index 000000000..257bc57e7 --- /dev/null +++ b/src/packages/sidebar/sidebar.scss @@ -0,0 +1,62 @@ +@import '../../styles/mixins/index'; +@import '../sidebaritem/sidebaritem.scss'; + +.nut-sidebar { + display: flex; + &-content { + flex-direction: column; + height: 100%; + &-wrap { + flex: 1; + overflow: hidden; + } + } + &-titles { + background: $sidebar-background-color; + flex-direction: column; + border-radius: $sidebar-border-radius; + height: 100%; + width: $sidebar-width; + max-width: $sidebar-max-width; + flex-shrink: 0; + &::-webkit-scrollbar { + display: none; + width: 0; + background: transparent; + } + .nut-sidebar-list { + width: 100%; + display: flex; + flex-direction: column; + flex-shrink: 0; + } + &-scrollable { + overflow-x: hidden; + overflow-y: auto; + } + &-item { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + height: $sidebar-title-height; + font-size: $sidebar-inactive-font-size; + color: $color-text; + &-text { + text-align: center; + white-space: normal; + width: $sidebar-width; + } + &-active .nut-sidebar-titles-item-text { + font-family: PingFangSC-Semibold; + color: $sidebar-active-color; + font-weight: $sidebar-active-font-weight; + font-size: $sidebar-active-font-size; + } + &-disabled { + color: $color-text-disabled; + cursor: not-allowed; + } + } + } +} diff --git a/src/packages/sidebar/sidebar.taro.tsx b/src/packages/sidebar/sidebar.taro.tsx new file mode 100644 index 000000000..42d8020fd --- /dev/null +++ b/src/packages/sidebar/sidebar.taro.tsx @@ -0,0 +1,236 @@ +import React, { FC, useEffect, useRef, useState } from 'react' +import { ScrollView, View } from '@tarojs/components' +import classNames from 'classnames' +import Taro, { nextTick, createSelectorQuery } from '@tarojs/taro' +import { ComponentDefaults } from '@/utils/typings' +import { usePropsValue } from '@/utils/use-props-value' +import { useForceUpdate } from '@/utils/use-force-update' +import raf from '@/utils/raf' +import useUuid from '@/utils/use-uuid' +import { SideBarItemProps, SideBarProps } from './types' +import SideBarItem from '@/packages/sidebaritem/index.taro' +import { mergeProps } from '@/utils/merge-props' + +const defaultProps = { + ...ComponentDefaults, + contentDuration: 0, + sidebarDuration: 0, +} as SideBarProps + +const classPrefix = 'nut-sidebar' +export const SideBar: FC> & { + Item: typeof SideBarItem +} = (props) => { + const { + contentDuration, + sidebarDuration, + children, + onClick, + onChange, + className, + ...rest + } = mergeProps(defaultProps, props) + const uuid = useUuid() + const [value, setValue] = usePropsValue({ + value: props.value, + defaultValue: props.defaultValue, + finalValue: 0, + onChange, + }) + + const titleItemsRef = useRef([]) + const navRef = useRef(null) + + const getTitles = () => { + const titles: SideBarItemProps[] = [] + React.Children.forEach(children, (child: any, idx) => { + if (React.isValidElement(child)) { + const props: any = child?.props + if (props?.title || props?.value) { + titles.push({ + title: props.title, + value: props.value || idx, + disabled: props.disabled, + }) + } + } + }) + return titles + } + + const titles = useRef(getTitles()) + const forceUpdate = useForceUpdate() + useEffect(() => { + titles.current = getTitles() + let current: string | number = '' + titles.current.forEach((title) => { + if (title.value === value) { + current = value + } + }) + forceUpdate() + }, [children]) + + const classes = classNames(classPrefix, className) + const classesTitle = classNames( + `${classPrefix}-titles`, + `${classPrefix}-titles-scrollable` + ) + const getRect = (selector: string) => { + return new Promise((resolve) => { + createSelectorQuery() + .select(selector) + .boundingClientRect() + .exec((rect = []) => { + resolve(rect[0]) + }) + }) + } + const getAllRect = (selector: string) => { + return new Promise((resolve) => { + createSelectorQuery() + .selectAll(selector) + .boundingClientRect() + .exec((rect = []) => { + resolve(rect[0]) + }) + }) + } + type RectItem = { + bottom: number + dataset: { sid: string } + height: number + id: string + left: number + right: number + top: number + width: number + } + const scrollWithAnimation = useRef(false) + const navRectRef = useRef() + const titleRectRef = useRef([]) + const [scrollTop, setScrollTop] = useState(0) + const scrollDirection = (to: number) => { + let count = 0 + const frames = sidebarDuration === 0 ? 1 : Math.round(sidebarDuration / 16) + function animate() { + setScrollTop(to) + if (++count < frames) { + raf(animate) + } + } + animate() + } + const scrollIntoView = (index: number) => { + raf(() => { + Promise.all([ + getRect(`#${classPrefix}-titles-${uuid} .${classPrefix}-list`), + getAllRect( + `#${classPrefix}-titles-${uuid} .${classPrefix}-titles-item` + ), + ]).then(([navRect, titleRects]: any) => { + navRectRef.current = navRect + titleRectRef.current = titleRects + const titleRect: RectItem = titleRectRef.current[index] + if (!titleRect) return + nextTick(() => { + scrollWithAnimation.current = true + }) + scrollDirection(titleRect.height * (index - 1)) + }) + }) + } + + const getContentStyle = () => { + let index = titles.current.findIndex( + (t) => String(t.value) === String(value) + ) + index = index < 0 ? 0 : index + return { + transform: `translate3d( 0,-${index * 100}%, 0)`, + transitionDuration: `${contentDuration}ms`, + } + } + + useEffect(() => { + let index = titles.current.findIndex( + (t) => String(t.value) === String(value) + ) + index = index < 0 ? 0 : index + scrollIntoView(index) + }, [value]) + + const tabChange = (item: SideBarItemProps, index: number) => { + if (item.disabled) return + onClick?.(item.value) + setValue(item.value) + } + + return ( + + + + {titles.current.map((item, index) => { + return ( + titleItemsRef.current.push(ref)} + id={`scrollIntoView${index}`} + onClick={(e) => { + tabChange(item, index) + }} + className={classNames(`${classPrefix}-titles-item`, { + [`${classPrefix}-titles-item-active`]: + !item.disabled && String(item.value) === String(value), + [`${classPrefix}-titles-item-disabled`]: item.disabled, + })} + key={item.value} + > + + {item.title} + + + ) + })} + + + + + {React.Children.map(children, (child, idx) => { + if (!React.isValidElement(child)) { + return null + } + let childProps = { + ...child.props, + active: value === child.props.value, + } + if (String(value) !== String(child.props.value ?? idx)) { + childProps = { + ...childProps, + } + } + return React.cloneElement(child, childProps) + })} + + + + ) +} + +SideBar.displayName = 'NutSideBar' +SideBar.Item = SideBarItem diff --git a/src/packages/sidebar/sidebar.tsx b/src/packages/sidebar/sidebar.tsx new file mode 100644 index 000000000..f6cd035a0 --- /dev/null +++ b/src/packages/sidebar/sidebar.tsx @@ -0,0 +1,173 @@ +import React, { FC, useEffect, useRef } from 'react' +import classNames from 'classnames' +import { ComponentDefaults } from '@/utils/typings' +import SideBarItem from '@/packages/sidebaritem' +import raf from '@/utils/raf' +import { usePropsValue } from '@/utils/use-props-value' +import { useForceUpdate } from '@/utils/use-force-update' +import { mergeProps } from '@/utils/merge-props' + +import { SideBarItemProps, SideBarProps } from './types' + +const defaultProps = { + ...ComponentDefaults, + contentDuration: 0, + sidebarDuration: 0, +} as SideBarProps + +const classPrefix = 'nut-sidebar' +export const SideBar: FC> & { + Item: typeof SideBarItem +} = (props) => { + const { + contentDuration, + sidebarDuration, + children, + onClick, + onChange, + className, + ...rest + } = mergeProps(defaultProps, props) + + const [value, setValue] = usePropsValue({ + value: props.value, + defaultValue: props.defaultValue, + finalValue: 0, + onChange, + }) + const titleItemsRef = useRef([]) + const navRef = useRef(null) + const scroll = (nav: any, to: number) => { + let count = 0 + const from = nav.scrollTop + const frames = sidebarDuration === 0 ? 1 : Math.round(sidebarDuration / 16) + function animate() { + nav.scrollTop += (to - from) / frames + if (++count < frames) { + raf(animate) + } + } + animate() + } + const scrollIntoView = (index: number) => { + const nav = navRef.current + const titleItem = titleItemsRef.current + const titlesLength = titles.current.length + const itemLength = titleItemsRef.current.length + if (!nav || !titleItem || !titleItem[itemLength - titlesLength + index]) { + return + } + const title = titleItem[itemLength - titlesLength + index] + const runTop = title.offsetTop - nav.offsetTop + 10 + const to = + runTop - (nav.offsetHeight - title.offsetHeight) / 2 + title.offsetHeight + scroll(nav, to) + } + + const getTitles = () => { + const titles: SideBarItemProps[] = [] + React.Children.forEach(children, (child: any, idx) => { + if (React.isValidElement(child)) { + const props: any = child?.props + if (props?.title || props?.value) { + titles.push({ + title: props.title, + value: props.value ?? idx, + disabled: props.disabled, + }) + } + } + }) + return titles + } + const titles = useRef(getTitles()) + const forceUpdate = useForceUpdate() + useEffect(() => { + titles.current = getTitles() + let current: string | number = '' + titles.current.forEach((title) => { + if (title.value === value) { + current = value + } + }) + forceUpdate() + }, [children]) + + const classes = classNames(classPrefix, className) + const classesTitle = classNames( + `${classPrefix}-titles`, + `${classPrefix}-titles-scrollable` + ) + + const getContentStyle = () => { + let index = titles.current.findIndex((t) => t.value === value) + index = index < 0 ? 0 : index + return { + transform: `translate3d( 0,-${index * 100}%, 0)`, + transitionDuration: `${contentDuration}ms`, + } + } + useEffect(() => { + let index = titles.current.findIndex((t) => t.value === value) + index = index < 0 ? 0 : index + const rafId = requestAnimationFrame(() => { + scrollIntoView(index) + }) + return () => cancelAnimationFrame(rafId) + }, [value]) + + const tabChange = (item: SideBarItemProps) => { + if (item.disabled) return + onClick?.(item.value) + setValue(item.value) + } + return ( +
+
+ {titles.current.map((item) => { + return ( +
{ + tabChange(item) + }} + className={classNames(`${classPrefix}-titles-item`, { + [`${classPrefix}-titles-item-active`]: + !item.disabled && String(item.value) === String(value), + [`${classPrefix}-titles-item-disabled`]: item.disabled, + })} + ref={(ref: HTMLDivElement) => titleItemsRef.current.push(ref)} + key={item.value} + > +
+ {item.title} +
+
+ ) + })} +
+
+
+ {React.Children.map(children, (child, idx) => { + if (!React.isValidElement(child)) { + return null + } + let childProps = { + ...child.props, + active: value === child.props.value, + } + + if (String(value) !== String(child.props.value || idx)) { + childProps = { + ...childProps, + } + } + return React.cloneElement(child, childProps) + })} +
+
+
+ ) +} + +SideBar.displayName = 'NutSideBar' +SideBar.Item = SideBarItem diff --git a/src/packages/sidebar/types.ts b/src/packages/sidebar/types.ts new file mode 100644 index 000000000..3bf5877ab --- /dev/null +++ b/src/packages/sidebar/types.ts @@ -0,0 +1,18 @@ +import { BasicComponent } from '@/utils/typings' + +export type SideBarItemProps = { + title: string + disabled: boolean + active?: boolean + value: string | number +} + +export interface SideBarProps extends BasicComponent { + value: string | number + defaultValue: string | number + contentDuration: number + sidebarDuration: number + onChange: (index: string | number) => void + onClick: (index: string | number) => void + children?: React.ReactNode +} diff --git a/src/packages/sidebar/utils.ts b/src/packages/sidebar/utils.ts new file mode 100644 index 000000000..5f4d67d36 --- /dev/null +++ b/src/packages/sidebar/utils.ts @@ -0,0 +1,22 @@ +import { MouseEventHandler } from 'react' + +const handleClick: MouseEventHandler = (e) => { + e.stopPropagation() + const isIcon = (e.target as HTMLDivElement).className.includes('arrow-icon') + const isTitle = + (e.target as HTMLDivElement).className.includes('-title') || isIcon + const currentClass = e.currentTarget.className + const isShow = currentClass.includes('sidenavbar-show') + const arrowIcon = e.currentTarget.querySelector('.arrow-icon') as Element + const iconClass = arrowIcon.className + + if (isTitle) { + e.currentTarget.className = isShow + ? currentClass.replace('sidenavbar-show', 'sidenavbar-hide') + : currentClass.replace('sidenavbar-hide', 'sidenavbar-show') + arrowIcon.className = isShow + ? iconClass.replace('arrow-down', 'arrow-up') + : iconClass.replace('arrow-up', 'arrow-down') + } +} +export { handleClick } diff --git a/src/packages/sidebaritem/index.taro.ts b/src/packages/sidebaritem/index.taro.ts new file mode 100644 index 000000000..26732133e --- /dev/null +++ b/src/packages/sidebaritem/index.taro.ts @@ -0,0 +1,4 @@ +import { SideBarItem } from './sidebaritem.taro' + +export type { SideBarItemProps } from './sidebaritem.taro' +export default SideBarItem diff --git a/src/packages/sidebaritem/index.ts b/src/packages/sidebaritem/index.ts new file mode 100644 index 000000000..dd3073a4a --- /dev/null +++ b/src/packages/sidebaritem/index.ts @@ -0,0 +1,4 @@ +import { SideBarItem } from './sidebaritem' + +export type { SideBarItemProps } from './sidebaritem' +export default SideBarItem diff --git a/src/packages/sidebaritem/sidebaritem.scss b/src/packages/sidebaritem/sidebaritem.scss new file mode 100644 index 000000000..3dc1602ae --- /dev/null +++ b/src/packages/sidebaritem/sidebaritem.scss @@ -0,0 +1,16 @@ +.nut-sidebaritem { + width: 100%; + height: 100%; + flex-shrink: 0; + display: block; + background-color: $sidebar-item-background; + color: $color-title; + padding: $sidebar-item-padding; + box-sizing: border-box; + overflow: auto; + + &.inactive { + overflow: visible; + height: 0; + } +} diff --git a/src/packages/sidebaritem/sidebaritem.taro.tsx b/src/packages/sidebaritem/sidebaritem.taro.tsx new file mode 100644 index 000000000..8a1566ff8 --- /dev/null +++ b/src/packages/sidebaritem/sidebaritem.taro.tsx @@ -0,0 +1,37 @@ +import React, { FunctionComponent } from 'react' +import classNames from 'classnames' +import { View } from '@tarojs/components' +import { mergeProps } from '@/utils/merge-props' + +export interface SideBarItemProps { + title: string | number + value: string | number + disabled: boolean + className: string + children?: React.ReactNode +} + +const defaultProps = { + title: '', + value: '', + disabled: false, +} as SideBarItemProps + +export const SideBarItem: FunctionComponent> = ( + props +) => { + const { children, className, disabled } = mergeProps(defaultProps, props) + + const classPrefix = 'nut-sidebaritem' + const classes = classNames( + classPrefix, + { + active: !disabled && (props as any).active, + }, + className + ) + + return children ? ( + {!disabled && children} + ) : null +} diff --git a/src/packages/sidebaritem/sidebaritem.tsx b/src/packages/sidebaritem/sidebaritem.tsx new file mode 100644 index 000000000..172b7cd60 --- /dev/null +++ b/src/packages/sidebaritem/sidebaritem.tsx @@ -0,0 +1,36 @@ +import React, { FunctionComponent } from 'react' +import classNames from 'classnames' +import { mergeProps } from '@/utils/merge-props' + +export interface SideBarItemProps { + title: string | number + value: string | number + disabled: boolean + className: string + children?: React.ReactNode +} + +const defaultProps = { + title: '', + value: '', + disabled: false, +} as SideBarItemProps + +export const SideBarItem: FunctionComponent> = ( + props +) => { + const { children, className, disabled } = mergeProps(defaultProps, props) + + const classPrefix = 'nut-sidebaritem' + const classes = classNames( + classPrefix, + { + active: !disabled && (props as any).active, + }, + className + ) + + return children ? ( +
{!disabled && children}
+ ) : null +} diff --git a/src/styles/variables.scss b/src/styles/variables.scss index a1ff1f1bc..eeb37f857 100644 --- a/src/styles/variables.scss +++ b/src/styles/variables.scss @@ -2167,7 +2167,33 @@ $navbar-title-font-color: var( --nutui-navbar-title-font-color, $color-title ) !default; - +// sidebar(✅) +$sidebar-background-color: var( + --nutui-sidebar-background-color, + $color-background +) !default; +$sidebar-border-radius: var(--nutui-sidebar-border-radius, 0) !default; +$sidebar-width: var(--nutui-sidebar-width, 104px) !default; +$sidebar-max-width: var(--nutui-sidebar-max-width, 128px) !default; +$sidebar-title-height: var(--nutui-sidebar-title-height, 52px) !default; +$sidebar-inactive-font-size: var( + --nutui-sidebar-inactive-font-size, + $font-size-base +) !default; +$sidebar-active-font-size: var( + --nutui-sidebar-active-font-size, + $font-size-l +) !default; +$sidebar-active-font-weight: var( + --nutui-sidebar-active-font-weight, + $font-weight-bold +) !default; +$sidebar-active-color: var( + --nutui-sidebar-active-color, + $color-primary +) !default; +$sidebar-item-background: var(--nutui-sidebar-item-background, $white) !default; +$sidebar-item-padding: var(--nutui-sidebar-item-padding, 24px 20px) !default; // sidenavbar(✅) $sidenavbar-content-bg-color: var( --nutui-sidenavbar-content-bg-color,