diff --git a/COMPONENTS.md b/COMPONENTS.md index fd131cae6..ec008f3d0 100644 --- a/COMPONENTS.md +++ b/COMPONENTS.md @@ -1,7 +1,7 @@ # Umbraco UI components - ### For RFC prototype: + - Avatar - Avatar Group - Button @@ -11,8 +11,8 @@ - Dialog - Delete Dialog - ### Basics + - Avatar - Avatar Group - Badge @@ -43,12 +43,15 @@ - TextAreaField? - Select List - Select List Item + ### Fragments + - Dialog - Notification - Tooltip ### Navigation + - Breadcrumbs - Overlay - Overlay Manager diff --git a/src/components/basics/uui-radio-group/UUIRadioGroupEvent.ts b/src/components/basics/uui-radio-group/UUIRadioGroupEvent.ts new file mode 100644 index 000000000..ac23e0fd3 --- /dev/null +++ b/src/components/basics/uui-radio-group/UUIRadioGroupEvent.ts @@ -0,0 +1,6 @@ +import { UUIEvent } from '../../../event/UUIEvent'; +import { UUIRadioGroupElement } from './uui-radio-group.element'; + +export class UUIRadioGroupEvent extends UUIEvent<{}, UUIRadioGroupElement> { + public static readonly CHANGE = 'change'; +} diff --git a/src/components/basics/uui-radio-group/index.ts b/src/components/basics/uui-radio-group/index.ts new file mode 100644 index 000000000..1fa2fbe0f --- /dev/null +++ b/src/components/basics/uui-radio-group/index.ts @@ -0,0 +1,3 @@ +import { UUIRadioGroupElement } from './uui-radio-group.element'; + +customElements.define('uui-radio-group', UUIRadioGroupElement); diff --git a/src/components/basics/uui-radio-group/uui-radio-group.element.ts b/src/components/basics/uui-radio-group/uui-radio-group.element.ts new file mode 100644 index 000000000..d2b85cdbc --- /dev/null +++ b/src/components/basics/uui-radio-group/uui-radio-group.element.ts @@ -0,0 +1,251 @@ +import { + LitElement, + html, + css, + property, + query, + internalProperty, +} from 'lit-element'; +import { UUIRadioElement } from '../uui-radio/uui-radio.element'; +import { UUIRadioEvent } from '../uui-radio/UUIRadioEvent'; +import { UUIRadioGroupEvent } from './UUIRadioGroupEvent'; + +/** + * @element uui-radio-group + * @slot for uui-radio elements + */ + +//TODO required? +//TODO focused style +//TODO fix the disabled selected - how that should behave + +const ARROW_LEFT = 'ArrowLeft'; +const ARROW_UP = 'ArrowUp'; +const ARROW_RIGHT = 'ArrowRight'; +const ARROW_DOWN = 'ArrowDown'; +const SPACE = ' '; + +export class UUIRadioGroupElement extends LitElement { + static styles = [css``]; + + static formAssociated = true; + + private _internals; + + constructor() { + super(); + this._internals = (this as any).attachInternals(); + this.addEventListener('keydown', this._onKeydown); + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute('role', 'radiogroup'); + } + + private radioElements!: UUIRadioElement[]; + + private getRadioElements(): Promise { + return new Promise(resolve => { + const promisedRadios = this.slotElement + ? (this.slotElement + .assignedElements({ flatten: true }) + .filter(el => el instanceof UUIRadioElement) as UUIRadioElement[]) + : []; + resolve(promisedRadios); + }); + } + + async firstUpdated() { + this.radioElements = await this.getRadioElements(); + if (this.radioElements.length > 0) + this.radioElements[0].setAttribute('tabindex', '0'); + this._addNameToRadios(this.name, this.radioElements); + if (this.disabled) this._toggleDisableOnChildren(true); + } + + @query('slot') protected slotElement!: HTMLSlotElement; + + protected get enabledRadioElements(): UUIRadioElement[] { + return this.radioElements.filter(el => !el.disabled); + } + + //someone help me refactor these two to one method + private _addNameToRadios(name: string, radios: UUIRadioElement[]) { + radios.forEach(el => (el.name = name)); + } + + private _toggleDisableOnChildren(value: boolean) { + this.radioElements.forEach(el => (el.disabled = value)); + } + + private async _handleSlotChange() { + this.radioElements = await this.getRadioElements(); + + const checkedRadios = this.radioElements.filter(el => el.checked === true); + + if (checkedRadios.length > 1) { + this.radioElements.forEach(el => { + el.checked = false; + }); + throw new Error( + 'There can only be one checked element among the children' + ); + } + if (checkedRadios.length === 1) { + this._selected = this.radioElements.indexOf(checkedRadios[0]); + this.value = checkedRadios[0].value; + } + } + + private _disabled = false; + @property({ type: Boolean, reflect: true }) + get disabled() { + return this._disabled; + } + + set disabled(newVal) { + const oldVal = this._disabled; + this._disabled = newVal; + this.requestUpdate('disabled', oldVal); + this._toggleDisableOnChildren(newVal); + } + + private _value: FormDataEntryValue = ''; + @internalProperty() + get value() { + return this._value; + } + set value(newValue) { + const oldVal = this._value; + this._value = newValue; + this._internals.setFormValue(this._value); + this.requestUpdate('value', oldVal); + } + + private _name = ''; + @property({ type: String }) + get name() { + return this._name; + } + + set name(newVal) { + const oldVal = this._name; + this._name = newVal; + if (this.radioElements) + this._addNameToRadios(this._name, this.radioElements); + this.requestUpdate('name', oldVal); + } + + private _selected: number | null = null; + @property({ type: Number }) + get selected() { + return this._selected; + } + + set selected(newVal) { + const oldVal = this._selected; + this._setSelected(newVal); + if (this._selected !== null) { + this.radioElements[this._selected].check(); + } + this.requestUpdate('selected', oldVal); + } + + private _setSelected(newVal: number | null) { + this._selected = newVal; + this._lastSelectedIndex = this.enabledElementsIndexes.findIndex( + index => index === this._selected + ); + if (newVal === null) { + this.radioElements[0].setAttribute('tabindex', '0'); + } + const notSelected = this.radioElements.filter( + el => this.radioElements.indexOf(el) !== this._selected + ); + notSelected.forEach(el => el.uncheck()); + this.value = newVal !== null ? this.radioElements[newVal].value : ''; + } + + protected get enabledElementsIndexes() { + const indexes: number[] = []; + this.radioElements.forEach(el => { + if (el.disabled === false) indexes.push(this.radioElements.indexOf(el)); + }); + return indexes; + } + + private _lastSelectedIndex = 0; //this is index in the array of enalbled radios indexes (this.enabledElementsIndexes) + private _selectPreviousElement() { + if ( + this.selected === null || + this.selected === this.enabledElementsIndexes[0] + ) { + this.selected = this.enabledElementsIndexes[ + this.enabledElementsIndexes.length - 1 + ]; + this._lastSelectedIndex = this.enabledElementsIndexes.length - 1; + } else { + this._lastSelectedIndex--; + this.selected = this.enabledElementsIndexes[this._lastSelectedIndex]; + } + this._fireChangeEvent(); + } + + private _selectNextElement() { + if ( + this.selected === null || + this.selected === + this.enabledElementsIndexes[this.enabledElementsIndexes.length - 1] + ) { + this.selected = this.enabledElementsIndexes[0]; + this._lastSelectedIndex = 0; + } else { + this._lastSelectedIndex++; + this.selected = this.enabledElementsIndexes[this._lastSelectedIndex]; + } + this._fireChangeEvent(); + } + + private _onKeydown(e: KeyboardEvent) { + switch (e.key) { + case ARROW_LEFT: + case ARROW_UP: { + e.preventDefault(); + this._selectPreviousElement(); + break; + } + case ARROW_RIGHT: + case ARROW_DOWN: { + e.preventDefault(); + this._selectNextElement(); + break; + } + + case SPACE: { + if (this.selected === null) + this.selected = this.enabledElementsIndexes[0]; + } + } + } + + private _fireChangeEvent() { + this.dispatchEvent(new UUIRadioGroupEvent(UUIRadioGroupEvent.CHANGE)); + } + + //TODO add event + private _handleSelectOnClick(e: UUIRadioEvent) { + e.stopPropagation(); + this._setSelected(this.radioElements.indexOf(e.target)); + this._fireChangeEvent(); + } + + render() { + return html` + + `; + } +} diff --git a/src/components/basics/uui-radio-group/uui-radio-group.story.ts b/src/components/basics/uui-radio-group/uui-radio-group.story.ts new file mode 100644 index 000000000..112813f0f --- /dev/null +++ b/src/components/basics/uui-radio-group/uui-radio-group.story.ts @@ -0,0 +1,60 @@ +import { html } from 'lit-html'; +import './index'; + +export default { + title: 'Basics/Radio Group', + component: 'uui-radio-group', +}; + +export const Overview = () => + html` + + Option 1 + + Option 3 + Option 4 + Option 5 + Option 6 + Option 7 + + `; + +export const doubleSelect = () => + html` + If you add more then 1 child with "checked" attribiute it will throw an + error in the console and no option will be selected + + Option 1 + + Option 3 + Option 4 + Option 5 + Option 6 + Option 7 + + `; + +export const InFrorm = () => + html` +
+ + Option 1 + Option 3 + + +
+ `; + +export const SelectDisabled = () => + html` + + Option 1 + + Option 3 + + `; diff --git a/src/components/basics/uui-radio-group/uui-radio-group.test.ts b/src/components/basics/uui-radio-group/uui-radio-group.test.ts new file mode 100644 index 000000000..222b63bb0 --- /dev/null +++ b/src/components/basics/uui-radio-group/uui-radio-group.test.ts @@ -0,0 +1,62 @@ +import { html, fixture, expect } from '@open-wc/testing'; +import { resolve } from 'path'; + +import '.'; +import '../uui-radio/index'; +import { UUIRadioElement } from '../uui-radio/uui-radio.element'; +import { UUIRadioGroupElement } from './uui-radio-group.element'; + +describe('UuiToggle', () => { + let element: UUIRadioGroupElement; + let radios: UUIRadioElement[]; + beforeEach(async () => { + element = await fixture(html` + + Option 1 + + Option 3 + `); + radios = Array.from(element.querySelectorAll('uui-radio')); + }); + + it('passes the a11y audit', async () => { + await expect(element).shadowDom.to.be.accessible(); + }); + + it('has internals', async () => { + await expect(element).to.have.property('_internals'); + }); + + it('it selects an item', async () => { + radios[1].check(); + await expect(element.selected).to.equal(1); + }); +}); + +describe('UuiToggle in a Form', () => { + let formElement: HTMLFormElement; + let element: UUIRadioGroupElement; + beforeEach(async () => { + formElement = await fixture( + html`
+ + Option 1 + + Option 3 + +
` + ); + element = formElement.querySelector('uui-radio-group') as any; + }); + + it('form output is empty if element not checked', () => { + const formData = new FormData(formElement); + expect(formData.get(`${element.name}`)).to.be.equal('Value 2'); + }); +}); + +//test double select +//test proagramatically selection +// with none checked +//test if the click works .click() diff --git a/src/components/basics/uui-radio/UUIRadioEvent.ts b/src/components/basics/uui-radio/UUIRadioEvent.ts new file mode 100644 index 000000000..46a83ddf6 --- /dev/null +++ b/src/components/basics/uui-radio/UUIRadioEvent.ts @@ -0,0 +1,6 @@ +import { UUIEvent } from '../../../event/UUIEvent'; +import { UUIRadioElement } from './uui-radio.element'; + +export class UUIRadioEvent extends UUIEvent<{}, UUIRadioElement> { + public static readonly CHANGE = 'change'; +} diff --git a/src/components/basics/uui-radio/index.ts b/src/components/basics/uui-radio/index.ts new file mode 100644 index 000000000..59e39fc92 --- /dev/null +++ b/src/components/basics/uui-radio/index.ts @@ -0,0 +1,3 @@ +import { UUIRadioElement } from './uui-radio.element'; + +customElements.define('uui-radio', UUIRadioElement); diff --git a/src/components/basics/uui-radio/uui-radio.element.ts b/src/components/basics/uui-radio/uui-radio.element.ts new file mode 100644 index 000000000..de0d90215 --- /dev/null +++ b/src/components/basics/uui-radio/uui-radio.element.ts @@ -0,0 +1,174 @@ +import { html, css, property, query, LitElement } from 'lit-element'; + +import { + UUIHorizontalShakeKeyframes, + UUIHorizontalShakeAnimationValue, +} from '../../../animations/uui-shake'; +import { UUIRadioEvent } from './UUIRadioEvent'; +/** + * @element uui-radio + * @slot - for label + * + */ + +export class UUIRadioElement extends LitElement { + static styles = [ + UUIHorizontalShakeKeyframes, + css` + :host { + font-family: inherit; + color: currentColor; + --uui-radio-button-size: calc(var(--uui-size-base-unit) * 3); + } + + label { + display: block; + margin: 5px 0; + display: flex; + align-items: center; + cursor: pointer; + } + + #input { + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + #button { + display: inline-block; + width: var(--uui-radio-button-size, 18px); + height: var(--uui-radio-button-size, 18px); + background-color: var(--uui-interface-background, white); + border: 1px solid var(--uui-interface-border, #d8d7d9); + border-radius: 100%; + margin-right: 10px; + position: relative; + } + + #button::after { + content: ''; + width: calc(var(--uui-radio-button-size) / 2); + height: calc(var(--uui-radio-button-size) / 2); + background-color: var(--uui-interface-selected, #1b264f); + border-radius: 100%; + position: absolute; + top: calc(var(--uui-radio-button-size) / 2); + left: calc(var(--uui-radio-button-size) / 2); + transform: translate(-50%, -50%) scale(0); + transition: all 0.15s ease-in-out; + } + + input:checked ~ #button::after { + transform: translate(-50%, -50%) scale(1); + } + + label:hover #button { + border: 1px solid var(--uui-interface-border-hover, #c4c4c4); + } + + input:checked ~ #button { + border: 1px solid var(--uui-interface-selected, #1b264f); + } + + input:checked:hover ~ #button { + border: 1px solid var(--uui-interface-selected-hover, #2152a3); + } + + input:checked:hover ~ #button::after { + background-color: var(--uui-interface-selected-hover, #2152a3); + } + + input:disabled ~ #button { + border: 1px solid var(--uui-interface-border-disabled); + } + + input:disabled ~ #label { + color: var(--uui-interface-contrast-disabled); + } + + :host([disabled]) label { + cursor: default; + } + + :host([disabled]) input:checked ~ #button { + border: 1px solid var(--uui-interface-selected-disabled); + } + + :host([disabled]) input:checked ~ #button::after { + background-color: var(--uui-interface-selected-disabled); + } + + :host([disabled]) #button:active { + animation: ${UUIHorizontalShakeAnimationValue}; + } + `, + ]; + + @query('#input') + private inputElement!: HTMLInputElement; + + @property({ type: String }) + public name = ''; + + @property({ type: String }) + public value = ''; + + @property({ type: String }) + public label = ''; + + @property({ type: Boolean, reflect: true }) + public checked = false; + + @property({ type: Boolean, reflect: true }) + public disabled = false; + + private _onChange() { + if (this.inputElement.checked) this.check(); + else this.uncheck(); + } + + public uncheck() { + this.checked = false; + this.setAttribute('tabindex', '-1'); + this.setAttribute('aria-checked', 'false'); + } + + public check() { + this.checked = true; + this.dispatchEvent(new UUIRadioEvent(UUIRadioEvent.CHANGE)); + if (!this.disabled) { + this.setAttribute('tabindex', '0'); + this.setAttribute('aria-checked', 'true'); + this.focus(); + } + } + + connectedCallback() { + super.connectedCallback(); + if (!this.hasAttribute('role')) this.setAttribute('role', 'radio'); + if (!this.hasAttribute('tabindex')) this.setAttribute('tabindex', '-1'); + if (!this.hasAttribute('aria-checked')) + this.setAttribute('aria-checked', 'false'); + } + + render() { + return html` `; + } +} diff --git a/src/components/basics/uui-radio/uui-radio.story.ts b/src/components/basics/uui-radio/uui-radio.story.ts new file mode 100644 index 000000000..078791086 --- /dev/null +++ b/src/components/basics/uui-radio/uui-radio.story.ts @@ -0,0 +1,19 @@ +import { html } from 'lit-html'; +import './index'; + +export default { + title: 'Basics/Radio', + component: 'uui-radio', +}; + +export const Default = () => html` Label`; + +export const Disabled = () => html` Active + Disabled + Selected disabled`; + +export const InAForm = () => html` +
+ Active +
+`; diff --git a/src/components/basics/uui-toggle/uui-toggle.story.ts b/src/components/basics/uui-toggle/uui-toggle.story.ts index 0caa20726..3ea102996 100644 --- a/src/components/basics/uui-toggle/uui-toggle.story.ts +++ b/src/components/basics/uui-toggle/uui-toggle.story.ts @@ -40,3 +40,9 @@ export const LongLabel = () => html` label="Let's see how it looks when someone out of reason put's the label in." > `; + +export const InAForm = () => html` +
+ +
+`; diff --git a/src/components/basics/uui-toggle/uui-toggle.test.ts b/src/components/basics/uui-toggle/uui-toggle.test.ts index 524a2f760..509f22f75 100644 --- a/src/components/basics/uui-toggle/uui-toggle.test.ts +++ b/src/components/basics/uui-toggle/uui-toggle.test.ts @@ -12,6 +12,14 @@ describe('UuiToggle', () => { it('passes the a11y audit', async () => { await expect(element).shadowDom.to.be.accessible(); }); + + it('has internals', async () => { + await expect(element).to.have.property('_internals'); + }); + + it('has value', () => { + expect(element.value).to.be.equal('on'); + }); }); describe('UuiToggle in a Form', () => { diff --git a/src/service/UUIFormAssociatedElement.ts b/src/service/UUIFormAssociatedElement.ts new file mode 100644 index 000000000..ab0d2803d --- /dev/null +++ b/src/service/UUIFormAssociatedElement.ts @@ -0,0 +1,31 @@ +import { LitElement, property } from 'lit-element'; + +//this is not used anywhere. YET!!! + +export class UUIFormAssociatedElement extends LitElement { + static formAssociated = true; + + private _internals; + + constructor() { + super(); + this._internals = (this as any).attachInternals(); + } + + private _value: FormDataEntryValue = ''; + + @property({ type: String, reflect: true }) + public name = ''; + + @property({ reflect: true }) + get value() { + return this._value; + } + set value(newVal) { + const oldValue = this._value; + //how to put additional logic here? + this._value = newVal; + this._internals.setFormValue(this._value); + this.requestUpdate('value', oldValue); + } +} diff --git a/src/style/custom-properties/interface.css b/src/style/custom-properties/interface.css index c47ab38a2..bd598d6b3 100644 --- a/src/style/custom-properties/interface.css +++ b/src/style/custom-properties/interface.css @@ -43,5 +43,4 @@ --uui-interface-selected-contrast-hover: var(--uui-color-white); --uui-interface-selected-contrast-disabled: var(--uui-color-white-dimmed); --uui-interface-selected-contrast-focus: var(--uui-color-white); - }