From d0446935804b88b765d2c1c0879519b2e909fbb7 Mon Sep 17 00:00:00 2001 From: Gery Hirschfeld Date: Wed, 2 Nov 2022 13:05:42 +0100 Subject: [PATCH] fix(radio): group elements sends focus and blur event. Closes #623 --- .../cypress/component/bal-radio.cy.ts | 10 +- .../cypress/component/bal-radio.vue | 6 +- packages/components/src/components.d.ts | 63 +++-- .../src/components/bal-app/bal-app.tsx | 28 +- .../bal-radio-group/bal-radio-group.tsx | 245 ++++++++++++++---- .../components/form/bal-radio/bal-radio.tsx | 196 ++++++++------ .../form/bal-radio/test/bal-radio.cy.html | 50 ++-- .../src/styles/components/radio-checkbox.sass | 12 +- .../components/src/utils/focus-visible.ts | 79 ++++++ packages/components/src/utils/helpers.ts | 16 +- .../control-value-accessors/value-accessor.ts | 1 + 11 files changed, 513 insertions(+), 193 deletions(-) create mode 100644 packages/components/src/utils/focus-visible.ts diff --git a/packages/components/cypress/component/bal-radio.cy.ts b/packages/components/cypress/component/bal-radio.cy.ts index 41bab9a83a..6f64a6e9f0 100644 --- a/packages/components/cypress/component/bal-radio.cy.ts +++ b/packages/components/cypress/component/bal-radio.cy.ts @@ -4,7 +4,7 @@ import BalRadioTest from './bal-radio.vue' describe('bal-radio.cy.ts', () => { describe('radio', () => { it('should have a default slot', () => { - cy.mount(BalRadio, { slots: { default: () => 'My label' } }) + cy.mount(BalRadio as any, { slots: { default: () => 'My label' } }) cy.get('bal-radio').contains('My label') cy.get('bal-radio').find('input').should('not.be.checked') }) @@ -14,17 +14,23 @@ describe('bal-radio.cy.ts', () => { let onClickSpy: Cypress.Agent let onBalChangeSpy: Cypress.Agent let onBalInputSpy: Cypress.Agent + let onBalFocusSpy: Cypress.Agent + let onBalBlurSpy: Cypress.Agent beforeEach(() => { onClickSpy = cy.spy().as('click') onBalChangeSpy = cy.spy().as('balChange') onBalInputSpy = cy.spy().as('balInput') + onBalFocusSpy = cy.spy().as('balFocus') + onBalBlurSpy = cy.spy().as('balBlur') - cy.mount(BalRadioTest, { + cy.mount(BalRadioTest as any, { props: { onClick: onClickSpy, onBalInput: onBalInputSpy, onBalChange: onBalChangeSpy, + onBalFocus: onBalFocusSpy, + onBalBlur: onBalBlurSpy, }, }) }) diff --git a/packages/components/cypress/component/bal-radio.vue b/packages/components/cypress/component/bal-radio.vue index ece9d65a67..83080c4084 100644 --- a/packages/components/cypress/component/bal-radio.vue +++ b/packages/components/cypress/component/bal-radio.vue @@ -4,8 +4,8 @@ import { BalRadioGroup, BalRadio } from '../../.storybook/vue/components' diff --git a/packages/components/src/components.d.ts b/packages/components/src/components.d.ts index 5fbdda0fb8..5c82e91a02 100644 --- a/packages/components/src/components.d.ts +++ b/packages/components/src/components.d.ts @@ -57,6 +57,7 @@ export namespace Components { "value": boolean; } interface BalApp { + "setFocus": (elements: HTMLElement[]) => Promise; } interface BalBadge { /** @@ -1753,10 +1754,6 @@ export namespace Components { "value"?: string | number; } interface BalRadio { - /** - * If `true`, the radio is selected. - */ - "checked": boolean; /** * If `true`, the element is not mutable, focusable, or even submitted with the form. The user can neither edit nor focus on the control, nor its form control descendants. */ @@ -1785,6 +1782,10 @@ export namespace Components { * @deprecated If `true` the radio has no label */ "isEmpty": boolean; + /** + * Label of the radio item. + */ + "label": string; /** * If `true` the radio has no label */ @@ -1801,20 +1802,18 @@ export namespace Components { * If `true`, the user must fill in a value before submitting a form. */ "required": boolean; + "setButtonTabindex": (value: number) => Promise; + "setFocus": (ev: any) => Promise; /** - * Sets blur on the native `input`. Use this method instead of the global `input.blur()`. - */ - "setBlur": () => Promise; - /** - * Sets the focus on the checkbox input element. - */ - "setFocus": () => Promise; - /** - * Value of the radio item, if checked the whole group has this value. + * the value of the radio. */ - "value": number | string | boolean; + "value"?: any | null; } interface BalRadioGroup { + /** + * If `true`, the radios can be deselected. + */ + "allowEmptySelection": boolean; /** * If `true`, the element is not mutable, focusable, or even submitted with the form. The user can neither edit nor focus on the control, nor its form control descendants. */ @@ -1837,9 +1836,9 @@ export namespace Components { "readonly"?: boolean; "setValue": (value: number | string | boolean) => Promise; /** - * The value of the control. + * the value of the radio group. */ - "value": number | string | boolean; + "value"?: any | null; /** * Displays the checkboxes vertically */ @@ -5134,10 +5133,6 @@ declare namespace LocalJSX { "value"?: string | number; } interface BalRadio { - /** - * If `true`, the radio is selected. - */ - "checked"?: boolean; /** * If `true`, the element is not mutable, focusable, or even submitted with the form. The user can neither edit nor focus on the control, nor its form control descendants. */ @@ -5162,6 +5157,10 @@ declare namespace LocalJSX { * @deprecated If `true` the radio has no label */ "isEmpty"?: boolean; + /** + * Label of the radio item. + */ + "label"?: string; /** * If `true` the radio has no label */ @@ -5195,11 +5194,15 @@ declare namespace LocalJSX { */ "required"?: boolean; /** - * Value of the radio item, if checked the whole group has this value. + * the value of the radio. */ - "value"?: number | string | boolean; + "value"?: any | null; } interface BalRadioGroup { + /** + * If `true`, the radios can be deselected. + */ + "allowEmptySelection"?: boolean; /** * If `true`, the element is not mutable, focusable, or even submitted with the form. The user can neither edit nor focus on the control, nor its form control descendants. */ @@ -5216,18 +5219,30 @@ declare namespace LocalJSX { * The name of the control, which is submitted with the form data. */ "name"?: string; + /** + * Emitted when the toggle loses focus. + */ + "onBalBlur"?: (event: BalRadioGroupCustomEvent) => void; /** * Emitted when the checked property has changed. */ "onBalChange"?: (event: BalRadioGroupCustomEvent) => void; + /** + * Emitted when the toggle has focus. + */ + "onBalFocus"?: (event: BalRadioGroupCustomEvent) => void; + /** + * Emitted when the checked property has changed. + */ + "onBalInput"?: (event: BalRadioGroupCustomEvent) => void; /** * If `true` the element can not mutated, meaning the user can not edit the control. */ "readonly"?: boolean; /** - * The value of the control. + * the value of the radio group. */ - "value"?: number | string | boolean; + "value"?: any | null; /** * Displays the checkboxes vertically */ diff --git a/packages/components/src/components/bal-app/bal-app.tsx b/packages/components/src/components/bal-app/bal-app.tsx index 74ba96148d..565367af2e 100644 --- a/packages/components/src/components/bal-app/bal-app.tsx +++ b/packages/components/src/components/bal-app/bal-app.tsx @@ -1,11 +1,25 @@ -import { Component, Host, h, Event, EventEmitter } from '@stencil/core' +import { Component, Host, h, Event, EventEmitter, Method } from '@stencil/core' import globalScript from '../../global' import { isBrowser } from '../../utils/browser' +import { rIC } from '../../utils/helpers' +import { Loggable, Logger, LogInstance } from '../../utils/log' @Component({ tag: 'bal-app', }) -export class App { +export class App implements Loggable { + private focusVisible?: any + log!: LogInstance + + @Logger('bal-app') + createLogger(log: LogInstance) { + this.log = log + } + + /** + * @internal + * Tells if the components are ready + */ @Event({ bubbles: true, composed: true }) balAppLoad!: EventEmitter connectedCallback() { @@ -14,6 +28,16 @@ export class App { componentDidLoad() { this.balAppLoad.emit(true) + rIC(async () => { + import('../../utils/focus-visible').then(module => (this.focusVisible = module.startFocusVisible())) + }) + } + + @Method() + async setFocus(elements: HTMLElement[]) { + if (this.focusVisible) { + this.focusVisible.setFocus(elements) + } } render() { diff --git a/packages/components/src/components/form/bal-radio/bal-radio-group/bal-radio-group.tsx b/packages/components/src/components/form/bal-radio/bal-radio-group/bal-radio-group.tsx index 1138eaaf7a..624fc88c96 100644 --- a/packages/components/src/components/form/bal-radio/bal-radio-group/bal-radio-group.tsx +++ b/packages/components/src/components/form/bal-radio/bal-radio-group/bal-radio-group.tsx @@ -8,35 +8,62 @@ import { Event, Watch, ComponentInterface, - Method, Listen, + Method, } from '@stencil/core' -import { stopEventBubbling } from '../../../../utils/form-input' -import { findItemLabel, isDescendant } from '../../../../utils/helpers' -import { inheritAttributes } from '../../../../utils/attributes' +import { findItemLabel, hasTagName, isDescendant } from '../../../../utils/helpers' import { Props, Events } from '../../../../types' import { BEM } from '../../../../utils/bem' +import { Loggable, Logger, LogInstance } from '../../../../utils/log' @Component({ tag: 'bal-radio-group', }) -export class RadioGroup implements ComponentInterface { +export class RadioGroup implements ComponentInterface, Loggable { private inputId = `bal-rg-${radioGroupIds++}` - private inheritedAttributes: { [k: string]: any } = {} - private initialValue: number | string | boolean = '' + private initialValue?: any | null - @Element() el!: HTMLElement + log!: LogInstance + + @Logger('bal-radio-group') + createLogger(log: LogInstance) { + this.log = log + } + + @Element() el!: HTMLBalRadioGroupElement /** - * Defines the layout of the radio button + * PUBLIC PROPERTY API + * ------------------------------------------------------ */ - @Prop() interface?: Props.BalRadioGroupInterface = undefined + + /** + * If `true`, the radios can be deselected. + */ + @Prop() allowEmptySelection = false /** * The name of the control, which is submitted with the form data. */ @Prop() name: string = this.inputId + /** + * the value of the radio group. + */ + @Prop({ mutable: true }) value?: any | null + + @Watch('value') + valueChanged(value: any | undefined) { + this.setRadioTabindex(value) + + this.balInput.emit(this.value) + } + + /** + * Defines the layout of the radio button + */ + @Prop() interface?: Props.BalRadioGroupInterface = undefined + /** * Displays the checkboxes vertically */ @@ -60,8 +87,8 @@ export class RadioGroup implements ComponentInterface { @Watch('disabled') disabledChanged(value: boolean | undefined) { if (value !== undefined) { - this.children.forEach(child => { - child.disabled = value + this.getRadios().forEach(radio => { + radio.disabled = value }) } } @@ -74,97 +101,214 @@ export class RadioGroup implements ComponentInterface { @Watch('readonly') readonlyChanged(value: boolean | undefined) { if (value !== undefined) { - this.children.forEach(child => { - child.readonly = value + this.getRadios().forEach(radio => { + radio.readonly = value }) } } /** - * The value of the control. + * Emitted when the checked property has changed. */ - @Prop({ mutable: true }) value: number | string | boolean = '' - @Watch('value') - valueChanged(value: number | string | boolean, oldValue: number | string | boolean) { - if (value !== oldValue) { - this.sync() - } - } + @Event() balChange!: EventEmitter /** * Emitted when the checked property has changed. */ - @Event() balChange!: EventEmitter + @Event() balInput!: EventEmitter + + /** + * Emitted when the toggle has focus. + */ + @Event() balFocus!: EventEmitter + + /** + * Emitted when the toggle loses focus. + */ + @Event() balBlur!: EventEmitter + + /** + * LIFECYCLE + * ------------------------------------------------------ + */ + + connectedCallback() { + this.initialValue = this.value + } + + componentWillLoad() { + this.setRadioInterface() + this.disabledChanged(this.disabled) + this.readonlyChanged(this.readonly) + } + + componentDidLoad() { + this.setRadioTabindex(this.value) + } + + /** + * LISTENERS + * ------------------------------------------------------ + */ + + @Listen('balFocus', { capture: true, target: 'document' }) + radioFocusListener(event: CustomEvent) { + const { target } = event + if (target && isDescendant(this.el, target) && hasTagName(target, 'bal-radio')) { + this.balFocus.emit(event.detail) + } + } - @Listen('balChange', { capture: true, target: 'document' }) - listenOnClick(ev: UIEvent) { - if (isDescendant(this.el, ev.target as HTMLElement)) { - stopEventBubbling(ev) + @Listen('balBlur', { capture: true, target: 'document' }) + radioBlurListener(event: CustomEvent) { + const { target } = event + if (target && isDescendant(this.el, target) && hasTagName(target, 'bal-blur')) { + this.balFocus.emit(event.detail) } } @Listen('reset', { capture: true, target: 'document' }) - resetHandler(event: UIEvent) { + resetListener(event: UIEvent) { const formElement = event.target as HTMLElement if (formElement?.contains(this.el)) { this.value = this.initialValue - this.sync() } } - connectedCallback() { - this.initialValue = this.value - } + @Listen('keydown', { target: 'document' }) + onKeydown(ev: any) { + if (ev.target && !this.el.contains(ev.target)) { + return + } - componentWillLoad() { - this.inheritedAttributes = inheritAttributes(this.el, ['aria-label', 'tabindex', 'title']) - this.sync() - this.disabledChanged(this.disabled) - this.readonlyChanged(this.readonly) - } + // Get all radios inside of the radio group and then + // filter out disabled radios since we need to skip those + const radios = this.getRadios().filter(radio => !radio.disabled) + const targetRadio = ev.target.closest('bal-radio') - private get children(): HTMLBalRadioElement[] { - return Array.from(this.el.querySelectorAll('bal-radio')) + // Only move the radio if the current focus is in the radio group + if (targetRadio && radios.includes(targetRadio)) { + const index = radios.findIndex(radio => radio === targetRadio) + const current = radios[index] + + let next + + // If hitting arrow down or arrow right, move to the next radio + // If we're on the last radio, move to the first radio + if (['ArrowDown', 'ArrowRight'].includes(ev.code)) { + next = index === radios.length - 1 ? radios[0] : radios[index + 1] + } + + // If hitting arrow up or arrow left, move to the previous radio + // If we're on the first radio, move to the last radio + if (['ArrowUp', 'ArrowLeft'].includes(ev.code)) { + next = index === 0 ? radios[radios.length - 1] : radios[index - 1] + } + + if (next && radios.includes(next)) { + next.setFocus(ev) + + this.value = next.value + this.balChange.emit(this.value) + } + + // Update the radio group value when a user presses the + // space bar on top of a selected radio + if (['Space'].includes(ev.code)) { + this.value = this.allowEmptySelection && this.value !== undefined ? undefined : current.value + + // Prevent browsers from jumping + // to the bottom of the screen + ev.preventDefault() + } + } } + /** + * PUBLIC METHODS + * ------------------------------------------------------ + */ + /** @internal */ @Method() async setValue(value: number | string | boolean) { this.value = value } - private sync() { - this.children.forEach((radio: HTMLBalRadioElement) => { + /** + * PRIVATE METHODS + * ------------------------------------------------------ + */ + + private setRadioTabindex = (value: any | undefined) => { + const radios = this.getRadios() + + // Get the first radio that is not disabled and the checked one + const first = radios.find(radio => !radio.disabled) + const checked = radios.find(radio => radio.value === value && !radio.disabled) + + if (!first && !checked) { + return + } + + // If an enabled checked radio exists, set it to be the focusable radio + // otherwise we default to focus the first radio + const focusable = checked || first + + for (const radio of radios) { + const tabindex = radio === focusable ? 0 : -1 + radio.setButtonTabindex(tabindex) + } + } + + private setRadioInterface() { + this.getRadios().forEach((radio: HTMLBalRadioElement) => { if (this.interface) { radio.interface = this.interface } - radio.checked = radio.value === this.value }) } + /** + * GETTERS + * ------------------------------------------------------ + */ + + private getRadios(): HTMLBalRadioElement[] { + return Array.from(this.el.querySelectorAll('bal-radio')) + } + + /** + * EVENT BINDING + * ------------------------------------------------------ + */ + private onClick = (ev: Event) => { const element = ev.target as HTMLAnchorElement if (element.href) { return } + ev.preventDefault() const selectedRadio = ev.target && (ev.target as HTMLElement).closest('bal-radio') - if (selectedRadio) { - if (selectedRadio.disabled || selectedRadio.readonly) { - ev.stopPropagation() - return - } - + if (selectedRadio && !selectedRadio.disabled && !selectedRadio.readonly) { const currentValue = this.value const newValue = selectedRadio.value if (newValue !== currentValue) { this.value = newValue - this.balChange.emit(this.value) + } else if (this.allowEmptySelection) { + this.value = undefined } + this.balChange.emit(this.value) } } + /** + * RENDER + * ------------------------------------------------------ + */ + render() { const label = findItemLabel(this.el) const block = BEM.block('radio-checkbox-group') @@ -179,7 +323,6 @@ export class RadioGroup implements ComponentInterface { aria-labelledby={label?.id} aria-disabled={this.disabled ? 'true' : null} onClick={this.onClick} - {...this.inheritedAttributes} >
{ +export class Radio implements ComponentInterface, Loggable { private inputId = `bal-rb-${radioIds++}` - private inheritedAttributes: { [k: string]: any } = {} + private radioGroup: HTMLBalRadioGroupElement | null = null + private nativeInput!: HTMLInputElement + private keyboardMode = true + + log!: LogInstance + + @Logger('bal-radio') + createLogger(log: LogInstance) { + this.log = log + } - nativeInput?: HTMLInputElement + @Element() el!: HTMLBalRadioElement + + /** + * If `true`, the radio is selected. + */ + @State() checked = false + @State() focused = false + + /** + * The tabindex of the radio button. + * @internal + */ + @State() buttonTabindex = -1 + + /** + * PUBLIC PROPERTY API + * ------------------------------------------------------ + */ - @Element() el!: HTMLElement + /** + * The name of the control, which is submitted with the form data. + */ + @Prop() name: string = this.inputId - @State() hasFocus = false - @State() hasLabel = true + /** + * the value of the radio. + */ + @Prop() value?: any | null /** * @deprecated If `true` the radio has no label @@ -49,9 +71,9 @@ export class Radio implements ComponentInterface, FormInput { } /** - * The name of the control, which is submitted with the form data. + * Label of the radio item. */ - @Prop() name: string = this.inputId + @Prop() label = '' /** * If `true` the radio has no label @@ -68,16 +90,6 @@ export class Radio implements ComponentInterface, FormInput { */ @Prop() interface: Props.BalRadioInterface = 'radio' - /** - * Value of the radio item, if checked the whole group has this value. - */ - @Prop() value: number | string | boolean = '' - - /** - * If `true`, the radio is selected. - */ - @Prop({ mutable: true, reflect: true }) checked = false - /** * If `true`, the element is not mutable, focusable, or even submitted with the form. The user can neither edit nor focus on the control, nor its form control descendants. */ @@ -123,50 +135,57 @@ export class Radio implements ComponentInterface, FormInput { */ @Event() balClick!: EventEmitter - @Listen('click', { capture: true, target: 'document' }) - listenOnClick(ev: UIEvent) { - if ( - (this.disabled || this.readonly) && - ev.target && - (ev.target === this.el || isDescendant(this.el, ev.target as HTMLElement)) - ) { - stopEventBubbling(ev) - } - } - - componentWillLoad() { - this.inheritedAttributes = inheritAttributes(this.el, ['aria-label', 'tabindex', 'title']) - } + /** + * LIFECYCLE + * ------------------------------------------------------ + */ connectedCallback() { - if (this.group) { + if (this.value === undefined) { + this.value = this.inputId + } + const radioGroup = (this.radioGroup = this.el.closest('bal-radio-group')) + if (radioGroup) { this.updateState() - this.group.addEventListener('balChange', () => this.updateState()) + radioGroup.addEventListener('balInput', this.updateState) } + + this.el.addEventListener('keydown', this.onKeydown) + this.el.addEventListener('touchstart', this.onPointerDown) + this.el.addEventListener('mousedown', this.onPointerDown) } disconnectedCallback() { - if (this.group) { - this.group.removeEventListener('balChange', () => this.updateState()) + const radioGroup = this.radioGroup + if (radioGroup) { + radioGroup.removeEventListener('balInput', this.updateState) + this.radioGroup = null } + + this.el.removeEventListener('keydown', this.onKeydown) + this.el.removeEventListener('touchstart', this.onPointerDown) + this.el.removeEventListener('mousedown', this.onPointerDown) } /** - * Sets the focus on the checkbox input element. + * PUBLIC METHODS + * ------------------------------------------------------ */ + + /** @internal */ @Method() - async setFocus() { - inputSetFocus(this) + async setFocus(ev: any) { + ev.stopPropagation() + ev.preventDefault() + + this.nativeInput.focus() + this.focused = true } - /** - * Sets blur on the native `input`. Use this method instead of the global - * `input.blur()`. - * @internal - */ + /** @internal */ @Method() - async setBlur() { - inputSetBlur(this) + async setButtonTabindex(value: number) { + this.buttonTabindex = value } /** @@ -177,35 +196,55 @@ export class Radio implements ComponentInterface, FormInput { return Promise.resolve(this.nativeInput) } - get group(): HTMLBalRadioGroupElement | null { - return this.el.closest('bal-radio-group') - } + /** + * PRIVATE METHODS + * ------------------------------------------------------ + */ private updateState = () => { - if (this.group) { - this.checked = this.group.value === this.value + if (this.radioGroup) { + this.checked = this.radioGroup.value === this.value } } - private onInputFocus = (ev: FocusEvent) => inputHandleFocus(this, ev) - - private onInputBlur = (ev: FocusEvent) => inputHandleBlur(this, ev) + /** + * EVENT BINDING + * ------------------------------------------------------ + */ - private onClick = (ev: MouseEvent) => { + private onClick = (ev: Event) => { const element = ev.target as HTMLAnchorElement if (element.href) { return } - if (element.nodeName !== 'INPUT' && !this.disabled && !this.readonly) { - this.balChange.emit(this.checked) - this.balClick.emit(ev) - ev.preventDefault() - } else { - stopEventBubbling(ev) + this.checked = this.nativeInput.checked + this.balClick.emit() + this.nativeInput.focus() + } + + private onFocus = () => { + this.balFocus.emit() + + if (this.keyboardMode) { + this.focused = true } } + private onBlur = () => { + this.balBlur.emit() + this.focused = false + } + + private onPointerDown = () => (this.keyboardMode = false) + + private onKeydown = (ev: any) => (this.keyboardMode = FOCUS_KEYS.includes(ev.key)) + + /** + * RENDER + * ------------------------------------------------------ + */ + render() { const block = BEM.block('radio-checkbox') const inputEl = block.element('input') @@ -220,21 +259,17 @@ export class Radio implements ComponentInterface, FormInput { aria-checked={`${this.checked}`} aria-disabled={this.disabled ? 'true' : null} aria-hidden={this.disabled ? 'true' : null} - aria-focused={this.hasFocus ? 'true' : null} class={{ + 'bal-focused': this.focused, ...block.class(), ...block.modifier('radio').class(), ...block.modifier('select-button').class(this.interface === 'select-button'), - ...block.modifier('focused').class(this.hasFocus), ...block.modifier('invalid').class(this.invalid), ...block.modifier('checked').class(this.checked), ...block.modifier('flat').class(this.flat), ...block.modifier('disabled').class(this.disabled || this.readonly), }} onClick={this.onClick} - onFocus={this.onInputFocus} - onBlur={this.onInputBlur} - {...this.inheritedAttributes} > { disabled={this.disabled} readonly={this.readonly} required={this.required} - onFocus={this.onInputFocus} - onBlur={this.onInputBlur} - ref={inputEl => (this.nativeInput = inputEl)} + onFocus={this.onFocus} + onBlur={this.onBlur} + ref={inputEl => (this.nativeInput = inputEl as HTMLInputElement)} /> diff --git a/packages/components/src/components/form/bal-radio/test/bal-radio.cy.html b/packages/components/src/components/form/bal-radio/test/bal-radio.cy.html index a5c89444fc..233498d676 100644 --- a/packages/components/src/components/form/bal-radio/test/bal-radio.cy.html +++ b/packages/components/src/components/form/bal-radio/test/bal-radio.cy.html @@ -1,25 +1,28 @@ - - - - - - - - - -
- Basic -
- - Label 1 - Label 2 - Label 3 - Label Disabled - -
- Form Reset + + + + + + + + + + + + +
+ Basic +
+ + Label 1 + Label 2 + Label 3 + Label Disabled + +
+ Form Reset
@@ -49,7 +52,8 @@ Label Disabled
-
-
- +
+
+ + diff --git a/packages/components/src/styles/components/radio-checkbox.sass b/packages/components/src/styles/components/radio-checkbox.sass index b53e493da2..26f46abf92 100644 --- a/packages/components/src/styles/components/radio-checkbox.sass +++ b/packages/components/src/styles/components/radio-checkbox.sass @@ -69,12 +69,12 @@ input + label, input + label > span cursor: pointer - // - // handle visible focus for key navigation - &--select-button:focus-within - @extend %focus-shadow - &__input:focus:not(&__input--select-button) + &__label::before - @extend %focus-shadow + + &.bal-focused + &.bal-radio-checkbox--select-button + @extend %focus-shadow + .bal-radio-checkbox__input:not(.bal-radio-checkbox__input--select-button) + .bal-radio-checkbox__label::before + @extend %focus-shadow // // label of the radio with the icon and the text diff --git a/packages/components/src/utils/focus-visible.ts b/packages/components/src/utils/focus-visible.ts new file mode 100644 index 0000000000..f1d352e3b2 --- /dev/null +++ b/packages/components/src/utils/focus-visible.ts @@ -0,0 +1,79 @@ +const BAL_FOCUSED = 'bal-focused' +const BAL_FOCUSABLE = 'bal-focusable' +export const FOCUS_KEYS = [ + 'Tab', + 'ArrowDown', + 'Space', + 'Escape', + ' ', + 'Shift', + 'Enter', + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'Home', + 'End', +] + +export const startFocusVisible = (rootEl?: HTMLElement) => { + let currentFocus: Element[] = [] + let keyboardMode = true + + const ref = rootEl ? rootEl.shadowRoot! : document + const root = rootEl ? rootEl : document.body + + const setFocus = (elements: Element[]) => { + currentFocus.forEach(el => el.classList.remove(BAL_FOCUSED)) + elements.forEach(el => el.classList.add(BAL_FOCUSED)) + currentFocus = elements + } + + const pointerDown = () => { + keyboardMode = false + setFocus([]) + } + + const onKeydown = (ev: any) => { + keyboardMode = FOCUS_KEYS.includes(ev.key) + if (!keyboardMode) { + setFocus([]) + } + } + + const onFocusin = (ev: Event) => { + if (keyboardMode && ev.composedPath !== undefined) { + const toFocus = ev.composedPath().filter((el: any) => { + if (el.classList) { + return el.classList.contains(BAL_FOCUSABLE) + } + return false + }) as Element[] + setFocus(toFocus) + } + } + + const onFocusout = () => { + if (ref.activeElement === root) { + setFocus([]) + } + } + + ref.addEventListener('keydown', onKeydown) + ref.addEventListener('focusin', onFocusin) + ref.addEventListener('focusout', onFocusout) + ref.addEventListener('touchstart', pointerDown) + ref.addEventListener('mousedown', pointerDown) + + const destroy = () => { + ref.removeEventListener('keydown', onKeydown) + ref.removeEventListener('focusin', onFocusin) + ref.removeEventListener('focusout', onFocusout) + ref.removeEventListener('touchstart', pointerDown) + ref.removeEventListener('mousedown', pointerDown) + } + + return { + destroy, + setFocus, + } +} diff --git a/packages/components/src/utils/helpers.ts b/packages/components/src/utils/helpers.ts index ce636c518e..c44d8423c6 100644 --- a/packages/components/src/utils/helpers.ts +++ b/packages/components/src/utils/helpers.ts @@ -3,6 +3,14 @@ import { EventEmitter } from '@stencil/core' declare const __zone_symbol__requestAnimationFrame: any declare const requestAnimationFrame: any +export const rIC = (callback: () => void) => { + if ('requestIdleCallback' in window) { + ;(window as any).requestIdleCallback(callback) + } else { + setTimeout(callback, 32) + } +} + export const wait = (ms = 0): Promise => { return new Promise(resolve => { setTimeout(() => resolve(), ms) @@ -33,8 +41,12 @@ export const findItemLabel = (componentEl: HTMLElement): HTMLLabelElement | null return null } -export const isDescendant = (parent: HTMLElement, child: HTMLElement) => { - let node = child.parentNode +export const hasTagName = (element: any, tag: string) => { + return element && element.tagName && element.tagName === tag.toUpperCase() +} + +export const isDescendant = (parent: HTMLElement, child: HTMLElement | EventTarget) => { + let node = (child as any).parentNode while (node != null) { if (node == parent) { return true diff --git a/packages/output-targets/angular/resources/control-value-accessors/value-accessor.ts b/packages/output-targets/angular/resources/control-value-accessors/value-accessor.ts index ca75842772..18996ad798 100644 --- a/packages/output-targets/angular/resources/control-value-accessors/value-accessor.ts +++ b/packages/output-targets/angular/resources/control-value-accessors/value-accessor.ts @@ -28,6 +28,7 @@ export class ValueAccessor implements ControlValueAccessor { @HostListener('balBlur', ['$event.target']) handleBlurEvent(el: any) { + console.warn('handleBlurEvent', el, this.el) if (el === this.el.nativeElement) { this.onTouched() }