Skip to content

Commit

Permalink
feat: Implement checkbox component (#100)
Browse files Browse the repository at this point in the history
* feat: Implement checkbox component

* tests: Add tests for checkbox component

* docs: Add route for Checkbox component

* feat(field): Don't render label if not provided

* chore: PR review

* chore: Update phone-field description
  • Loading branch information
TSenter authored Aug 26, 2024
1 parent 3446818 commit 32dc8bb
Show file tree
Hide file tree
Showing 9 changed files with 337 additions and 12 deletions.
117 changes: 117 additions & 0 deletions apps/ember-test-app/app/components/f/form/checkbox.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { array, fn } from '@ember/helper';
import { action, set } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import Checkbox from '@nrg-ui/ember/components/form/checkbox';
import bind from '@nrg-ui/ember/helpers/bind';
import FreestyleUsage from 'ember-freestyle/components/freestyle/usage';
import FreestyleSection from 'ember-freestyle/components/freestyle-section';

import CodeBlock from '../../code-block';

// TypeScript doesn't recognize that this function is used in the template
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function log(...msg: string[]) {
console.log(msg.join(' '));
}

class Model {
@tracked
property = '';
}

export default class extends Component {
model = new Model();

@tracked
class = '';

@tracked
disabled = false;

@tracked
inline;

@tracked
label = 'Checkbox label';

@tracked
type = 'checkbox';

@action
update(key: string, value: unknown) {
set(this, key, value);
}

<template>
<FreestyleSection @name="Checkbox" as |Section|>
<Section.subsection @name="Basic">
<FreestyleUsage>
<:example>
<Checkbox
class={{this.class}}
@binding={{bind this.model "property"}}
@disabled={{this.disabled}}
@inline={{this.inline}}
@label={{this.label}}
@type={{this.type}}
@onChange={{fn log "The value changed to"}}
/>
</:example>
<:api as |Args|>
<Args.String
@name="class"
@description="The class to apply to the group input. Note that this is not an argument but rather a class applied directly to the input"
@value={{this.class}}
@onInput={{fn this.update "class"}}
@options={{this.classOptions}}
/>
<Args.Bool
@name="binding"
@description="Create a two-way binding with the value"
@value={{this.model.property}}
@onInput={{fn this.update "model.property"}}
/>
<Args.Bool
@name="disabled"
@defaultValue={{false}}
@description="When true, the input will be disabled"
@value={{this.disabled}}
@onInput={{fn this.update "disabled"}}
/>
<Args.Bool
@name="inline"
@defaultValue={{false}}
@description="When true, the input will be displayed inline"
@value={{this.inline}}
@onInput={{fn this.update "inline"}}
/>
<Args.String
@name="label"
@description="The label to display next to the checkbox"
@value={{this.label}}
@onInput={{fn this.update "label"}}
/>
<Args.String
@defaultValue="checkbox"
@name="type"
@description="The type of checkbox to render"
@options={{array "checkbox" "switch"}}
@value={{this.type}}
@onInput={{fn this.update "type"}}
/>
<Args.Action
@name="onChange"
@description="The action to call when the value changes"
>
<CodeBlock
@lang="typescript"
@code="(newValue: boolean) => unknown"
/>
</Args.Action>
</:api>
</FreestyleUsage>
</Section.subsection>
</FreestyleSection>
</template>
}
2 changes: 1 addition & 1 deletion apps/ember-test-app/app/components/f/form/phone-field.gts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export default class extends Component {
<:api as |Args|>
<Args.String
@name="class"
@description="The class to apply to the button. Note that this is not an argument but rather a class applied directly to the button"
@description="The class to apply to the input. Note that this is not an argument but rather a class applied directly to the input"
@value={{this.class}}
@onInput={{fn this.update "class"}}
@options={{this.classOptions}}
Expand Down
1 change: 1 addition & 0 deletions apps/ember-test-app/app/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Router.map(function () {
this.route('card');
this.route('header');
this.route('form', function () {
this.route('checkbox');
this.route('phone-field');
this.route('radio-group');
this.route('select');
Expand Down
1 change: 1 addition & 0 deletions apps/ember-test-app/app/templates/components.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<Sidebar.Group @header="Forms">
<:group as |Item|>
<Item @name="Form" @route="components.form.index" />
<Item @name="Checkbox" @route="components.form.checkbox" />
<Item @name="Radio Group" @route="components.form.radio-group" />
<Item @name="Select" @route="components.form.select" />
<Item @name="Text Area" @route="components.form.text-area" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{{page-title "Checkbox"}}

<div class="container mx-auto">
<F::Form::Checkbox />
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { click, render, settled } from '@ember/test-helpers';
import { tracked } from '@glimmer/tracking';
import Checkbox from '@nrg-ui/ember/components/form/checkbox';
import bind from '@nrg-ui/ember/helpers/bind';
import { setupRenderingTest } from 'ember-test-app/tests/helpers';
import { module, test } from 'qunit';

module('Integration | Component | form/checkbox', function (hooks) {
setupRenderingTest(hooks);

class Model {
@tracked
value: boolean = false;
}

test('it renders', async function (assert) {
const model = new Model();
await render(<template>
<Checkbox
@binding={{bind model "value"}}
@id="my-id"
@label="This is a checkbox"
/>
</template>);

assert
.dom('.form-check > input')
.hasAttribute('role', 'checkbox')
.hasAttribute('type', 'checkbox')
.hasClass('form-check-input')
.hasValue('false')
.isNotChecked();

const labelId = this.element.querySelector('.form-check > input').id;

assert
.dom('.form-check > label')
.hasAttribute('for', labelId)
.hasText('This is a checkbox');

model.value = true;
await settled();

assert.dom('.form-check > input').hasValue('true').isChecked();
});

test('it renders (switch)', async function (assert) {
const model = new Model();
await render(<template>
<Checkbox
@binding={{bind model "value"}}
@id="my-id"
@label="This is a checkbox"
@type="switch"
/>
</template>);

assert.dom('div').hasClass('form-switch');

assert
.dom('.form-check > input')
.hasAttribute('role', 'switch')
.hasAttribute('type', 'checkbox')
.hasClass('form-check-input')
.hasValue('false')
.isNotChecked();

const labelId = this.element.querySelector('.form-check > input').id;

assert
.dom('.form-check > label')
.hasAttribute('for', labelId)
.hasText('This is a checkbox');

model.value = true;
await settled();

assert.dom('.form-check > input').hasValue('true').isChecked();
});

test('it works', async function (assert) {
const model = new Model();
await render(<template>
<Checkbox @binding={{bind model "value"}} @label="This is a checkbox" />
</template>);

assert.dom('.form-check > input').hasValue('false').isNotChecked();

model.value = true;
await settled();

assert.dom('.form-check > input').hasValue('true').isChecked();

await click('.form-check > input');

assert.dom('.form-check > input').hasValue('false').isNotChecked();
assert.false(model.value);

await click('.form-check > input');

assert.dom('.form-check > input').hasValue('true').isChecked();
assert.true(model.value);
});
});
1 change: 1 addition & 0 deletions packages/ember/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@
"./components/card.js": "./dist/_app_/components/card.js",
"./components/footer.js": "./dist/_app_/components/footer.js",
"./components/form/bound-value.js": "./dist/_app_/components/form/bound-value.js",
"./components/form/checkbox.js": "./dist/_app_/components/form/checkbox.js",
"./components/form/field.js": "./dist/_app_/components/form/field.js",
"./components/form/index.js": "./dist/_app_/components/form/index.js",
"./components/form/phone-field.js": "./dist/_app_/components/form/phone-field.js",
Expand Down
82 changes: 82 additions & 0 deletions packages/ember/src/components/form/checkbox.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { on } from '@ember/modifier';
import { action } from '@ember/object';

import BoundValue from './bound-value.ts';

export interface CheckboxSignature {
Element: HTMLInputElement;
Args: {
describedBy?: string;
disabled?: boolean;
id?: string;
inline?: boolean;
isInvalid?: boolean;
isWarning?: boolean;
label?: string;
type?: 'checkbox' | 'switch';
};
Blocks: {
default: [];
};
}

export default class FormCheckbox extends BoundValue<
CheckboxSignature,
boolean
> {
get classList() {
const classes = ['form-check-input'];

if (this.args.isInvalid) {
classes.push('is-invalid');
} else if (this.args.isWarning) {
classes.push('is-warning');
}

return classes.join(' ');
}

get divClassList() {
const classList = ['form-check'];

if (this.isSwitch) {
classList.push('form-switch');
}

if (this.args.inline) {
classList.push('form-check-inline');
}

return classList.join(' ');
}

get isSwitch() {
return this.args.type === 'switch';
}

@action
change(evt: Event) {
const target = evt.target as HTMLInputElement;
this.onChange(target.checked);
}

<template>
<div class={{this.divClassList}}>
<input
aria-describedby={{@describedBy}}
checked={{this.value}}
class={{this.classList}}
disabled={{@disabled}}
id={{@id}}
role={{if this.isSwitch "switch" "checkbox"}}
type="checkbox"
value={{this.value}}
{{on "change" this.change}}
...attributes
/>
<label class="form-check-label" for={{@id}}>
{{@label}}
</label>
</div>
</template>
}
Loading

0 comments on commit 32dc8bb

Please sign in to comment.