diff --git a/commitlint.config.js b/commitlint.config.js index 5757b3ac..b5b9519b 100644 --- a/commitlint.config.js +++ b/commitlint.config.js @@ -15,6 +15,7 @@ module.exports = { 'tab', 'tooltip', 'progress-indicator', + 'checkbox-group', 'checkbox', 'alert', 'select', diff --git a/src/baklava.ts b/src/baklava.ts index cff6aae7..8317dde3 100644 --- a/src/baklava.ts +++ b/src/baklava.ts @@ -1,7 +1,8 @@ export { default as BlAlert } from './components/alert/bl-alert'; export { default as BlBadge } from './components/badge/bl-badge'; export { default as BlButton } from './components/button/bl-button'; -export { default as BlCheckbox } from './components/checkbox/bl-checkbox'; +export { default as BlCheckboxGroup } from './components/checkbox-group/bl-checkbox-group'; +export { default as BlCheckbox } from './components/checkbox-group/checkbox/bl-checkbox'; export { default as BlDialog } from './components/dialog/bl-dialog'; export { default as BlDrawer } from './components/drawer/bl-drawer'; export { default as BlIcon } from './components/icon/bl-icon'; diff --git a/src/components/checkbox-group/bl-checkbox-group.css b/src/components/checkbox-group/bl-checkbox-group.css new file mode 100644 index 00000000..35d36740 --- /dev/null +++ b/src/components/checkbox-group/bl-checkbox-group.css @@ -0,0 +1,21 @@ +:host { + display: flex; + flex-direction: row; +} + +fieldset { + border: none; + padding: 0; +} + +legend { + font: var(--bl-font-title-3-medium); + color: var(--bl-color-content-primary); +} + +.options { + display: flex; + flex-flow: var(--bl-checkbox-direction, column) wrap; + gap: var(--bl-size-m); + margin-block: var(--bl-size-xs); +} diff --git a/src/components/checkbox-group/bl-checkbox-group.stories.mdx b/src/components/checkbox-group/bl-checkbox-group.stories.mdx new file mode 100644 index 00000000..cb564fbb --- /dev/null +++ b/src/components/checkbox-group/bl-checkbox-group.stories.mdx @@ -0,0 +1,89 @@ +import { html } from 'lit'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { styleMap } from 'lit/directives/style-map.js'; +import { Meta, Canvas, ArgsTable, Story, Preview, Source } from '@storybook/addon-docs'; +import { userEvent } from '@storybook/testing-library'; + + + +export const CheckboxGroupTemplate = (args) => html` +${args.options.map((option) => + html`\n ${option.label}` + )} +` + +export const StyledTemplate = (args) => html` + +${CheckboxGroupTemplate(args)} +` + +export const KeyboardNavigationTemplate = (args) => html` + +${CheckboxGroupTemplate(args)} +` + +export const focusSecondOption = async ({ }) => { + await userEvent.keyboard('[Tab]'); + await userEvent.keyboard('[ArrowRight]'); +} + +# Checkbox Group Component + +Checkbox Group is a component to take multiple selections from user with a list of options. It needs to be used with `bl-checkbox` component. +If you set a list of `values`, options with those values will be selected. + + + + {CheckboxGroupTemplate.bind({})} + + + +Checkbox Group component handles keyboard navigation and highlights active checkbox option while navigating with keyboard. First `Tab` focuses on first available option and user can navigate with arrow keys or `Tab`, `Shift+Tab` within options, and `Space` key for selecting it. + + + + {KeyboardNavigationTemplate.bind({})} + + + +By default checkbox options are listed in vertical stack. You can change this by setting `--bl-checkbox-direction` CSS variable as `row`. + + + + {StyledTemplate.bind({})} + + + +## Reference + + diff --git a/src/components/checkbox-group/bl-checkbox-group.test.ts b/src/components/checkbox-group/bl-checkbox-group.test.ts new file mode 100644 index 00000000..03f78ed8 --- /dev/null +++ b/src/components/checkbox-group/bl-checkbox-group.test.ts @@ -0,0 +1,352 @@ +import { elementUpdated, expect, fixture, html } from '@open-wc/testing'; +import { sendKeys } from '@web/test-runner-commands'; +import BlCheckboxGroup from './bl-checkbox-group'; +import './checkbox/bl-checkbox'; + +describe('bl-checkbox-group', () => { + it('should be defined checkbox group instance', () => { + //when + const el = document.createElement('bl-checkbox-group'); + + //then + expect(el).instanceOf(BlCheckboxGroup); + }); + + it('should be rendered with default values', async () => { + //when + const el = await fixture( + html` + Basketball + Football + ` + ); + + //then + expect(el).shadowDom.equal( + ` +
+ Choose sports you like +
+ +
+
+ ` + ); + }); + + it('should set correct options checked with a value', async () => { + //when + const el = await fixture( + html` + Basketball + Football + Tennis + ` + ); + + //then + expect(el.options[0].checked).to.be.true; + expect(el.options[1].checked).to.be.false; + expect(el.options[2].checked).to.be.true; + }); + + describe('keyboard navigation', () => { + it('should focus first option with tab key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[0]); + }); + + it('should focus next option with right arrow key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'ArrowRight', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[1]); + }); + + it('should focus next option with down arrow key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'ArrowDown', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[1]); + }); + + it('should focus previous option with up arrow key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'ArrowDown', + }); + await sendKeys({ + press: 'ArrowUp', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[0]); + }); + + it('should focus previous option with left arrow key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'ArrowRight', + }); + await sendKeys({ + press: 'ArrowLeft', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[0]); + }); + + it('should select current option with space key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: ' ', + }); + + //then + expect(checkboxGroup?.value.length).to.equal(1); + expect(checkboxGroup?.value[0]).to.equal('basketball'); + }); + + it('should focus the next option with Tab key & previous option with Shift+Tab key', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'Tab', + }); + // Shift+Tab + await sendKeys({ + down: 'Shift', + }); + await sendKeys({ + press: 'Tab', + }); + + await sendKeys({ + up: 'Shift', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[0]); + }); + + it('should focus out of the group with tab key when the last element is active', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'Tab', + }); + + //then + expect(document.activeElement).to.equal(el.querySelector('#nextinput')); + }); + + it('should not respond any other keys', async () => { + //when + const el = await fixture( + html`
+ + + Basketball + Football + Tennis + + > +
` + ); + + await elementUpdated(el); + + el.querySelector('#previnput')?.focus(); + + const checkboxGroup = el.querySelector('bl-checkbox-group'); + + //given + await sendKeys({ + press: 'Tab', + }); + await sendKeys({ + press: 'A', + }); + + //then + expect(document.activeElement).to.equal(checkboxGroup?.options[0]); + }); + }); +}); diff --git a/src/components/checkbox-group/bl-checkbox-group.ts b/src/components/checkbox-group/bl-checkbox-group.ts new file mode 100644 index 00000000..601dd9b2 --- /dev/null +++ b/src/components/checkbox-group/bl-checkbox-group.ts @@ -0,0 +1,142 @@ +import { FormControlMixin } from '@open-wc/form-control'; +import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import 'element-internals-polyfill'; +import { event, EventDispatcher } from '../../utilities/event'; +import style from './bl-checkbox-group.css'; +import BlCheckbox, { blCheckboxTag } from './checkbox/bl-checkbox'; + +export const blCheckboxGroupTag = 'bl-checkbox-group'; + +export const blChangeEventName = 'bl-checkbox-group-change'; + +/** + * @tag bl-checkbox-group + * @summary Baklava Button component + * + * @cssproperty --bl-checkbox-direction - Can be used for showing checkbox options as columns instead of rows. Options are `row` or `column` + */ +@customElement(blCheckboxGroupTag) +export default class BlCheckboxGroup extends FormControlMixin(LitElement) { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Sets the checkbox group label + */ + @property({ type: String }) + label: string; + + /** + * Set and gets the actual value of the field + */ + @property({ type: Array, reflect: true }) + value: string[] = []; + + /** + * Sets option as required + */ + @property({ type: Boolean, reflect: true }) + required = false; + + get options(): BlCheckbox[] { + return [].slice.call(this.querySelectorAll(blCheckboxTag)); + } + + get checkedOptions(): string[] { + return this.options.filter(opt => opt.checked).map(opt => opt.value); + } + + get availableOptions(): BlCheckbox[] { + return this.options.filter(option => !option.disabled); + } + + connectedCallback(): void { + super.connectedCallback(); + + this.tabIndex = 0; + this.addEventListener('focus', this.handleFocus); + this.addEventListener('keydown', this.handleKeyDown); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener('focus', this.handleFocus); + this.removeEventListener('keydown', this.handleKeyDown); + } + + updated(changedProperties: Map): void { + if (changedProperties.has('value')) { + this.setValue(this.checkedOptions.join(',')); + this.onChange(this.value); + } + } + + /** + * Fires when checkbox group value changed + */ + @event('bl-checkbox-group-change') private onChange: EventDispatcher; + + private focusedOptionIndex = 0; + + private handleOptionChecked() { + this.value = this.checkedOptions; + } + + private handleKeyDown(event: KeyboardEvent) { + // Next option + if (['ArrowDown', 'ArrowRight'].includes(event.key)) { + this.focusedOptionIndex++; + + // Previous option + } else if (['ArrowUp', 'ArrowLeft'].includes(event.key)) { + this.focusedOptionIndex--; + + // next or previous option with tab / hold shift & tab + } else if (event.key === 'Tab') { + event.shiftKey ? this.focusedOptionIndex-- : this.focusedOptionIndex++; + + if (this.focusedOptionIndex === this.availableOptions.length) { + this.tabIndex = 0; + this.focusedOptionIndex = 0; + return; + } + } else { + // Other keys are not our interest here + return; + } + + // Don't exceed array indexes + this.focusedOptionIndex = Math.max( + 0, + Math.min(this.focusedOptionIndex, this.availableOptions.length - 1) + ); + + this.availableOptions[this.focusedOptionIndex].focus(); + + event.preventDefault(); + } + + private handleFocus() { + this.availableOptions[this.focusedOptionIndex].focus(); + } + + render(): TemplateResult { + return html`
+ ${this.label} +
+ +
+
`; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blCheckboxGroupTag]: BlCheckboxGroup; + } + interface HTMLElementEventMap { + [blChangeEventName]: CustomEvent; + } +} diff --git a/src/components/checkbox/bl-checkbox.css b/src/components/checkbox-group/checkbox/bl-checkbox.css similarity index 78% rename from src/components/checkbox/bl-checkbox.css rename to src/components/checkbox-group/checkbox/bl-checkbox.css index 9e1e484c..1bd67104 100644 --- a/src/components/checkbox/bl-checkbox.css +++ b/src/components/checkbox-group/checkbox/bl-checkbox.css @@ -3,6 +3,10 @@ vertical-align: middle; } +:host * { + outline:none; +} + label { display: flex; align-items: center; @@ -31,7 +35,8 @@ input { font-size: var(--bl-font-size-2xs); } -:host([checked]) .label { +:host([checked]) .label, +:host(:hover) .label { color: var(--bl-color-primary); } @@ -40,6 +45,11 @@ input { border: none; } +:host([disabled]) { + cursor: not-allowed; + pointer-events: none; +} + :host([disabled]) .check-mark, :host([disabled]) .label { color: var(--bl-color-content-passive); @@ -48,3 +58,7 @@ input { :host([disabled]) .check-mark { background-color: var(--bl-color-secondary-background); } + +:host(:not([disabled])) input:focus-visible + .check-mark { + box-shadow: 0 0 0 1px white, 0 0 0 3px var(--bl-color-primary); +} diff --git a/src/components/checkbox/bl-checkbox.stories.mdx b/src/components/checkbox-group/checkbox/bl-checkbox.stories.mdx similarity index 84% rename from src/components/checkbox/bl-checkbox.stories.mdx rename to src/components/checkbox-group/checkbox/bl-checkbox.stories.mdx index f602bb61..72316bcd 100644 --- a/src/components/checkbox/bl-checkbox.stories.mdx +++ b/src/components/checkbox-group/checkbox/bl-checkbox.stories.mdx @@ -20,7 +20,13 @@ import { Meta, Canvas, ArgsTable, Story } from '@storybook/addon-docs'; indeterminate: { control: 'boolean', default: false - } + }, + value: { + control: 'text', + }, + required: { + control: 'boolean', + }, }} /> @@ -28,7 +34,12 @@ export const CheckboxTemplate = (args) => html` ${args.label} + name='${ifDefined(args.name)}' + value='${ifDefined(args.value)}' + ?indeterminate=${args.indeterminate} + ?required=${args.required}> + ${args.label} + `; # Checkbox @@ -101,6 +112,16 @@ Disabled state can be set via `disabled` attribute. A checkbox can be `disabled` +## Form + +Provide the name and the value of the checkbox element, so that its value can be set on the form element + + + + {CheckboxTemplate.bind({})} + + + ## Reference diff --git a/src/components/checkbox/bl-checkbox.test.ts b/src/components/checkbox-group/checkbox/bl-checkbox.test.ts similarity index 94% rename from src/components/checkbox/bl-checkbox.test.ts rename to src/components/checkbox-group/checkbox/bl-checkbox.test.ts index ec1b80d0..02664768 100644 --- a/src/components/checkbox/bl-checkbox.test.ts +++ b/src/components/checkbox-group/checkbox/bl-checkbox.test.ts @@ -14,7 +14,10 @@ describe('bl-checkbox', () => { el, ` @@ -40,7 +43,7 @@ describe('bl-checkbox', () => { }); it('should be rendered with check icon when checkbox checked', async () => { - const el = await fixture(html``); + const el = await fixture(html``); const iconEl = el.shadowRoot?.querySelector('bl-icon'); expect(iconEl?.getAttribute('name')).to.eq('check'); diff --git a/src/components/checkbox-group/checkbox/bl-checkbox.ts b/src/components/checkbox-group/checkbox/bl-checkbox.ts new file mode 100644 index 00000000..f08b9877 --- /dev/null +++ b/src/components/checkbox-group/checkbox/bl-checkbox.ts @@ -0,0 +1,159 @@ +import { FormControlMixin } from '@open-wc/form-control'; +import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; +import { customElement, property, query } from 'lit/decorators.js'; +import { live } from 'lit/directives/live.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import 'element-internals-polyfill'; +import { event, EventDispatcher } from '../../../utilities/event'; +import '../../icon/bl-icon'; +import type BlCheckboxGroup from '../bl-checkbox-group'; +import style from './bl-checkbox.css'; +import { blCheckboxGroupTag, blChangeEventName } from '../bl-checkbox-group'; + +export const blCheckboxTag = 'bl-checkbox'; + +/** + * @tag bl-checkbox + * @summary Baklava Checkbox component + */ +@customElement(blCheckboxTag) +export default class BlCheckbox extends FormControlMixin(LitElement) { + static get styles(): CSSResultGroup { + return [style]; + } + + /** + * Sets the checked state for checkbox + */ + @property({ type: Boolean, reflect: true }) + checked = false; + + /** + * Sets the checkbox value + */ + @property() + value: string; + + /** + * Sets checkbox as required + */ + @property({ type: Boolean, reflect: true }) + required = false; + + /** + * Sets the disabled state for checkbox + */ + @property({ type: Boolean, reflect: true }) + disabled = false; + + /** + * Sets the indeterminate state for checkbox + */ + @property({ type: Boolean, reflect: true }) + indeterminate = false; + + /** + * Fires whenever user change the value of the checkbox. + */ + @event('bl-checkbox-change') private onChange: EventDispatcher; + + /** + * Fires when checkbox is focused + */ + @event('bl-focus') private onFocus: EventDispatcher; + + /** + * Fires when checkbox is blurred + */ + @event('bl-blur') private onBlur: EventDispatcher; + + @query('[type=checkbox]') checkboxElement: HTMLElement; + + protected field: BlCheckboxGroup | null; + + connectedCallback(): void { + super.connectedCallback(); + + this.field = this.closest(blCheckboxGroupTag); + this.field?.addEventListener(blChangeEventName, this.handleFieldValueChange); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.field?.removeEventListener(blChangeEventName, this.handleFieldValueChange); + } + + updated(changedProperties: Map): void { + if (changedProperties.has('checked') && this.required && this.checked) { + this.setValue(this.value); + } + } + + update(changedProperties: Map) { + super.update(changedProperties); + if (this.indeterminate && this.checked) { + this.checked = false; + this.requestUpdate('checked', true); + } + } + + /** + * Focuses this option + */ + focus() { + this.checkboxElement.tabIndex = 0; + this.checkboxElement.focus(); + this.onFocus(this.value); + } + + /** + * Blurs from this option + */ + blur() { + this.onBlur(this.value); + if (!this.field) return; + this.checkboxElement.tabIndex = -1; + } + + private handleChange(event: CustomEvent) { + const target = event.target as HTMLInputElement; + this.checked = target.checked; + this.onChange(target.checked); + this.indeterminate = false; + } + + private handleFieldValueChange = (event: CustomEvent>) => { + this.checked = event.detail.includes(this.value); + }; + + render(): TemplateResult { + let icon = ''; + if (this.checked) icon = 'check'; + if (this.indeterminate) icon = 'minus'; + + return html` + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + [blCheckboxTag]: BlCheckbox; + } +} diff --git a/src/components/checkbox/bl-checkbox.ts b/src/components/checkbox/bl-checkbox.ts deleted file mode 100644 index 2866dddd..00000000 --- a/src/components/checkbox/bl-checkbox.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { CSSResultGroup, html, LitElement, TemplateResult } from 'lit'; -import { customElement, property } from 'lit/decorators.js'; -import { live } from 'lit/directives/live.js'; -import { event, EventDispatcher } from '../../utilities/event'; -import '../icon/bl-icon'; -import style from './bl-checkbox.css'; - -/** - * @tag bl-checkbox - * @summary Baklava Checkbox component - */ -@customElement('bl-checkbox') -export default class BlCheckbox extends LitElement { - static get styles(): CSSResultGroup { - return [style]; - } - - /** - * Sets the checked state for checkbox - */ - @property({ type: Boolean, reflect: true }) - checked = false; - - /** - * Sets the disabled state for checkbox - */ - @property({ type: Boolean, reflect: true }) - disabled = false; - - /** - * Sets the indeterminate state for checkbox - */ - @property({ type: Boolean, reflect: true }) - indeterminate = false; - - /** - * Fires whenever user change the value of the checkbox. - */ - @event('bl-checkbox-change') private onChange: EventDispatcher; - - handleChange(event: CustomEvent) { - const target = event.target as HTMLInputElement; - this.checked = target.checked; - this.onChange(target.checked); - this.indeterminate = false; - } - - update(changedProperties: Map) { - super.update(changedProperties); - if (this.indeterminate && this.checked) { - this.checked = false; - this.requestUpdate('checked', true); - } - } - - render(): TemplateResult { - let icon = ''; - if (this.checked) icon = 'check'; - if (this.indeterminate) icon = 'minus'; - - return html` - - `; - } -} - -declare global { - interface HTMLElementTagNameMap { - 'bl-checkbox': BlCheckbox; - } -} diff --git a/src/components/select/bl-select.test.ts b/src/components/select/bl-select.test.ts index d8545dbc..59b52bbd 100644 --- a/src/components/select/bl-select.test.ts +++ b/src/components/select/bl-select.test.ts @@ -1,7 +1,7 @@ import BlSelect from './bl-select'; import { assert, expect, fixture, html, oneEvent } from '@open-wc/testing'; import { BlIcon, BlSelectOption } from '../../baklava'; -import BlCheckbox from '../checkbox/bl-checkbox'; +import BlCheckbox from '../checkbox-group/checkbox/bl-checkbox'; describe('bl-select', () => { it('is defined', () => {