mirror of
https://github.com/element-plus/element-plus.git
synced 2024-11-21 01:02:59 +08:00
feat(components): [segmented] new component (#16258)
* feat(components): [segmented] new component * feat(components): [segmented] * feat(components): update * feat(components): update * feat(theme-chalk): update * feat(components): update * feat: update * feat: update * feat(components): add focus-visible * feat(theme-chalk): update * feat(components): fix test * docs: docs * feat(components): update * feat: add icon
This commit is contained in:
parent
d0eb6c3d1a
commit
546b21ea82
@ -246,6 +246,11 @@
|
||||
"link": "/statistic",
|
||||
"text": "Statistic",
|
||||
"promotion": "2.2.30"
|
||||
},
|
||||
{
|
||||
"link": "/segmented",
|
||||
"text": "Segmented",
|
||||
"promotion": "2.7.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -49,6 +49,7 @@ import OvTour from './ov-tour.vue'
|
||||
import OvTree from './ov-tree.vue'
|
||||
import OvTreeSelect from './ov-tree-select.vue'
|
||||
import OvStatistic from './ov-statistic.vue'
|
||||
import OvSegmented from './ov-segmented.vue'
|
||||
import OvAffix from './ov-affix.vue'
|
||||
import OvAnchor from './ov-anchor.vue'
|
||||
import OvBacktop from './ov-backtop.vue'
|
||||
@ -126,6 +127,7 @@ export default {
|
||||
'tree-select': OvTreeSelect,
|
||||
'tree-v2': OvTree,
|
||||
statistic: OvStatistic,
|
||||
segmented: OvSegmented,
|
||||
affix: OvAffix,
|
||||
anchor: OvAnchor,
|
||||
backtop: OvBacktop,
|
||||
|
@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<svg
|
||||
width="280"
|
||||
height="180"
|
||||
viewBox="0 0 280 180"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect width="280" height="180" fill="var(--el-fill-color-light)" />
|
||||
<g filter="url(#filter0_d_15674_94)">
|
||||
<rect
|
||||
x="56"
|
||||
y="74"
|
||||
width="168"
|
||||
height="28"
|
||||
rx="4"
|
||||
fill="var(--el-fill-color-blank)"
|
||||
/>
|
||||
<rect
|
||||
x="55.7"
|
||||
y="73.7"
|
||||
width="168.6"
|
||||
height="28.6"
|
||||
rx="4.3"
|
||||
stroke="var(--el-border-color-dark)"
|
||||
stroke-width="0.6"
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x="124"
|
||||
y="84"
|
||||
width="32"
|
||||
height="8"
|
||||
rx="4"
|
||||
fill="var(--el-color-primary)"
|
||||
/>
|
||||
<rect
|
||||
x="80"
|
||||
y="84"
|
||||
width="32"
|
||||
height="8"
|
||||
rx="4"
|
||||
fill="var(--el-border-color-dark)"
|
||||
/>
|
||||
<rect
|
||||
x="168"
|
||||
y="84"
|
||||
width="32"
|
||||
height="8"
|
||||
rx="4"
|
||||
fill="var(--el-border-color-dark)"
|
||||
/>
|
||||
<defs>
|
||||
<filter
|
||||
id="filter0_d_15674_94"
|
||||
x="42.0666"
|
||||
y="60.0671"
|
||||
width="195.867"
|
||||
height="55.8659"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="sRGB"
|
||||
>
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix" />
|
||||
<feColorMatrix
|
||||
in="SourceAlpha"
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
|
||||
result="hardAlpha"
|
||||
/>
|
||||
<feOffset />
|
||||
<feGaussianBlur stdDeviation="6.66667" />
|
||||
<feComposite in2="hardAlpha" operator="out" />
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in2="BackgroundImageFix"
|
||||
result="effect1_dropShadow_15674_94"
|
||||
/>
|
||||
<feBlend
|
||||
mode="normal"
|
||||
in="SourceGraphic"
|
||||
in2="effect1_dropShadow_15674_94"
|
||||
result="shape"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
1
docs/components.d.ts
vendored
1
docs/components.d.ts
vendored
@ -92,6 +92,7 @@ declare module '@vue/runtime-core' {
|
||||
OvRate: typeof import('./.vitepress/vitepress/components/overview-icons/ov-rate.vue')['default']
|
||||
OvResult: typeof import('./.vitepress/vitepress/components/overview-icons/ov-result.vue')['default']
|
||||
OvScrollbar: typeof import('./.vitepress/vitepress/components/overview-icons/ov-scrollbar.vue')['default']
|
||||
OvSegmented: typeof import('./.vitepress/vitepress/components/overview-icons/ov-segmented.vue')['default']
|
||||
OvSelect: typeof import('./.vitepress/vitepress/components/overview-icons/ov-select.vue')['default']
|
||||
OvSelectV2: typeof import('./.vitepress/vitepress/components/overview-icons/ov-select-v2.vue')['default']
|
||||
OvSkeleton: typeof import('./.vitepress/vitepress/components/overview-icons/ov-skeleton.vue')['default']
|
||||
|
102
docs/en-US/component/segmented.md
Normal file
102
docs/en-US/component/segmented.md
Normal file
@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Segmented
|
||||
lang: en-US
|
||||
---
|
||||
|
||||
# Segmented
|
||||
|
||||
Display multiple options and allow users to select a single option.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
Set `v-model` to the option value is selected.
|
||||
|
||||
:::demo
|
||||
|
||||
segmented/basic
|
||||
|
||||
:::
|
||||
|
||||
## Disabled
|
||||
|
||||
Set `disabled` of segmented or option to `true` to disable it.
|
||||
|
||||
:::demo
|
||||
|
||||
segmented/disabled
|
||||
|
||||
:::
|
||||
|
||||
## Block
|
||||
|
||||
Set `block` to `true` to fit the width of parent element.
|
||||
|
||||
:::demo
|
||||
|
||||
segmented/block
|
||||
|
||||
:::
|
||||
|
||||
## Custom Content
|
||||
|
||||
Set default slot to render custom content.
|
||||
|
||||
:::demo
|
||||
|
||||
segmented/custom-content
|
||||
|
||||
:::
|
||||
|
||||
## Custom Style
|
||||
|
||||
Set default slot to render custom content.
|
||||
|
||||
:::demo
|
||||
|
||||
segmented/custom-style
|
||||
|
||||
:::
|
||||
|
||||
## API
|
||||
|
||||
### Attributes
|
||||
|
||||
| Name | Description | Type | Default |
|
||||
|-----------------------|------------------------------------|------------------------------------------------|---------|
|
||||
| model-value / v-model | binding value | ^[string] / ^[number] | — |
|
||||
| options | data of the options | ^[array]`Option[]` | [] |
|
||||
| size | size of component | ^[enum]`'' \| 'large' \| 'default' \| 'small'` | '' |
|
||||
| block | fit width of parent content | ^[boolean] | — |
|
||||
| disabled | whether segmented is disabled | ^[boolean] | false |
|
||||
| validate-event | whether to trigger form validation | ^[boolean] | true |
|
||||
| name | native `name` attribute | ^[string] | — |
|
||||
| id | native `id` attribute | ^[string] | — |
|
||||
| aria-label ^(a11y) | native `aria-label` attribute | ^[string] | — |
|
||||
|
||||
### Events
|
||||
|
||||
| Name | Description | Type |
|
||||
|--------|-------------------------------------------------------------------------------|---------------------------------|
|
||||
| change | triggers when the selected value changes, the param is current selected value | ^[Function]`(val: any) => void` |
|
||||
|
||||
### Slots
|
||||
|
||||
| Name | Description |
|
||||
|---------|-----------------|
|
||||
| default | option renderer |
|
||||
|
||||
## Type Declarations
|
||||
|
||||
<details>
|
||||
<summary>Show declarations</summary>
|
||||
|
||||
```ts
|
||||
type Option = {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
} | string | number | boolean | undefined
|
||||
```
|
||||
|
||||
</details>
|
@ -54,6 +54,9 @@
|
||||
<el-form-item label="Instant delivery" prop="delivery">
|
||||
<el-switch v-model="ruleForm.delivery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity location" prop="location">
|
||||
<el-segmented v-model="ruleForm.location" :options="locationOptions" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Activity type" prop="type">
|
||||
<el-checkbox-group v-model="ruleForm.type">
|
||||
<el-checkbox value="Online activities" name="type">
|
||||
@ -99,6 +102,7 @@ interface RuleForm {
|
||||
date1: string
|
||||
date2: string
|
||||
delivery: boolean
|
||||
location: string
|
||||
type: string[]
|
||||
resource: string
|
||||
desc: string
|
||||
@ -113,11 +117,14 @@ const ruleForm = reactive<RuleForm>({
|
||||
date1: '',
|
||||
date2: '',
|
||||
delivery: false,
|
||||
location: '',
|
||||
type: [],
|
||||
resource: '',
|
||||
desc: '',
|
||||
})
|
||||
|
||||
const locationOptions = ['Home', 'Company', 'School']
|
||||
|
||||
const rules = reactive<FormRules<RuleForm>>({
|
||||
name: [
|
||||
{ required: true, message: 'Please input Activity name', trigger: 'blur' },
|
||||
@ -153,6 +160,13 @@ const rules = reactive<FormRules<RuleForm>>({
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
location: [
|
||||
{
|
||||
required: true,
|
||||
message: 'Please select a location',
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
type: [
|
||||
{
|
||||
type: 'array',
|
||||
|
14
docs/examples/segmented/basic.vue
Normal file
14
docs/examples/segmented/basic.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<el-segmented v-model="value" :options="options" size="large" />
|
||||
<el-segmented v-model="value" :options="options" size="default" />
|
||||
<el-segmented v-model="value" :options="options" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const value = ref('Mon')
|
||||
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
</script>
|
20
docs/examples/segmented/block.vue
Normal file
20
docs/examples/segmented/block.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-segmented v-model="value" :options="options" block />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const value = ref('Mon')
|
||||
|
||||
const options = [
|
||||
'Mon',
|
||||
'Tue',
|
||||
'Wed',
|
||||
'Thu',
|
||||
'Fri',
|
||||
'Sat',
|
||||
'Sunday long long long long long long long',
|
||||
]
|
||||
</script>
|
60
docs/examples/segmented/custom-content.vue
Normal file
60
docs/examples/segmented/custom-content.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<el-segmented v-model="value" :options="options">
|
||||
<template #default="{ item }">
|
||||
<div class="flex flex-col items-center gap-2 p-2">
|
||||
<el-icon size="20">
|
||||
<component :is="item.icon" />
|
||||
</el-icon>
|
||||
<div>{{ item.label }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-segmented>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
Apple,
|
||||
Cherry,
|
||||
Grape,
|
||||
Orange,
|
||||
Pear,
|
||||
Watermelon,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const value = ref('Apple')
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: 'Apple',
|
||||
value: 'Apple',
|
||||
icon: Apple,
|
||||
},
|
||||
{
|
||||
label: 'Cherry',
|
||||
value: 'Cherry',
|
||||
icon: Cherry,
|
||||
},
|
||||
{
|
||||
label: 'Grape',
|
||||
value: 'Grape',
|
||||
icon: Grape,
|
||||
},
|
||||
{
|
||||
label: 'Orange',
|
||||
value: 'Orange',
|
||||
icon: Orange,
|
||||
},
|
||||
{
|
||||
label: 'Pear',
|
||||
value: 'Pear',
|
||||
icon: Pear,
|
||||
},
|
||||
{
|
||||
label: 'Watermelon',
|
||||
value: 'Watermelon',
|
||||
icon: Watermelon,
|
||||
},
|
||||
]
|
||||
</script>
|
20
docs/examples/segmented/custom-style.vue
Normal file
20
docs/examples/segmented/custom-style.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="custom-style">
|
||||
<el-segmented v-model="value" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const value = ref('Delicacy')
|
||||
|
||||
const options = ['Delicacy', 'Desserts&Drinks', 'Fresh foods', 'Supermarket']
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-style .el-segmented {
|
||||
--el-segmented-item-selected-color: var(--el-text-color-primary);
|
||||
--el-segmented-item-selected-bg-color: #ffd100;
|
||||
--el-border-radius-base: 16px;
|
||||
}
|
||||
</style>
|
45
docs/examples/segmented/disabled.vue
Normal file
45
docs/examples/segmented/disabled.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-start gap-4">
|
||||
<el-segmented v-model="value" :options="options" disabled />
|
||||
<el-segmented v-model="value" :options="options" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const value = ref('Mon')
|
||||
const options = [
|
||||
{
|
||||
label: 'Mon',
|
||||
value: 'Mon',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Tue',
|
||||
value: 'Tue',
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
value: 'Wed',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Thu',
|
||||
value: 'Thu',
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
value: 'Fri',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Sat',
|
||||
value: 'Sat',
|
||||
},
|
||||
{
|
||||
label: 'Sun',
|
||||
value: 'Sun',
|
||||
},
|
||||
]
|
||||
</script>
|
@ -72,6 +72,7 @@ export * from './virtual-list'
|
||||
export * from './watermark'
|
||||
export * from './tour'
|
||||
export * from './anchor'
|
||||
export * from './segmented'
|
||||
|
||||
// plugins
|
||||
export * from './infinite-scroll'
|
||||
|
136
packages/components/segmented/__tests__/segmented.test.tsx
Normal file
136
packages/components/segmented/__tests__/segmented.test.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import Segmented from '../src/segmented.vue'
|
||||
|
||||
describe('Segmented.vue', () => {
|
||||
test('render test', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options}></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.is-selected').text()).toEqual('Mon')
|
||||
})
|
||||
|
||||
test('render v-model', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options}></Segmented>
|
||||
))
|
||||
expect(wrapper.find('.is-selected').text()).toEqual('Mon')
|
||||
value.value = 'Tue'
|
||||
await nextTick()
|
||||
expect(wrapper.find('.is-selected').text()).toEqual('Tue')
|
||||
})
|
||||
|
||||
test('render options', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ref(['a', 'b'])
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options.value}></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.el-segmented__item').length).toEqual(2)
|
||||
options.value.push('c')
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.el-segmented__item').length).toEqual(3)
|
||||
})
|
||||
|
||||
test('render block', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options} block></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.is-block').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('render size', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented
|
||||
v-model={value.value}
|
||||
options={options}
|
||||
size={'large'}
|
||||
></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.el-segmented--large').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('render disabled', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options} disabled></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.is-disabled').length).toBe(7)
|
||||
})
|
||||
|
||||
test('render option disabled', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = [
|
||||
{
|
||||
label: 'Mon',
|
||||
value: 'Mon',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Tue',
|
||||
value: 'Tue',
|
||||
},
|
||||
{
|
||||
label: 'Wed',
|
||||
value: 'Wed',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Thu',
|
||||
value: 'Thu',
|
||||
},
|
||||
{
|
||||
label: 'Fri',
|
||||
value: 'Fri',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
label: 'Sat',
|
||||
value: 'Sat',
|
||||
},
|
||||
{
|
||||
label: 'Sun',
|
||||
value: 'Sun',
|
||||
},
|
||||
]
|
||||
const wrapper = mount(() => (
|
||||
<Segmented v-model={value.value} options={options}></Segmented>
|
||||
))
|
||||
await nextTick()
|
||||
expect(wrapper.findAll('.is-disabled').length).toBe(3)
|
||||
})
|
||||
|
||||
test('render accessible attributes', async () => {
|
||||
const value = ref('Mon')
|
||||
const options = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const wrapper = mount(() => (
|
||||
<Segmented
|
||||
v-model={value.value}
|
||||
options={options}
|
||||
id={'id'}
|
||||
name={'name'}
|
||||
aria-label={'label'}
|
||||
></Segmented>
|
||||
))
|
||||
const input = wrapper.find('input')
|
||||
await nextTick()
|
||||
expect(wrapper.attributes('id')).toEqual('id')
|
||||
expect(wrapper.attributes('aria-label')).toEqual('label')
|
||||
expect(input.attributes('name')).toEqual('name')
|
||||
})
|
||||
})
|
7
packages/components/segmented/index.ts
Normal file
7
packages/components/segmented/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { withInstall } from '@element-plus/utils'
|
||||
import Segmented from './src/segmented.vue'
|
||||
|
||||
export const ElSegmented = withInstall(Segmented)
|
||||
export default ElSegmented
|
||||
|
||||
export * from './src/segmented'
|
70
packages/components/segmented/src/segmented.ts
Normal file
70
packages/components/segmented/src/segmented.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {
|
||||
buildProps,
|
||||
definePropType,
|
||||
isNumber,
|
||||
isString,
|
||||
} from '@element-plus/utils'
|
||||
import { useSizeProp } from '@element-plus/hooks'
|
||||
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
||||
|
||||
import type { Option } from './types'
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
import type Segmented from './segmented.vue'
|
||||
|
||||
export const segmentedProps = buildProps({
|
||||
/**
|
||||
* @description options of segmented
|
||||
*/
|
||||
options: {
|
||||
type: definePropType<Option[]>(Array),
|
||||
default: () => [],
|
||||
},
|
||||
/**
|
||||
* @description binding value
|
||||
*/
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean],
|
||||
default: undefined,
|
||||
},
|
||||
/**
|
||||
* @description fit width of parent content
|
||||
*/
|
||||
block: Boolean,
|
||||
/**
|
||||
* @description size of component
|
||||
*/
|
||||
size: useSizeProp,
|
||||
/**
|
||||
* @description whether segmented is disabled
|
||||
*/
|
||||
disabled: Boolean,
|
||||
/**
|
||||
* @description whether to trigger form validation
|
||||
*/
|
||||
validateEvent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
/**
|
||||
* @description native input id
|
||||
*/
|
||||
id: String,
|
||||
/**
|
||||
* @description native `name` attribute
|
||||
*/
|
||||
name: String,
|
||||
/**
|
||||
* @description native `aria-label` attribute
|
||||
*/
|
||||
ariaLabel: String,
|
||||
})
|
||||
|
||||
export type SegmentedProps = ExtractPropTypes<typeof segmentedProps>
|
||||
|
||||
export const segmentedEmits = {
|
||||
[UPDATE_MODEL_EVENT]: (val: any) => isString(val) || isNumber(val),
|
||||
[CHANGE_EVENT]: (val: any) => isString(val) || isNumber(val),
|
||||
}
|
||||
export type SegmentedEmits = typeof segmentedEmits
|
||||
|
||||
export type SegmentedInstance = InstanceType<typeof Segmented>
|
173
packages/components/segmented/src/segmented.vue
Normal file
173
packages/components/segmented/src/segmented.vue
Normal file
@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
:id="inputId"
|
||||
ref="segmentedRef"
|
||||
:class="segmentedCls"
|
||||
role="radiogroup"
|
||||
:aria-label="!isLabeledByFormItem ? ariaLabel || 'segmented' : undefined"
|
||||
:aria-labelledby="isLabeledByFormItem ? formItem!.labelId : undefined"
|
||||
>
|
||||
<div :class="ns.e('group')">
|
||||
<div :style="selectedStyle" :class="selectedCls" />
|
||||
<label
|
||||
v-for="(item, index) in options"
|
||||
:key="index"
|
||||
:class="getItemCls(item)"
|
||||
>
|
||||
<input
|
||||
:class="ns.e('item-input')"
|
||||
type="radio"
|
||||
:name="name"
|
||||
:disabled="getDisabled(item)"
|
||||
:checked="getSelected(item)"
|
||||
@change="handleChange(item)"
|
||||
/>
|
||||
<div :class="ns.e('item-label')">
|
||||
<slot :item="item">{{ getLabel(item) }}</slot>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useActiveElement, useResizeObserver } from '@vueuse/core'
|
||||
import { useId, useNamespace } from '@element-plus/hooks'
|
||||
import {
|
||||
useFormDisabled,
|
||||
useFormItem,
|
||||
useFormItemInputId,
|
||||
useFormSize,
|
||||
} from '@element-plus/components/form'
|
||||
import { debugWarn, isObject } from '@element-plus/utils'
|
||||
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
||||
import { segmentedEmits, segmentedProps } from './segmented'
|
||||
import type { Option } from './types'
|
||||
|
||||
defineOptions({
|
||||
name: 'ElSegmented',
|
||||
})
|
||||
|
||||
const props = defineProps(segmentedProps)
|
||||
const emit = defineEmits(segmentedEmits)
|
||||
|
||||
const ns = useNamespace('segmented')
|
||||
const segmentedId = useId()
|
||||
const segmentedSize = useFormSize()
|
||||
const _disabled = useFormDisabled()
|
||||
const { formItem } = useFormItem()
|
||||
const { inputId, isLabeledByFormItem } = useFormItemInputId(props, {
|
||||
formItemContext: formItem,
|
||||
})
|
||||
|
||||
const segmentedRef = ref<HTMLElement | null>(null)
|
||||
const activeElement = useActiveElement()
|
||||
|
||||
const state = reactive({
|
||||
isInit: false,
|
||||
width: 0,
|
||||
translateX: 0,
|
||||
disabled: false,
|
||||
focusVisible: false,
|
||||
})
|
||||
|
||||
const handleChange = (item: Option) => {
|
||||
const value = getValue(item)
|
||||
emit(UPDATE_MODEL_EVENT, value)
|
||||
emit(CHANGE_EVENT, value)
|
||||
}
|
||||
|
||||
const getValue = (item: Option) => {
|
||||
return isObject(item) ? item.value : item
|
||||
}
|
||||
|
||||
const getLabel = (item: Option) => {
|
||||
return isObject(item) ? item.label : item
|
||||
}
|
||||
|
||||
const getDisabled = (item: Option) => {
|
||||
return !!(_disabled.value || (isObject(item) ? item.disabled : false))
|
||||
}
|
||||
|
||||
const getSelected = (item: Option) => {
|
||||
return props.modelValue === getValue(item)
|
||||
}
|
||||
|
||||
const getOption = (value: any) => {
|
||||
return props.options.find((item) => getValue(item) === value)
|
||||
}
|
||||
|
||||
const getItemCls = (item: Option) => {
|
||||
return [
|
||||
ns.e('item'),
|
||||
ns.is('selected', getSelected(item)),
|
||||
ns.is('disabled', getDisabled(item)),
|
||||
]
|
||||
}
|
||||
|
||||
const updateSelect = () => {
|
||||
if (!segmentedRef.value) return
|
||||
const selectedItem = segmentedRef.value.querySelector(
|
||||
'.is-selected'
|
||||
) as HTMLElement
|
||||
const selectedItemInput = segmentedRef.value.querySelector(
|
||||
'.is-selected input'
|
||||
) as HTMLElement
|
||||
if (!selectedItem || !selectedItemInput) {
|
||||
state.width = 0
|
||||
state.translateX = 0
|
||||
state.disabled = false
|
||||
state.focusVisible = false
|
||||
return
|
||||
}
|
||||
const rect = selectedItem.getBoundingClientRect()
|
||||
state.isInit = true
|
||||
state.width = rect.width
|
||||
state.translateX = selectedItem.offsetLeft
|
||||
state.disabled = getDisabled(getOption(props.modelValue))
|
||||
try {
|
||||
// This will failed in test
|
||||
state.focusVisible = selectedItemInput.matches(':focus-visible')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const segmentedCls = computed(() => [
|
||||
ns.b(),
|
||||
ns.m(segmentedSize.value),
|
||||
ns.is('block', props.block),
|
||||
])
|
||||
|
||||
const selectedStyle = computed(() => ({
|
||||
width: `${state.width}px`,
|
||||
transform: `translateX(${state.translateX}px)`,
|
||||
display: state.isInit ? 'block' : 'none',
|
||||
}))
|
||||
|
||||
const selectedCls = computed(() => [
|
||||
ns.e('item-selected'),
|
||||
ns.is('disabled', state.disabled),
|
||||
ns.is('focus-visible', state.focusVisible),
|
||||
])
|
||||
|
||||
const name = computed(() => {
|
||||
return props.name || segmentedId.value
|
||||
})
|
||||
|
||||
useResizeObserver(segmentedRef, updateSelect)
|
||||
|
||||
watch(activeElement, updateSelect)
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
updateSelect()
|
||||
if (props.validateEvent) {
|
||||
formItem?.validate?.('change').catch((err) => debugWarn(err))
|
||||
}
|
||||
},
|
||||
{
|
||||
flush: 'post',
|
||||
}
|
||||
)
|
||||
</script>
|
11
packages/components/segmented/src/types.ts
Normal file
11
packages/components/segmented/src/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type Option =
|
||||
| {
|
||||
label: string
|
||||
value: string | number | boolean
|
||||
disabled?: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| undefined
|
2
packages/components/segmented/style/css.ts
Normal file
2
packages/components/segmented/style/css.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style/css'
|
||||
import '@element-plus/theme-chalk/el-segmented.css'
|
2
packages/components/segmented/style/index.ts
Normal file
2
packages/components/segmented/style/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
import '@element-plus/components/base/style'
|
||||
import '@element-plus/theme-chalk/src/segmented.scss'
|
@ -105,6 +105,7 @@ import { ElUpload } from '@element-plus/components/upload'
|
||||
import { ElWatermark } from '@element-plus/components/watermark'
|
||||
import { ElTour, ElTourStep } from '@element-plus/components/tour'
|
||||
import { ElAnchor, ElAnchorLink } from '@element-plus/components/anchor'
|
||||
import { ElSegmented } from '@element-plus/components/segmented'
|
||||
|
||||
import type { Plugin } from 'vue'
|
||||
|
||||
@ -212,4 +213,5 @@ export default [
|
||||
ElTourStep,
|
||||
ElAnchor,
|
||||
ElAnchorLink,
|
||||
ElSegmented,
|
||||
] as Plugin[]
|
||||
|
@ -832,6 +832,25 @@ $anchor: map.merge(
|
||||
$anchor
|
||||
);
|
||||
|
||||
// Segmented
|
||||
// css3 var in packages/theme-chalk/src/segmented.scss
|
||||
$segmented: () !default;
|
||||
$segmented: map.merge(
|
||||
(
|
||||
'color': getCssVar('text-color', 'regular'),
|
||||
'bg-color': getCssVar('fill-color', 'light'),
|
||||
'padding': 2px,
|
||||
'item-selected-color': getCssVar('color-white'),
|
||||
'item-selected-bg-color': getCssVar('color-primary'),
|
||||
'item-selected-disabled-bg-color': getCssVar('color-primary', 'light-5'),
|
||||
'item-hover-color': getCssVar('text-color', 'primary'),
|
||||
'item-hover-bg-color': getCssVar('fill-color', 'dark'),
|
||||
'item-active-bg-color': getCssVar('fill-color', 'darker'),
|
||||
'item-disabled-color': getCssVar('text-color', 'placeholder'),
|
||||
),
|
||||
$segmented
|
||||
);
|
||||
|
||||
// Table
|
||||
// css3 var in packages/theme-chalk/src/table.scss
|
||||
$table: () !default;
|
||||
|
@ -106,3 +106,4 @@
|
||||
@use './tour.scss';
|
||||
@use './anchor.scss';
|
||||
@use './anchor-link.scss';
|
||||
@use './segmented.scss';
|
||||
|
160
packages/theme-chalk/src/segmented.scss
Normal file
160
packages/theme-chalk/src/segmented.scss
Normal file
@ -0,0 +1,160 @@
|
||||
@use 'sass:map';
|
||||
|
||||
@use 'mixins/mixins' as *;
|
||||
@use 'mixins/utils' as *;
|
||||
@use 'mixins/var' as *;
|
||||
@use 'common/var' as *;
|
||||
|
||||
$segmented-border-radius: () !default;
|
||||
$segmented-border-radius: map.merge(
|
||||
(
|
||||
'large': map.get($button-border-radius, 'large'),
|
||||
'default': map.get($button-border-radius, 'default'),
|
||||
'small': map.get($button-border-radius, 'small'),
|
||||
),
|
||||
$segmented-border-radius
|
||||
);
|
||||
|
||||
$segmented-font-size: () !default;
|
||||
$segmented-font-size: map.merge(
|
||||
(
|
||||
'large': 16px,
|
||||
'default': 14px,
|
||||
'small': 14px,
|
||||
),
|
||||
$segmented-font-size
|
||||
);
|
||||
|
||||
$segmented-item-padding: () !default;
|
||||
$segmented-item-padding: map.merge(
|
||||
(
|
||||
'large': 0 11px,
|
||||
'default':0 11px,
|
||||
'small': 0 7px,
|
||||
),
|
||||
$segmented-item-padding
|
||||
);
|
||||
|
||||
@include b(segmented) {
|
||||
@include set-component-css-var('segmented', $segmented);
|
||||
}
|
||||
|
||||
@include b(segmented) {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
min-height: map.get($input-height, 'default');
|
||||
background: getCssVar('segmented-bg-color');
|
||||
padding: getCssVar('segmented-padding');
|
||||
border-radius: map.get($segmented-border-radius, 'default');
|
||||
font-size: map.get($segmented-font-size, 'default');
|
||||
color: getCssVar('segmented-color');
|
||||
box-sizing: border-box;
|
||||
|
||||
@include e(group) {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@include e(item-selected) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: getCssVar('segmented-item-selected-bg-color');
|
||||
height: 100%;
|
||||
width: 10px;
|
||||
border-radius: calc(#{map.get($segmented-border-radius, 'default')} - 2px);
|
||||
transition: all 0.3s;
|
||||
pointer-events: none;
|
||||
|
||||
@include when(disabled) {
|
||||
background: getCssVar('segmented-item-selected-disabled-bg-color');
|
||||
}
|
||||
|
||||
@include when(focus-visible) {
|
||||
&:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
outline: 2px solid getCssVar('segmented-item-selected-bg-color');
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include e(item) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
border-radius: calc(#{map.get($segmented-border-radius, 'default')} - 2px);
|
||||
padding: map.get($segmented-item-padding, 'default');
|
||||
|
||||
&:not(.is-disabled):not(.is-selected):hover {
|
||||
color: getCssVar('segmented-item-hover-color');
|
||||
background: getCssVar('segmented-item-hover-bg-color');
|
||||
}
|
||||
|
||||
&:not(.is-disabled):not(.is-selected):active {
|
||||
background: getCssVar('segmented-item-active-bg-color');
|
||||
}
|
||||
|
||||
@include when(selected) {
|
||||
color: getCssVar('segmented-item-selected-color');
|
||||
|
||||
&.is-disabled {
|
||||
color: getCssVar('segmented-item-selected-color');
|
||||
}
|
||||
}
|
||||
|
||||
@include when(disabled) {
|
||||
cursor: not-allowed;
|
||||
color: getCssVar('segmented-item-disabled-color');
|
||||
}
|
||||
}
|
||||
|
||||
@include e(item-input) {
|
||||
position: absolute;
|
||||
margin: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@include e(item-label) {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
line-height: normal;
|
||||
@include utils-ellipsis;
|
||||
transition: color 0.3s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@include when(block) {
|
||||
display: flex;
|
||||
|
||||
.#{$namespace}-segmented__item {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@each $size in (large, small) {
|
||||
@include m($size) {
|
||||
min-height: map.get($input-height, $size);
|
||||
border-radius: map.get($segmented-border-radius, $size);
|
||||
font-size: map.get($segmented-font-size, $size);
|
||||
|
||||
@include e(item-selected) {
|
||||
border-radius: calc(#{map.get($segmented-border-radius, $size)} - 2px);
|
||||
}
|
||||
|
||||
@include e(item) {
|
||||
border-radius: calc(#{map.get($segmented-border-radius, $size)} - 2px);
|
||||
padding: map.get($segmented-item-padding, $size);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
1
typings/components.d.ts
vendored
1
typings/components.d.ts
vendored
@ -103,6 +103,7 @@ declare module '@vue/runtime-core' {
|
||||
ElTourStep: typeof import('../packages/element-plus')['ElTourStep']
|
||||
ElAnchor: typeof import('../packages/element-plus')['ElAnchor']
|
||||
ElAnchorLink: typeof import('../packages/element-plus')['ElAnchorLink']
|
||||
ElSegmented: typeof import('../packages/element-plus')['ElSegmented']
|
||||
}
|
||||
|
||||
interface ComponentCustomProperties {
|
||||
|
Loading…
Reference in New Issue
Block a user