(
+ 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``;
+ }
+}
+
+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
+
+
+
## 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', () => {