Microsoft/fast-dna

View on GitHub
packages/web-components/fast-foundation/src/radio-group/radio-group.ts

Summary

Maintainability
D
1 day
Test Coverage
import { attr, FASTElement, observable } from "@microsoft/fast-element";
import {
    ArrowKeys,
    Direction,
    keyArrowDown,
    keyArrowLeft,
    keyArrowRight,
    keyArrowUp,
    keyEnter,
} from "@microsoft/fast-web-utilities";
import { FASTRadio } from "../radio/index.js";
import { getDirection } from "../utilities/direction.js";
import { RadioGroupOrientation } from "./radio-group.options.js";

/**
 * An Radio Group Custom HTML Element.
 * Implements the {@link https://www.w3.org/TR/wai-aria-1.1/#radiogroup | ARIA radiogroup }.
 *
 * @slot label - The slot for the label
 * @slot - The default slot for radio buttons
 * @csspart positioning-region - The positioning region for laying out the radios
 * @fires change - Fires a custom 'change' event when the value changes
 *
 * @public
 */
export class FASTRadioGroup extends FASTElement {
    /**
     * When true, the child radios will be immutable by user interaction.
     * See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/readonly | readonly HTML attribute} for more information.
     * @public
     * @remarks
     * HTML Attribute: readonly
     */
    @attr({ attribute: "readonly", mode: "boolean" })
    public readOnly: boolean;

    /**
     * Disables the radio group and child radios.
     *
     * @public
     * @remarks
     * HTML Attribute: disabled
     */
    @attr({ attribute: "disabled", mode: "boolean" })
    public disabled: boolean;
    protected disabledChanged(): void {}

    /**
     * The name of the radio group. Setting this value will set the name value
     * for all child radio elements.
     *
     * @public
     * @remarks
     * HTML Attribute: name
     */
    @attr
    public name: string;
    protected nameChanged(): void {
        if (this.slottedRadioButtons) {
            this.slottedRadioButtons.forEach((radio: FASTRadio) => {
                radio.setAttribute("name", this.name);
            });
        }
    }

    /**
     * The value of the checked radio
     *
     * @public
     * @remarks
     * HTML Attribute: value
     */
    @attr
    public value: string;
    protected valueChanged(): void {
        if (this.slottedRadioButtons) {
            this.slottedRadioButtons.forEach((radio: FASTRadio) => {
                if (radio.value === this.value) {
                    radio.checked = true;
                    this.selectedRadio = radio;
                }
            });
        }
        this.$emit("change");
    }

    /**
     * The orientation of the group
     *
     * @public
     * @remarks
     * HTML Attribute: orientation
     */
    @attr
    public orientation: RadioGroupOrientation = RadioGroupOrientation.horizontal;

    @observable
    public childItems: HTMLElement[];

    /**
     * @internal
     */
    @observable
    public slottedRadioButtons: HTMLElement[];
    protected slottedRadioButtonsChanged(
        oldValue: unknown,
        newValue: HTMLElement[]
    ): void {
        if (this.slottedRadioButtons && this.slottedRadioButtons.length > 0) {
            this.setupRadioButtons();
        }
    }

    private selectedRadio: FASTRadio | null;
    private focusedRadio: FASTRadio | null;
    private direction: Direction;

    private get parentToolbar(): HTMLElement | null {
        return this.closest('[role="toolbar"]');
    }

    private get isInsideToolbar(): boolean {
        return (this.parentToolbar ?? false) as boolean;
    }

    private get isInsideFoundationToolbar(): boolean {
        return !!this.parentToolbar?.["$fastController"];
    }

    /**
     * @internal
     */
    public connectedCallback(): void {
        super.connectedCallback();
        this.direction = getDirection(this);

        this.setupRadioButtons();
    }

    public disconnectedCallback(): void {
        this.slottedRadioButtons.forEach((radio: FASTRadio) => {
            radio.removeEventListener("change", this.radioChangeHandler);
        });
    }

    private setupRadioButtons(): void {
        const checkedRadios: HTMLElement[] = this.slottedRadioButtons.filter(
            (radio: FASTRadio) => {
                return radio.hasAttribute("checked");
            }
        );
        const numberOfCheckedRadios: number = checkedRadios ? checkedRadios.length : 0;
        if (numberOfCheckedRadios > 1) {
            const lastCheckedRadio: FASTRadio = checkedRadios[
                numberOfCheckedRadios - 1
            ] as FASTRadio;
            lastCheckedRadio.checked = true;
        }
        let foundMatchingVal: boolean = false;

        this.slottedRadioButtons.forEach((radio: FASTRadio) => {
            if (this.name !== undefined) {
                radio.setAttribute("name", this.name);
            }

            if (this.value && this.value === radio.value) {
                this.selectedRadio = radio;
                this.focusedRadio = radio;
                radio.checked = true;
                radio.setAttribute("tabindex", "0");
                foundMatchingVal = true;
            } else {
                if (!this.isInsideFoundationToolbar) {
                    radio.setAttribute("tabindex", "-1");
                }
                radio.checked = false;
            }

            radio.addEventListener("change", this.radioChangeHandler);
        });

        if (this.value === undefined && this.slottedRadioButtons.length > 0) {
            const checkedRadios: HTMLElement[] = this.slottedRadioButtons.filter(
                (radio: FASTRadio) => {
                    return radio.hasAttribute("checked");
                }
            );
            const numberOfCheckedRadios: number =
                checkedRadios !== null ? checkedRadios.length : 0;
            if (numberOfCheckedRadios > 0 && !foundMatchingVal) {
                const lastCheckedRadio: FASTRadio = checkedRadios[
                    numberOfCheckedRadios - 1
                ] as FASTRadio;
                lastCheckedRadio.checked = true;
                this.focusedRadio = lastCheckedRadio;
                lastCheckedRadio.setAttribute("tabindex", "0");
            } else {
                this.slottedRadioButtons[0].setAttribute("tabindex", "0");
                this.focusedRadio = this.slottedRadioButtons[0] as FASTRadio;
            }
        }
    }

    private radioChangeHandler = (e: CustomEvent): boolean | void => {
        const changedRadio: FASTRadio = e.target as FASTRadio;

        if (changedRadio.checked) {
            this.slottedRadioButtons.forEach((radio: FASTRadio) => {
                if (radio !== changedRadio) {
                    radio.checked = false;
                    if (!this.isInsideFoundationToolbar) {
                        radio.setAttribute("tabindex", "-1");
                    }
                }
            });
            this.selectedRadio = changedRadio;
            this.value = changedRadio.value;
            changedRadio.setAttribute("tabindex", "0");
            this.focusedRadio = changedRadio;
        }
        e.stopPropagation();
    };

    private moveToRadioByIndex = (group: HTMLElement[], index: number) => {
        const radio: FASTRadio = group[index] as FASTRadio;
        if (!this.isInsideToolbar) {
            radio.setAttribute("tabindex", "0");
            radio.checked = true;
            this.selectedRadio = radio;
        }
        this.focusedRadio = radio;
        radio.focus();
    };

    private moveRightOffGroup = () => {
        (this.nextElementSibling as FASTRadio)?.focus();
    };

    private moveLeftOffGroup = () => {
        (this.previousElementSibling as FASTRadio)?.focus();
    };

    /**
     * @internal
     */
    public focusOutHandler = (e: FocusEvent): boolean | void => {
        const group: HTMLElement[] = this.slottedRadioButtons;
        const radio: FASTRadio | null = e.target as FASTRadio;
        const index: number = radio !== null ? group.indexOf(radio) : 0;
        const focusedIndex: number = this.focusedRadio
            ? group.indexOf(this.focusedRadio)
            : -1;

        if (
            (focusedIndex === 0 && index === focusedIndex) ||
            (focusedIndex === group.length - 1 && focusedIndex === index)
        ) {
            if (!this.selectedRadio) {
                this.focusedRadio = group[0] as FASTRadio;
                this.focusedRadio.setAttribute("tabindex", "0");
                group.forEach((nextRadio: FASTRadio) => {
                    if (nextRadio !== this.focusedRadio) {
                        nextRadio.setAttribute("tabindex", "-1");
                    }
                });
            } else {
                this.focusedRadio = this.selectedRadio;

                if (!this.isInsideFoundationToolbar) {
                    this.selectedRadio.setAttribute("tabindex", "0");
                    group.forEach((nextRadio: FASTRadio) => {
                        if (nextRadio !== this.selectedRadio) {
                            nextRadio.setAttribute("tabindex", "-1");
                        }
                    });
                }
            }
        }
        return true;
    };

    /**
     * @internal
     */
    public handleDisabledClick = (e: MouseEvent): void | boolean => {
        // prevent focus events on items from the click handler when disabled
        if (this.disabled) {
            e.preventDefault();
            return;
        }

        return true;
    };

    /**
     * @internal
     */
    public clickHandler = (e: MouseEvent): void | boolean => {
        if (this.disabled) {
            return;
        }

        e.preventDefault();
        const radio: FASTRadio | null = e.target as FASTRadio;

        if (radio && radio instanceof FASTRadio) {
            radio.checked = true;
            radio.setAttribute("tabindex", "0");
            this.selectedRadio = radio;
            this.focusedRadio = radio;
        }
    };

    private shouldMoveOffGroupToTheRight = (
        index: number,
        group: HTMLElement[],
        key: string
    ): boolean => {
        return index === group.length && this.isInsideToolbar && key === keyArrowRight;
    };

    private shouldMoveOffGroupToTheLeft = (
        group: HTMLElement[],
        key: string
    ): boolean => {
        const index = this.focusedRadio ? group.indexOf(this.focusedRadio) - 1 : 0;
        return index < 0 && this.isInsideToolbar && key === keyArrowLeft;
    };

    private checkFocusedRadio = (): void => {
        if (this.focusedRadio !== null && !this.focusedRadio.checked) {
            this.focusedRadio.checked = true;
            this.focusedRadio.setAttribute("tabindex", "0");
            this.focusedRadio.focus();
            this.selectedRadio = this.focusedRadio;
        }
    };

    private moveRight = (e: KeyboardEvent): void => {
        const group: HTMLElement[] = this.slottedRadioButtons;
        let index: number = 0;

        index = this.focusedRadio ? group.indexOf(this.focusedRadio) + 1 : 1;
        if (this.shouldMoveOffGroupToTheRight(index, group, e.key)) {
            this.moveRightOffGroup();
            return;
        } else if (index === group.length) {
            index = 0;
        }
        /* looping to get to next radio that is not disabled */
        /* matching native radio/radiogroup which does not select an item if there is only 1 in the group */
        while (index < group.length && group.length > 1) {
            if (!(group[index] as FASTRadio).disabled) {
                this.moveToRadioByIndex(group, index);
                break;
            } else if (this.focusedRadio && index === group.indexOf(this.focusedRadio)) {
                break;
            } else if (index + 1 >= group.length) {
                if (this.isInsideToolbar) {
                    break;
                } else {
                    index = 0;
                }
            } else {
                index += 1;
            }
        }
    };

    private moveLeft = (e: KeyboardEvent): void => {
        const group: HTMLElement[] = this.slottedRadioButtons;
        let index: number = 0;

        index = this.focusedRadio ? group.indexOf(this.focusedRadio) - 1 : 0;
        index = index < 0 ? group.length - 1 : index;

        if (this.shouldMoveOffGroupToTheLeft(group, e.key)) {
            this.moveLeftOffGroup();
            return;
        }
        /* looping to get to next radio that is not disabled */
        while (index >= 0 && group.length > 1) {
            if (!(group[index] as FASTRadio).disabled) {
                this.moveToRadioByIndex(group, index);
                break;
            } else if (this.focusedRadio && index === group.indexOf(this.focusedRadio)) {
                break;
            } else if (index - 1 < 0) {
                index = group.length - 1;
            } else {
                index -= 1;
            }
        }
    };

    /**
     * keyboard handling per https://w3c.github.io/aria-practices/#for-radio-groups-not-contained-in-a-toolbar
     * navigation is different when there is an ancestor with role='toolbar'
     *
     * @internal
     */
    public keydownHandler = (e: KeyboardEvent): boolean | void => {
        const key = e.key;

        if (key in ArrowKeys && (this.isInsideFoundationToolbar || this.disabled)) {
            return true;
        }

        switch (key) {
            case keyEnter: {
                this.checkFocusedRadio();
                break;
            }

            case keyArrowRight:
            case keyArrowDown: {
                if (this.direction === Direction.ltr) {
                    this.moveRight(e);
                } else {
                    this.moveLeft(e);
                }
                break;
            }

            case keyArrowLeft:
            case keyArrowUp: {
                if (this.direction === Direction.ltr) {
                    this.moveLeft(e);
                } else {
                    this.moveRight(e);
                }
                break;
            }

            default: {
                return true;
            }
        }
    };
}