Skip to content

Commit

Permalink
feat(switch): add component (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kordrad authored Jun 25, 2024
1 parent 248c995 commit a6028d5
Show file tree
Hide file tree
Showing 9 changed files with 635 additions and 29 deletions.
389 changes: 362 additions & 27 deletions projects/cli/documentation.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion projects/cli/schematics/components/components-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export interface ComponentGeneratorSchema {
components: ('avatar' | 'badge' | 'pin' | 'tag')[];
components: ('avatar' | 'badge' | 'pin' | 'switch' | 'tag')[];
path: string;
}
10 changes: 10 additions & 0 deletions projects/cli/schematics/components/files/switch/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core';

import { ZenSwitchComponent } from './zen-switch.component';

@NgModule({
imports: [ZenSwitchComponent],
exports: [ZenSwitchComponent],
})
export class ZenSwitchModule {}
export * from './zen-switch.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<button
class="switch"
[attr.aria-checked]="checked()"
[attr.aria-disabled]="disabled()"
[disabled]="disabled()"
(click)="onToggle()"
(keydown)="onKeyDown($event)"
role="switch"
>
<span class="slider"></span>
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Define SCSS variables for better maintainability
$switch-width: 36px;
$switch-height: 22px;
$slider-size: 16px;
$transition-duration: 0.4s;
$switch-bg-color: grey;
$switch-checked-bg-color: green;
$switch-disabled-bg-color: lightgrey;
$slider-bg-color: white;

// Other
$slider-padding: calc(($switch-height - $slider-size) / 2);
$slider-transform-distance: calc(
$switch-width - $slider-size - $slider-padding * 2
);

/* Switch container */
.switch {
position: relative;
display: inline-block;
width: $switch-width;
height: $switch-height;
background-color: $switch-bg-color;
border: none;
border-radius: 99999px; // force perfectly rounded
cursor: pointer;
transition: background-color $transition-duration;

/*
outline: none;
&:focus {
box-shadow: 0 0 3px 2px rgba(21, 156, 228, 0.4);
}
*/
}

/* Slider */
.slider {
position: absolute;
top: $slider-padding;
left: $slider-padding;
height: $slider-size;
width: $slider-size;
background-color: $slider-bg-color;
transition: transform $transition-duration;
border-radius: 50%;
}

/* Styles when the button is checked */
.switch[aria-checked='true'] {
background-color: $switch-checked-bg-color;

.slider {
transform: translateX($slider-transform-distance);
}
}

/* Styles when the button is disabled */
.switch[aria-disabled='true'] {
background-color: $switch-disabled-bg-color;
cursor: not-allowed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { ZenSwitchComponent } from './zen-switch.component';

describe('SwitchComponent', () => {
let component: ZenSwitchComponent;
let fixture: ComponentFixture<ZenSwitchComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ZenSwitchComponent],
}).compileComponents();

fixture = TestBed.createComponent(ZenSwitchComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});

it('should create', () => {
expect(component).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { NgIf } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
forwardRef,
model,
} from '@angular/core';
import {
ControlValueAccessor,
FormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms';

type OnChangeFn = (value: boolean) => void;
type OnTouchedFn = () => void;

/**
* ZenSwitchComponent is a custom switch component that implements ControlValueAccessor to work seamlessly with Angular forms.
*
* @example <zen-switch />
*
* @export
* @class ZenSwitchComponent
* @implements {ControlValueAccessor}
*
* @license BSD-2-Clause
* @author Konrad Stępień <kord.stp@gmail.com>
* @link https://github.com/Kordrad/ng-zen
*/
@Component({
selector: 'zen-switch',
standalone: true,
templateUrl: './zen-switch.component.html',
styleUrl: './zen-switch.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ZenSwitchComponent),
multi: true,
},
],
imports: [FormsModule, NgIf],
})
export class ZenSwitchComponent implements ControlValueAccessor {
/** Model for the checked state of the switch. */
checked = model<boolean>(false);

/** Model for the disabled state of the switch. */
disabled = model<boolean>(false);

/** @ignore */
private onChange: OnChangeFn = () => {};
/** @ignore */
private onTouched: OnTouchedFn = () => {};

/**
* Writes a new value to the component.
* @ignore
*/
writeValue(value: boolean): void {
this.checked.set(value);
}

/**
* Registers a function to be called when the value changes.
* @ignore
*/
registerOnChange(fn: OnChangeFn): void {
this.onChange = fn;
}

/**
* Registers a function to be called when the component is touched.
* @ignore
*/
registerOnTouched(fn: OnTouchedFn): void {
this.onTouched = fn;
}

/**
* Sets the disabled state of the component.
* @ignore
*/
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}

/**
* Toggles the switch value and notifies the change.
*/
onToggle(check?: boolean): void {
if (this.disabled()) return;

const value = check ?? !this.checked();

this.checked.set(value);
this.onChange(value);
this.onTouched();
}

/**
* Handles keyboard events for accessibility.
*/
onKeyDown(event: KeyboardEvent): void {
switch (event.code) {
case 'Enter':
case 'Space': {
event.preventDefault();
this.onToggle();
break;
}
case 'ArrowRight': {
this.onToggle(true);
break;
}
case 'ArrowLeft': {
this.onToggle(false);
break;
}
}
}
}
2 changes: 1 addition & 1 deletion projects/cli/schematics/components/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"type": "array",
"items": {
"type": "string",
"enum": ["avatar", "badge", "pin", "tag"]
"enum": ["avatar", "badge", "pin", "switch", "tag"]
},
"multiselect": true,
"x-prompt": "Which component should be generated?"
Expand Down
43 changes: 43 additions & 0 deletions projects/cli/stories/components/switch.stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { FormsModule } from '@angular/forms';
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';

import { ZenSwitchComponent } from '../../schematics/components/files/switch';

export default {
title: 'Components/Switch',
component: ZenSwitchComponent,
tags: ['autodocs'],
decorators: [
moduleMetadata({
imports: [FormsModule],
}),
],
render: args => ({ props: { ...args } }),
} satisfies Meta<ZenSwitchComponent>;

type Story = StoryObj<ZenSwitchComponent>;

export const Default: Story = {
render: () => ({
template: `
<zen-switch />
`,
}),
};

export const Disabled: Story = {
render: () => ({
template: `<zen-switch [disabled]="true" />`,
}),
};

export const Checked: Story = {
render: () => ({
template: `
<zen-switch [ngModel]="true" />
<br/>
<zen-switch [checked]="true" />
`,
}),
};

0 comments on commit a6028d5

Please sign in to comment.