diff --git a/.changeset/bright-impalas-fetch.md b/.changeset/bright-impalas-fetch.md new file mode 100644 index 00000000..d7af500b --- /dev/null +++ b/.changeset/bright-impalas-fetch.md @@ -0,0 +1,6 @@ +--- +'@banana-ui/react': minor +'@banana-ui/banana': minor +--- + +add switch component diff --git a/.dumirc.ts b/.dumirc.ts index b9f41882..cc5020fc 100644 --- a/.dumirc.ts +++ b/.dumirc.ts @@ -124,6 +124,10 @@ export default defineConfig({ link: '/example/stepper', title: 'Stepper 数量选择器', }, + { + link: '/example/switch', + title: 'Switch 开关', + }, ], }, { diff --git a/docs/example/Switch/demos/basicUsage.tsx b/docs/example/Switch/demos/basicUsage.tsx new file mode 100644 index 00000000..44aa9511 --- /dev/null +++ b/docs/example/Switch/demos/basicUsage.tsx @@ -0,0 +1,9 @@ +/** + * title: 基本使用 + */ + +import { Switch } from '@banana-ui/react'; + +export default function BasicUsage() { + return ; +} diff --git a/docs/example/Switch/demos/disabled.tsx b/docs/example/Switch/demos/disabled.tsx new file mode 100644 index 00000000..ae8a4fba --- /dev/null +++ b/docs/example/Switch/demos/disabled.tsx @@ -0,0 +1,20 @@ +/** + * title: 禁用状态 + */ + +import { Switch } from '@banana-ui/react'; + +export default function Disabled() { + return ( +
+ + +
+ ); +} diff --git a/docs/example/Switch/demos/size.tsx b/docs/example/Switch/demos/size.tsx new file mode 100644 index 00000000..11f5c75f --- /dev/null +++ b/docs/example/Switch/demos/size.tsx @@ -0,0 +1,31 @@ +/** + * title: 尺寸 + * description: Switch 组件提供了`small`、`default`、默认为 `default`两种尺寸。还可以通过修改CSS变量来自定义大小。 + */ + +import { Switch } from '@banana-ui/react'; + +export default function BasicUsage() { + return ( +
+ + + 自定义尺寸 + +
+ ); +} diff --git a/docs/example/Switch/demos/withContent.tsx b/docs/example/Switch/demos/withContent.tsx new file mode 100644 index 00000000..67498690 --- /dev/null +++ b/docs/example/Switch/demos/withContent.tsx @@ -0,0 +1,83 @@ +/** + * title: 携带内容 + */ + +import { Switch } from '@banana-ui/react'; + +export default function WithContent() { + const checkedSvg = ( + + + + + + + ); + + const uncheckedSvg = ( + + + + ); + + return ( +
+ + 开启 + 关闭 + + + 1 + 0 + + + {checkedSvg} + {uncheckedSvg} + +
+ ); +} diff --git a/docs/example/Switch/index.md b/docs/example/Switch/index.md new file mode 100644 index 00000000..db387fc7 --- /dev/null +++ b/docs/example/Switch/index.md @@ -0,0 +1,72 @@ +--- +group: 组件 +demo: + cols: 2 +--- + +# Switch 开关 表单组件 + +``` + | Switch +``` + +表示开关状态/两种状态之间的切换时; + +## 代码演示 + + + + + + + +## 属性 - Attributes & Properties + +| 属性 | 说明 | 类型 | 默认值 | +| -------------- | --------------------------------- | ------------------------ | ----------- | +| checked | 是否选中 | `boolean` | false | +| defaultChecked | 默认是否选中 | `boolean` | false | +| disabled | 是否禁用 | `boolean` | false | +| readonly | 是否只读 | `boolean` | false | +| name | 用于表单提交的字段名 | `string` | - | +| size | 尺寸 | `'small'` \| `'default'` | `'default'` | +| required | 是否必填 | `boolean` | false | +| controlled | 是否受控 | `boolean` | false | +| form | 可以传入一个 id, 用于指定所属表单 | `string` | - | + +## 事件 - Events + +| 事件 | 说明 | Event Detail | +| ------ | ------------------ | ---------------------- | +| change | checked 变化时触发 | `{ checked: boolean }` | + +## 插槽 - Slots + +| 插槽 | 说明 | +| --------- | --------------------------------- | +| checked | 开关为 checked 状态时展示的内容 | +| unchecked | 开关为 unchecked 状态时展示的内容 | + +## CSS Parts + +| Part | 说明 | +| ------- | ------------------------------------------- | +| base | 包裹组件的容器 | +| control | 开关的 control 部分,也就是开关移动的小圆点 | +| inner | 开关有内容的时候,存放内容的容器 | + +## 样式变量 + +| 变量 | 说明 | 默认值 | +| ------------------------------------------- | ---------------------------------------- | ------------------- | +| --banana-switch-gap | 开关的 control 与容器的 padding | 2px | +| --banana-color-text | 开关的文字的颜色 | #fff | +| --banana-font-size | 开关的字体大小 | 14px | +| --banana-font-family | 开关的字体 | inherit | +| --banana-switch-width | 开关容器的最小宽度 | 44px | +| --banana-switch-height | 开关容器的高度 | 22px | +| --banana-switch-background-no-checked | 开关不是 checked 状态时的背景颜色 | rgba(0, 0, 0, 0.25) | +| --banana-switch-background-no-checked-hover | 开关不是 checked 状态时 hover 的背景颜色 | rgba(0, 0, 0, 0.45) | +| --banana-switch-background-checked | 开关是 checked 状态背景颜色 | #1677ff | +| --banana-switch-control-size | 开关的 control 的大小 | 18px | +| --banana-inner-gap | 开关的 control 与容器间的间距 | 2px | diff --git a/packages/banana-react/src/index.ts b/packages/banana-react/src/index.ts index b13aeef2..5b6c38ab 100644 --- a/packages/banana-react/src/index.ts +++ b/packages/banana-react/src/index.ts @@ -20,6 +20,7 @@ import { Rating } from './rating'; import { Select } from './select'; import { SelectOption } from './select-option'; import { Stepper } from './stepper'; +import { Switch } from './switch'; import { Tooltip } from './tooltip'; export { @@ -45,5 +46,6 @@ export { Select, SelectOption, Stepper, + Switch, Tooltip, }; diff --git a/packages/banana-react/src/switch/index.ts b/packages/banana-react/src/switch/index.ts new file mode 100644 index 00000000..a509f348 --- /dev/null +++ b/packages/banana-react/src/switch/index.ts @@ -0,0 +1,18 @@ +import { BSwitch } from '@banana-ui/banana'; +import { EventName, createComponent } from '@lit-labs/react'; +import * as React from 'react'; + +const events = { + onChange: 'change' as EventName< + CustomEvent<{ + value: boolean; + }> + >, +}; + +export const Switch = createComponent({ + tagName: 'b-switch', + react: React, + elementClass: BSwitch, + events, +}); diff --git a/packages/banana/src/index.ts b/packages/banana/src/index.ts index 9e28961b..ad835551 100644 --- a/packages/banana/src/index.ts +++ b/packages/banana/src/index.ts @@ -20,6 +20,7 @@ import BRating from './rating'; import BSelect from './select'; import BSelectOption from './select-option'; import BStepper from './stepper'; +import BSwitch from './switch'; import BTooltip from './tooltip'; export { @@ -45,5 +46,6 @@ export { BSelect, BSelectOption, BStepper, + BSwitch, BTooltip, }; diff --git a/packages/banana/src/switch/index.styles.ts b/packages/banana/src/switch/index.styles.ts new file mode 100644 index 00000000..feb524be --- /dev/null +++ b/packages/banana/src/switch/index.styles.ts @@ -0,0 +1,138 @@ +import { css, unsafeCSS } from 'lit'; +import componentStyles from '../../styles/components.styles'; +import { Colors, Variables as Var } from '../../styles/global-variables'; + +export default [ + componentStyles, + css` + :host { + color: rgba(${unsafeCSS(Colors.Red5)}); + line-height: ${unsafeCSS(Var.LineHeightDense)}; + --banana-switch-gap: 2px; + --banana-color-text: #fff; + --banana-font-size: 14px; + --banana-font-family: inherit; + --banana-switch-width: 44px; + --banana-switch-height: 22px; + --banana-switch-background-no-checked: rgba(0, 0, 0, 0.25); + --banana-switch-background-no-checked-hover: rgba(0, 0, 0, 0.45); + --banana-switch-background-checked: #1677ff; + --banana-switch-control-size: 18px; + --banana-inner-gap: 4px; + } + :host([checked]) > .banana-switch { + background-color: var(--banana-switch-background-checked); + } + :host([disabled]) > .banana-switch { + opacity: 0.6; + cursor: not-allowed; + } + + :host(:not([disabled], [checked])) > .banana-switch:hover { + background-color: var(--banana-switch-background-no-checked-hover); + } + + :host([checked]) > .banana-switch:hover { + background-color: #4096ff; + } + + .banana-switch { + position: relative; + box-sizing: border-box; + margin: 0; + padding: var(--banana-switch-gap); + color: var(--banana-color-text); + font-size: var(--banana-font-size); + list-style: none; + font-family: var(--banana-font-family); + display: inline-flex; + align-items: center; + min-width: var(--banana-switch-width); + height: var(--banana-switch-height); + vertical-align: middle; + border: 0; + border-radius: 100px; + cursor: pointer; + transition: all ${unsafeCSS(Var.TransitionNormal)}; + user-select: none; + background-color: var(--banana-switch-background-no-checked); + } + + .banana-switch-sm { + min-width: var(--banana-switch-width-sm, 28px); + height: var(--banana-switch-height-sm, 16px); + } + + .switch__input { + position: absolute; + opacity: 0; + padding: 0; + margin: 0; + pointer-events: none; + } + + .switch__inner { + position: relative; + overflow: hidden; + height: 100%; + width: calc(var(--banana-inner-width) + var(--banana-switch-control-size) + var(--banana-inner-gap) * 2); + white-space: nowrap; + } + + :host([checked]) .switch__inner { + padding-inline-start: var(--banana-inner-gap); + padding-inline-end: calc(var(--banana-switch-control-size) + var(--banana-switch-gap)); + } + + :host(:not([checked])) .switch__inner { + padding-inline-start: calc(var(--banana-switch-control-size) + var(--banana-switch-gap)); + padding-inline-end: var(--banana-inner-gap); + } + + .switch__control { + position: absolute; + display: inline-block; + width: var(--banana-switch-control-size); + height: var(--banana-switch-control-size); + background: #fff; + border-radius: calc(var(--banana-switch-control-size) / 2); + transition: all ${unsafeCSS(Var.TransitionNormal)}; + inset-inline-start: var(--banana-switch-gap); + } + + .switch__control-sm { + width: var(--banana-switch-control-size-sm, 12px); + height: var(--banana-switch-control-size-sm, 12px); + } + + :host([checked][size='default']) .switch__control { + inset-inline-start: calc(100% - calc(var(--banana-switch-control-size) + var(--banana-switch-gap))); + } + + :host([checked][size='small']) .switch__control { + inset-inline-start: calc(100% - calc(var(--banana-switch-control-size-sm, 12px) + var(--banana-switch-gap))); + } + + .switch__inner-wrapper { + display: inline-block; + width: 100%; + text-align: center; + transition: all ${unsafeCSS(Var.TransitionNormal)}; + } + + :host(:not([checked])) .switch__checked-offset { + translate: calc(-100% - var(--banana-inner-gap) - var(--banana-switch-control-size)); + } + :host([checked]) .switch__checked-offset { + translate: 0; + } + + :host(:not([checked])) .switch__unchecked-offset { + translate: calc(-100% - var(--banana-inner-gap)); + } + + :host([checked]) .switch__unchecked-offset { + translate: var(--banana-switch-control-size); + } + `, +]; diff --git a/packages/banana/src/switch/index.test.ts b/packages/banana/src/switch/index.test.ts new file mode 100644 index 00000000..bcdf0a7f --- /dev/null +++ b/packages/banana/src/switch/index.test.ts @@ -0,0 +1,231 @@ +import { expect, fixture, html } from '@open-wc/testing'; +import sinon from 'sinon'; +import BSwitch from '.'; + +describe('b-switch', () => { + it('accessibility tests', async () => { + const element = await fixture(html``); + await expect(element).to.be.accessible(); + }); + + describe('when provided no parameters', async () => { + const element = await fixture(html``); + + it('should have correct default values', () => { + expect(element.name).to.equal(''); + expect(element.checked).to.equal(false); + expect(element.defaultChecked).to.equal(false); + expect(element.disabled).to.equal(false); + expect(element.required).to.equal(false); + expect(element.readonly).to.equal(false); + expect(element.controlled).to.equal(false); + expect(element.form).to.equal(undefined); + }); + + it('should have validation methods', () => { + expect(element.reportValidity).to.be.a('function'); + expect(element.checkValidity).to.be.a('function'); + }); + }); + + describe('when provided parameters', async () => { + const element = await fixture(html` + `); + + it('should have correct values', () => { + expect(element.name).to.equal('test'); + expect(element.checked).to.equal(true); + expect(element.defaultChecked).to.equal(true); + expect(element.disabled).to.equal(true); + expect(element.required).to.equal(true); + expect(element.readonly).to.equal(true); + expect(element.controlled).to.equal(true); + expect(element.form).to.equal('test'); + }); + }); + + describe('when clicked', () => { + it('normal switch should toggle the checked state', async () => { + const element = await fixture(html`>`); + // The click event listener is on a container element. + const clickElement = element.shadowRoot?.querySelector('.banana-switch') as HTMLLabelElement; + + // Change event should be fired. + const spy = sinon.spy(); + element.addEventListener('change', spy); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(true); + expect(spy.calledOnce).to.equal(true); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(false); + expect(spy.calledTwice).to.equal(true); + }); + + it('disabled switch should not toggle the checked state', async () => { + const element = await fixture(html``); + const clickElement = element.shadowRoot?.querySelector('.banana-switch') as HTMLLabelElement; + + // Change event should not be fired. + const spy = sinon.spy(); + element.addEventListener('change', spy); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(false); + expect(spy.called).to.equal(false); + }); + + it('readonly switch should not toggle the checked state', async () => { + const element = await fixture(html``); + const clickElement = element.shadowRoot?.querySelector('.banana-switch') as HTMLLabelElement; + + // Change event should not be fired. + const spy = sinon.spy(); + element.addEventListener('change', spy); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(false); + expect(spy.called).to.equal(false); + }); + + it('should not toggle the checked state when the switch is controlled', async () => { + const element = await fixture(html`>`); + const clickElement = element.shadowRoot?.querySelector('.banana-switch') as HTMLLabelElement; + + // Change event should be fired. + const spy = sinon.spy(); + element.addEventListener('change', spy); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(false); + expect(spy.calledOnce).to.equal(true); + clickElement.click(); + await element.updateComplete; + expect(element.checked).to.equal(false); + expect(spy.calledTwice).to.equal(true); + }); + + it('should toggle the display text when provide slot to switch', async () => { + const element = await fixture(html` + open + unOpen + `); + const clickElement = element.shadowRoot?.querySelector('.switch__inner') as HTMLLabelElement; + + const checkedEl = element.shadowRoot?.querySelector('slot[name="checked"]') as HTMLSlotElement; + + const unCheckedEl = element.shadowRoot?.querySelector('slot[name="unchecked"]') as HTMLSlotElement; + + // Change event should be fired. + expect(unCheckedEl.assignedElements()[0].textContent).equal('unOpen'); + const spy = sinon.spy(); + element.addEventListener('change', spy); + clickElement.click(); + await element.updateComplete; + expect(checkedEl.assignedElements()[0].textContent).equal('open'); + }); + }); + + describe('form', () => { + it('a native form should be able to get the value of switch', async () => { + const element = await fixture( + html`
+ +
`, + ); + + // Should get correct form data. + const formData = new FormData(element); + console.log(typeof formData.get('test')); + + expect(formData.get('test')).to.equal('true'); + }); + + it('should not submit the form when a required switch is empty', async () => { + const element = await fixture(html`
{ + event.preventDefault(); + }} + > + +
`); + const switch0 = element.querySelector('b-switch') as BSwitch; + const spy = sinon.spy(); + element.addEventListener('submit', spy); + + // Then make it not empty then submit the form. + switch0.checked = true; + element.requestSubmit(); + + // It should submit the form now. + expect(spy.calledOnce).to.equal(true); + }); + + it('a disabled switch should not be a part of the form data, even if it has a name', async () => { + const element = await fixture(html`
{ + event.preventDefault(); + }} + > + +
`); + + // Should get correct form data. + const formData = new FormData(element); + expect(formData.get('test')).to.equal(null); + }); + + it('should be valid and submit the form when a empty required switch is disabled', async () => { + const element = await fixture(html`
{ + event.preventDefault(); + }} + > + +
`); + const spy = sinon.spy(); + element.addEventListener('submit', spy); + + element.requestSubmit(); + expect(spy.calledOnce).to.equal(true); + }); + + it('should be valid and submit the form when a required switch0 is empty and the form is novaalidate', async () => { + const element = await fixture(html`
{ + event.preventDefault(); + }} + > + +
`); + const spy = sinon.spy(); + element.addEventListener('submit', spy); + + element.requestSubmit(); + expect(spy.calledOnce).to.equal(true); + }); + + it('should become the default value when the form is reset', async () => { + const element = await fixture(html`
+ +
`); + const switchTest = element.querySelector('b-switch') as BSwitch; + + expect(switchTest.checked).to.equal(true); + + element.reset(); + expect(switchTest.checked).to.equal(true); + }); + }); +}); diff --git a/packages/banana/src/switch/index.ts b/packages/banana/src/switch/index.ts new file mode 100644 index 00000000..147ce37f --- /dev/null +++ b/packages/banana/src/switch/index.ts @@ -0,0 +1,142 @@ +import { CSSResultGroup, html, LitElement, nothing } from 'lit'; +import { customElement, property, query, queryAssignedElements, state } from 'lit/decorators.js'; +import { classMap } from 'lit/directives/class-map.js'; +import { BananaFormElementWithOverriddenProperties, FormController } from 'packages/banana/controllers/form'; +import styles from './index.styles'; + +const overriddenProperties = [ + ['value', 'checked'], + ['defaultValue', 'defaultChecked'], +] as const; + +@customElement('b-switch') +export default class BSwitch + extends LitElement + implements BananaFormElementWithOverriddenProperties +{ + private readonly formController = new FormController(this, overriddenProperties); + + static styles?: CSSResultGroup = styles; + + @query('input') + private _validationInput!: HTMLInputElement; + + @queryAssignedElements({ slot: 'checked' }) + _checkedSlotEl!: Array; + + @queryAssignedElements({ slot: 'unchecked' }) + _uncheckedSlotEl!: Array; + + @property() + name = ''; + + @property({ reflect: true }) + size: 'small' | 'default' = 'default'; + + @property({ reflect: true, type: Boolean }) + checked = false; + + @property({ reflect: true, attribute: 'default-checked', type: Boolean }) + defaultChecked = false; + + @property() + form: string | undefined; + + @property({ type: Boolean, reflect: true }) + disabled = false; + + @property({ type: Boolean, reflect: true }) + required = false; + + @property({ type: Boolean, reflect: true }) + readonly = false; + + @property({ type: Boolean, reflect: true }) + controlled = false; + + @state() + private _innerWidth?: number; + + // Pass the reportValidity() method to the form controller. + reportValidity() { + return this._validationInput.reportValidity(); + } + + checkValidity() { + return this._validationInput.checkValidity(); + } + + private _handleChange() { + if (this.disabled || this.readonly) return; + const checked = !this.checked; + if (!this.controlled) { + this.checked = checked; + } + + const eventOptions = { bubbles: false, cancelable: false, composed: true, detail: { checked } }; + this.dispatchEvent(new CustomEvent('change', eventOptions)); + } + + private _handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this._handleChange(); + } + } + + protected firstUpdated(): void { + if (!this.checked) { + this.checked = this.defaultChecked; + } + + this._innerWidth = + this._checkedSlotEl[0]?.offsetWidth > this._uncheckedSlotEl[0]?.offsetWidth + ? this._checkedSlotEl[0]?.offsetWidth + : this._uncheckedSlotEl[0]?.offsetWidth; + } + + connectedCallback() { + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + } + + render() { + return html`
+ +
+ + ${this._checkedSlotEl || this._uncheckedSlotEl + ? html`
+
+ +
+
+ +
+
` + : nothing} +
`; + } +} diff --git a/public/Switch/basicUsage.html b/public/Switch/basicUsage.html new file mode 100644 index 00000000..29d32ab9 --- /dev/null +++ b/public/Switch/basicUsage.html @@ -0,0 +1 @@ + diff --git a/public/Switch/disabled.html b/public/Switch/disabled.html new file mode 100644 index 00000000..5f37984b --- /dev/null +++ b/public/Switch/disabled.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/public/Switch/size.html b/public/Switch/size.html new file mode 100644 index 00000000..f42ee23c --- /dev/null +++ b/public/Switch/size.html @@ -0,0 +1,8 @@ +
+ + + 自定义尺寸 + +
diff --git a/public/Switch/withContent.html b/public/Switch/withContent.html new file mode 100644 index 00000000..dc7e63c0 --- /dev/null +++ b/public/Switch/withContent.html @@ -0,0 +1,53 @@ +
+ + 开启 + 关闭 + + + 1 + 0 + + + + + + + + + + + + + + + + +